# Copyright The OpenTelemetry Authors
# SPDX-License-Identifier: Apache-2.0

# pylint: disable=no-member,invalid-name,too-many-lines

from __future__ import annotations

import logging
from collections import defaultdict
from collections.abc import Callable, Iterable
from pathlib import Path
from typing import Final

from google.protobuf import descriptor_pb2 as descriptor
from google.protobuf.compiler import plugin_pb2 as plugin

from opentelemetry.codegen.json.types import (
    get_default_value,
    get_json_allowed_types,
    get_python_type,
    is_bytes_type,
    is_hex_encoded_field,
    is_int64_type,
    to_json_field_name,
)
from opentelemetry.codegen.json.writer import CodeWriter

_logger = logging.getLogger(__name__)

CODEC_MODULE_NAME: Final[str] = "_json_codec"


class OtlpJsonGenerator:
    """
    Generates Python dataclasses and JSON serialization/deserialization code
    from protobuf descriptors.
    """

    def __init__(
        self,
        request: plugin.CodeGeneratorRequest,
        package_transform: Callable[[str], str],
        version: str,
    ) -> None:
        """
        Initialize the generator.

        Args:
            request: Protobuf compiler plugin request
            package_transform: A callable that transforms the proto file path.
            version: Version string for the generated code.
        """
        self._request = request
        self._package_transform = package_transform
        self._version = version
        self._generated_files: dict[str, str] = {}
        self._common_root: str = ""
        self._file_to_proto: dict[str, descriptor.FileDescriptorProto] = {f.name: f for f in request.proto_file}
        self._fqn_to_file: dict[str, str] = {}
        self._fqn_to_class_path: dict[str, str] = {}
        self._file_dependencies: dict[str, list[str]] = {f.name: list(f.dependency) for f in request.proto_file}

        for proto_file in request.proto_file:
            self._index_file(proto_file)

    def generate_all(self) -> dict[str, str]:
        """
        Generate Python code for all proto files and support modules.

        Returns:
            Dictionary mapping output file paths to generated code
        """
        files_to_generate = self._request.file_to_generate
        file_to_output = {proto_file: self._transform_proto_path(proto_file) for proto_file in files_to_generate}

        if not file_to_output:
            return {}

        self._common_root = _find_common_root(file_to_output.values())

        for proto_file in files_to_generate:
            file_desc = self._file_to_proto[proto_file]
            code = self._generate_file(file_desc)
            output_path = file_to_output[proto_file]
            self._generated_files[output_path] = code

        codec_path = f"{self._common_root}/{CODEC_MODULE_NAME}.py"
        self._generated_files[codec_path] = _load_codec_source()

        version_init_path = f"{self._common_root}/version/__init__.py"
        version_writer = CodeWriter(indent_size=4)
        self._generate_header(version_writer)
        version_writer.writemany(f'__version__ = "{self._version}"', "")
        self._generated_files[version_init_path] = version_writer.to_string()

        self._ensure_init_files()

        return self._generated_files

    def _index_file(self, file_desc: descriptor.FileDescriptorProto) -> None:
        """
        Index all messages and enums in the file for usage during generation.

        Args:
            file_desc: File descriptor to index
        """
        package = file_desc.package
        for enum_desc in file_desc.enum_type:
            fqn = f"{package}.{enum_desc.name}" if package else enum_desc.name
            self._fqn_to_file[fqn] = file_desc.name
            self._fqn_to_class_path[fqn] = enum_desc.name
        for msg_desc in file_desc.message_type:
            self._index_message(msg_desc, package, file_desc.name, None)

    def _index_message(
        self,
        msg_desc: descriptor.DescriptorProto,
        package: str,
        file_name: str,
        parent_path: str | None,
    ) -> None:
        """
        Recursively index a message and its nested types.

        Args:
            msg_desc: Message descriptor to index
            package: Protobuf package name for the message
            file_name: Proto file where the message is defined
            parent_path: Full parent class path for nested messages
        """
        current_path = f"{parent_path}.{msg_desc.name}" if parent_path else msg_desc.name
        fqn = f"{package}.{current_path}" if package else current_path
        self._fqn_to_file[fqn] = file_name
        self._fqn_to_class_path[fqn] = current_path

        for enum_desc in msg_desc.enum_type:
            enum_fqn = f"{fqn}.{enum_desc.name}"
            self._fqn_to_file[enum_fqn] = file_name
            self._fqn_to_class_path[enum_fqn] = f"{current_path}.{enum_desc.name}"

        for nested_msg in msg_desc.nested_type:
            if not nested_msg.options.map_entry:
                self._index_message(nested_msg, package, file_name, current_path)

    def _ensure_init_files(self) -> None:
        """
        Ensure that every directory in the output path contains an __init__.py file.
        """
        dirs = set()
        for path in self._generated_files:
            p = Path(path)
            for parent in p.parents:
                parent_str = str(parent)
                # Skip '.', root, and the 'opentelemetry' namespace directory
                if parent_str in (".", "/", "opentelemetry"):
                    continue
                dirs.add(parent_str)

        for d in dirs:
            init_path = f"{d}/__init__.py"
            if init_path not in self._generated_files:
                self._generated_files[init_path] = ""

    def _get_codec_module_path(self) -> str:
        """
        Get the absolute module path for the codec module.

        Returns:
            Absolute module path as a string
        """
        return f"{self._common_root.replace('/', '.')}.{CODEC_MODULE_NAME}" if self._common_root else CODEC_MODULE_NAME

    def _transform_proto_path(self, proto_path: str) -> str:
        """
        Transform proto file path to output Python file path.

        Example: 'opentelemetry/proto/trace/v1/trace.proto'
              -> 'opentelemetry/proto_json/trace/v1/trace.py'

        Args:
            proto_path: Original .proto file path

        Returns:
            Transformed .py file path
        """
        transformed = self._package_transform(proto_path)
        if transformed.endswith(".proto"):
            transformed = transformed[:-6] + ".py"
        return transformed

    def _get_module_path(self, proto_file: str) -> str:
        """
        Convert a proto file path to its transformed Python module path.

        Example: 'opentelemetry/proto/common/v1/common.proto'
              -> 'opentelemetry.proto_json.common.v1.common'

        Args:
            proto_file: Original .proto file path

        Returns:
            Python module path (dot-separated)
        """
        transformed = self._transform_proto_path(proto_file)
        if transformed.endswith(".py"):
            transformed = transformed[:-3]
        return transformed.replace("/", ".")

    def _generate_file(self, file_desc: descriptor.FileDescriptorProto) -> str:
        """
        Generate complete Python file for a proto file.

        Args:
            file_desc: File descriptor

        Returns:
            Generated Python code as string
        """
        writer = CodeWriter(indent_size=4)
        proto_file = file_desc.name

        self._generate_header(writer, proto_file)
        self._generate_imports(writer, proto_file, self._file_has_enums(file_desc))
        self._generate_enums_for_file(writer, file_desc.enum_type)
        self._generate_messages_for_file(writer, proto_file, file_desc.message_type)
        writer.blank_line()

        return writer.to_string()

    def _file_has_enums(self, file_desc: descriptor.FileDescriptorProto) -> bool:
        """
        Check if the file or any of its messages (recursively) contain enums.

        Args:
            file_desc: File descriptor to check
        Returns:
            True if any enums are found, False otherwise
        """
        if file_desc.enum_type:
            return True
        for msg in file_desc.message_type:
            if self._msg_has_enums(msg):
                return True
        return False

    def _msg_has_enums(self, msg_desc: descriptor.DescriptorProto) -> bool:
        """
        Recursively check if the message or any of its nested messages contain enums.

        Args:
            msg_desc: Message descriptor to check
        Returns:
            True if any enums are found, False otherwise
        """
        if msg_desc.enum_type:
            return True
        for nested in msg_desc.nested_type:
            if self._msg_has_enums(nested):
                return True
        return False

    @classmethod
    def _generate_header(cls, writer: CodeWriter, proto_file: str = "") -> None:
        """
        Generate file header with license and metadata.

        Args:
            writer: Code writer instance
            proto_file: Original proto file path (optional)
        """
        writer.comment(
            [
                "Copyright The OpenTelemetry Authors",
                "SPDX-License-Identifier: Apache-2.0",
            ]
        )
        writer.blank_line()
        if proto_file:
            writer.comment(f'AUTO-GENERATED from "{proto_file}"')
            writer.comment("DO NOT EDIT MANUALLY")
            writer.blank_line()

    def _generate_imports(
        self,
        writer: CodeWriter,
        proto_file: str,
        include_enum: bool,
    ) -> None:
        """
        Generate all necessary import statements.

        Args:
            writer: Code writer instance
            proto_file: Original proto file path
            include_enum: Whether to include the enum module import
        """
        writer.import_("__future__", "annotations")
        writer.blank_line()

        std_imports = [
            "builtins",
            "dataclasses",
            "functools",
            "typing",
        ]
        if include_enum:
            std_imports.append("enum")

        for module in sorted(std_imports):
            writer.import_(module)

        writer.blank_line()

        writer.assignment(
            "_dataclass",
            "functools.partial(dataclasses.dataclass, slots=True)",
        )
        writer.blank_line()

        # Collect all imports needed
        imports = self._collect_imports(proto_file)
        imports.add(f"import {self._get_codec_module_path()}")

        # Generate cross file imports
        if imports:
            for import_info in sorted(imports):
                writer.writeln(import_info)
            writer.blank_line()
        writer.blank_line()

    def _collect_imports(self, proto_file: str) -> set[str]:
        """
        Collect all import statements needed for cross file references.

        Args:
            proto_file: Current proto file path

        Returns:
            Set of import statement strings
        """
        return set(
            "import " + self._get_module_path(dep_file) for dep_file in self._file_dependencies.get(proto_file, [])
        )

    def _generate_enums_for_file(
        self,
        writer: CodeWriter,
        enum_descs: Iterable[descriptor.EnumDescriptorProto],
    ) -> None:
        """
        Generate all enums for a file (top level and nested).

        Args:
            writer: Code writer instance
            enum_descs: List of top level enums
        """
        for enum_desc in enum_descs:
            self._generate_enum_class(writer, enum_desc)
            writer.blank_line()

    def _generate_messages_for_file(
        self,
        writer: CodeWriter,
        proto_file: str,
        msg_descs: Iterable[descriptor.DescriptorProto],
    ) -> None:
        """
        Generate all message classes for a file.

        Args:
            writer: Code writer instance
            proto_file: Original proto file path
            msg_descs: List of top level messages
        """
        for i, message in enumerate(msg_descs):
            if i:
                writer.blank_line(2)

            self._generate_message_class(writer, proto_file, message)

    def _generate_message_class(
        self,
        writer: CodeWriter,
        proto_file: str,
        msg_desc: descriptor.DescriptorProto,
        parent_path: str | None = None,
    ) -> None:
        """
        Generate a complete dataclass for a protobuf message.

        Args:
            writer: Code writer instance
            proto_file: Original proto file path
            msg_desc: Message descriptor
            parent_path: Full parent class path for nested messages
        """
        current_path = f"{parent_path}.{msg_desc.name}" if parent_path else msg_desc.name
        codec = self._get_codec_module_path()
        with writer.dataclass(
            msg_desc.name,
            bases=(f"{codec}.JsonMessage",),
            decorators=("typing.final",),
            decorator_name="_dataclass",
        ):
            if msg_desc.field or msg_desc.nested_type or msg_desc.enum_type:
                writer.docstring([f"Generated from protobuf message {msg_desc.name}"])
                writer.blank_line()

            for enum_desc in msg_desc.enum_type:
                self._generate_enum_class(writer, enum_desc)
                writer.blank_line()

            for nested_desc in msg_desc.nested_type:
                if not nested_desc.options.map_entry:
                    self._generate_message_class(writer, proto_file, nested_desc, current_path)
                    writer.blank_line()

            if msg_desc.field:
                for field_desc in msg_desc.field:
                    self._generate_field(writer, proto_file, field_desc)
            else:
                writer.pass_()

            writer.blank_line()
            self._generate_to_dict(writer, msg_desc)
            writer.blank_line()
            self._generate_from_dict(writer, proto_file, msg_desc, current_path)

    @classmethod
    def _generate_enum_class(cls, writer: CodeWriter, enum_desc: descriptor.EnumDescriptorProto) -> None:
        """
        Generate an IntEnum class for a protobuf enum.

        Args:
            writer: Code writer instance
            enum_desc: Enum descriptor
        """
        with writer.enum(
            enum_desc.name,
            enum_type="enum.IntEnum",
            decorators=("typing.final",),
        ):
            writer.docstring([f"Generated from protobuf enum {enum_desc.name}"])
            writer.blank_line()

            if enum_desc.value:
                for val_desc in enum_desc.value:
                    writer.enum_member(val_desc.name, val_desc.number)
            else:
                writer.pass_()

    def _generate_field(
        self,
        writer: CodeWriter,
        proto_file: str,
        field_desc: descriptor.FieldDescriptorProto,
    ) -> None:
        """
        Generate a dataclass field.

        Args:
            writer: Code writer instance
            proto_file: Original proto file path
            field_desc: Field descriptor
        """
        type_hint = self._get_field_type_hint(proto_file, field_desc)
        writer.field(
            field_desc.name,
            type_hint,
            default=self._get_field_default(field_desc),
        )

    def _generate_to_dict(
        self,
        writer: CodeWriter,
        msg_desc: descriptor.DescriptorProto,
    ) -> None:
        """
        Generate a to_dict() method that converts the dataclass instance to a dictionary

        Args:
            writer: Code writer instance
            msg_desc: Message descriptor for the class being generated
        """
        with writer.method(
            "to_dict",
            ["self"],
            return_type="builtins.dict[builtins.str, typing.Any]",
        ):
            writer.docstring(
                [
                    "Convert this message to a dictionary with lowerCamelCase keys.",
                    "",
                    "Returns:",
                    "    Dictionary representation following OTLP JSON encoding",
                ]
            )
            writer.assignment("_result", "{}")

            # Separate fields into oneof groups and standalone fields
            oneof_groups: dict[int, list[descriptor.FieldDescriptorProto]] = defaultdict(list)
            standalone_fields: list[descriptor.FieldDescriptorProto] = []

            for field in msg_desc.field:
                if field.HasField("oneof_index") and not field.proto3_optional:
                    oneof_groups[field.oneof_index].append(field)
                else:
                    standalone_fields.append(field)

            for field in standalone_fields:
                with writer.if_(f"self.{field.name} is not None" if field.proto3_optional else f"self.{field.name}"):
                    self._generate_serialization_statements(writer, field, "_result")

            for group_index in sorted(oneof_groups.keys()):
                group_fields = oneof_groups[group_index]
                for i, field in enumerate(reversed(group_fields)):
                    condition = f"self.{field.name} is not None"
                    context = writer.elif_(condition) if i else writer.if_(condition)

                    with context:
                        self._generate_serialization_statements(writer, field, "_result")

            writer.return_("_result")

    def _generate_from_dict(
        self,
        writer: CodeWriter,
        proto_file: str,
        msg_desc: descriptor.DescriptorProto,
        current_path: str,
    ) -> None:
        """
        Generate a from_dict() class method that creates an instance from a dictionary.

        Args:
            writer: Code writer instance
            proto_file: Original proto file path
            msg_desc: Message descriptor for the class being generated
            current_path: Full class path for type hints and return type
        """
        with writer.method(
            "from_dict",
            ["cls", "data: builtins.dict[builtins.str, typing.Any]"],
            decorators=["builtins.classmethod"],
            return_type=f'"{current_path}"',
        ):
            writer.docstring(
                [
                    "Create from a dictionary with lowerCamelCase keys.",
                    "",
                    "Args:",
                    "    data: Dictionary representation following OTLP JSON encoding",
                    "",
                    "Returns:",
                    f"    {msg_desc.name} instance",
                ]
            )
            codec = self._get_codec_module_path()
            writer.writeln(f'{codec}.validate_type(data, builtins.dict, "data")')
            writer.assignment("_args", "{}")
            writer.blank_line()

            # Separate fields into oneof groups and standalone fields
            oneof_groups: dict[int, list[descriptor.FieldDescriptorProto]] = defaultdict(list)
            standalone_fields: list[descriptor.FieldDescriptorProto] = []

            for field in msg_desc.field:
                if field.HasField("oneof_index") and not field.proto3_optional:
                    oneof_groups[field.oneof_index].append(field)
                else:
                    standalone_fields.append(field)

            # Handle standalone fields
            for field in standalone_fields:
                json_name = field.json_name if field.json_name else to_json_field_name(field.name)
                with writer.if_(f'(_value := data.get("{json_name}")) is not None'):
                    self._generate_deserialization_statements(writer, proto_file, field, "_value", "_args")

            # Handle oneof groups
            for group_index in sorted(oneof_groups.keys()):
                group_fields = oneof_groups[group_index]
                for i, field in enumerate(reversed(group_fields)):
                    json_name = field.json_name if field.json_name else to_json_field_name(field.name)
                    condition = f'(_value := data.get("{json_name}")) is not None'
                    context = writer.elif_(condition) if i else writer.if_(condition)

                    with context:
                        self._generate_deserialization_statements(writer, proto_file, field, "_value", "_args")

            writer.blank_line()
            writer.return_("cls(**_args)")

    def _generate_serialization_statements(
        self,
        writer: CodeWriter,
        field_desc: descriptor.FieldDescriptorProto,
        target_dict: str,
    ) -> None:
        """
        Generate statements to serialize a field and assign it to the target dictionary.

        Args:
            writer: Code writer instance
            field_desc: Field descriptor for the field being serialized
            target_dict: Name of the dictionary variable to assign the serialized value to
        """
        json_name = field_desc.json_name if field_desc.json_name else to_json_field_name(field_desc.name)
        if field_desc.label == descriptor.FieldDescriptorProto.LABEL_REPEATED:
            item_expr = self._get_serialization_expr(field_desc, "_v")
            if item_expr == "_v":
                writer.assignment(f'{target_dict}["{json_name}"]', f"self.{field_desc.name}")
            else:
                codec = self._get_codec_module_path()
                writer.assignment(
                    f'{target_dict}["{json_name}"]',
                    f"{codec}.encode_repeated(self.{field_desc.name}, lambda _v: {item_expr})",
                )
        else:
            val_expr = self._get_serialization_expr(field_desc, f"self.{field_desc.name}")
            writer.assignment(f'{target_dict}["{json_name}"]', val_expr)

    # pylint: disable-next=too-many-return-statements
    def _get_serialization_expr(self, field_desc: descriptor.FieldDescriptorProto, var_name: str) -> str:
        """
        Get the Python expression to serialize a value of a given type for JSON output.

        Args:
            field_desc: Field descriptor for the value being serialized
            var_name: Variable name representing the value to serialize
        """
        codec = self._get_codec_module_path()
        if field_desc.type == descriptor.FieldDescriptorProto.TYPE_MESSAGE:
            return f"{var_name}.to_dict()"
        if field_desc.type == descriptor.FieldDescriptorProto.TYPE_ENUM:
            return f"builtins.int({var_name})"
        if is_hex_encoded_field(field_desc.name):
            return f"{codec}.encode_hex({var_name})"
        if is_int64_type(field_desc.type):
            return f"{codec}.encode_int64({var_name})"
        if is_bytes_type(field_desc.type):
            return f"{codec}.encode_base64({var_name})"
        if field_desc.type in (
            descriptor.FieldDescriptorProto.TYPE_FLOAT,
            descriptor.FieldDescriptorProto.TYPE_DOUBLE,
        ):
            return f"{codec}.encode_float({var_name})"

        return var_name

    def _generate_deserialization_statements(
        self,
        writer: CodeWriter,
        proto_file: str,
        field_desc: descriptor.FieldDescriptorProto,
        var_name: str,
        target_dict: str,
    ) -> None:
        """
        Generate statements to deserialize a field from a JSON value and assign it to the target dictionary.

        Args:
            writer: Code writer instance
            proto_file: Original proto file path
            field_desc: Field descriptor for the field being deserialized
            var_name: Variable name representing the JSON value to deserialize
            target_dict: Name of the dictionary variable to assign the deserialized value to
        """
        codec = self._get_codec_module_path()
        if field_desc.label == descriptor.FieldDescriptorProto.LABEL_REPEATED:
            item_expr = self._get_deserialization_expr(proto_file, field_desc, "_v")
            writer.assignment(
                f'{target_dict}["{field_desc.name}"]',
                f'{codec}.decode_repeated({var_name}, lambda _v: {item_expr}, "{field_desc.name}")',
            )
            return

        if field_desc.type == descriptor.FieldDescriptorProto.TYPE_MESSAGE:
            msg_type = self._resolve_message_type(field_desc.type_name, proto_file)
            writer.assignment(
                f'{target_dict}["{field_desc.name}"]',
                f"{msg_type}.from_dict({var_name})",
            )
        elif field_desc.type == descriptor.FieldDescriptorProto.TYPE_ENUM:
            enum_type = self._resolve_enum_type(field_desc.type_name, proto_file)
            writer.writeln(f'{codec}.validate_type({var_name}, builtins.int, "{field_desc.name}")')
            writer.assignment(
                f'{target_dict}["{field_desc.name}"]',
                f"{enum_type}({var_name})",
            )
        elif is_hex_encoded_field(field_desc.name):
            writer.assignment(
                f'{target_dict}["{field_desc.name}"]',
                f'{codec}.decode_hex({var_name}, "{field_desc.name}")',
            )
        elif is_int64_type(field_desc.type):
            writer.assignment(
                f'{target_dict}["{field_desc.name}"]',
                f'{codec}.decode_int64({var_name}, "{field_desc.name}")',
            )
        elif is_bytes_type(field_desc.type):
            writer.assignment(
                f'{target_dict}["{field_desc.name}"]',
                f'{codec}.decode_base64({var_name}, "{field_desc.name}")',
            )
        elif field_desc.type in (
            descriptor.FieldDescriptorProto.TYPE_FLOAT,
            descriptor.FieldDescriptorProto.TYPE_DOUBLE,
        ):
            writer.assignment(
                f'{target_dict}["{field_desc.name}"]',
                f'{codec}.decode_float({var_name}, "{field_desc.name}")',
            )
        else:
            allowed_types = get_json_allowed_types(field_desc.type, field_desc.name)
            writer.writeln(f'{codec}.validate_type({var_name}, {allowed_types}, "{field_desc.name}")')
            writer.assignment(f'{target_dict}["{field_desc.name}"]', var_name)

    # pylint: disable-next=too-many-return-statements
    def _get_deserialization_expr(
        self,
        proto_file: str,
        field_desc: descriptor.FieldDescriptorProto,
        var_name: str,
    ) -> str:
        """
        Get the Python expression to deserialize a value of a given type for JSON input.

        Args:
            proto_file: Original proto file path
            field_desc: Field descriptor for the value being deserialized
            var_name: Variable name representing the JSON value to deserialize

        Returns:
            Python expression string to perform deserialization
        """
        codec = self._get_codec_module_path()
        if field_desc.type == descriptor.FieldDescriptorProto.TYPE_MESSAGE:
            msg_type = self._resolve_message_type(field_desc.type_name, proto_file)
            return f"{msg_type}.from_dict({var_name})"
        if field_desc.type == descriptor.FieldDescriptorProto.TYPE_ENUM:
            enum_type = self._resolve_enum_type(field_desc.type_name, proto_file)
            return f"{enum_type}({var_name})"
        if is_hex_encoded_field(field_desc.name):
            return f'{codec}.decode_hex({var_name}, "{field_desc.name}")'
        if is_int64_type(field_desc.type):
            return f'{codec}.decode_int64({var_name}, "{field_desc.name}")'
        if is_bytes_type(field_desc.type):
            return f'{codec}.decode_base64({var_name}, "{field_desc.name}")'
        if field_desc.type in (
            descriptor.FieldDescriptorProto.TYPE_FLOAT,
            descriptor.FieldDescriptorProto.TYPE_DOUBLE,
        ):
            return f'{codec}.decode_float({var_name}, "{field_desc.name}")'

        return var_name

    def _get_field_type_hint(self, proto_file: str, field_desc: descriptor.FieldDescriptorProto) -> str:
        """
        Get the Python type hint for a field.

        Args:
            proto_file: Original proto file path
            field_desc: Field descriptor

        Returns:
            Python type hint string
        """
        if field_desc.type == descriptor.FieldDescriptorProto.TYPE_MESSAGE:
            base_type = self._resolve_message_type(field_desc.type_name, proto_file)
        elif field_desc.type == descriptor.FieldDescriptorProto.TYPE_ENUM:
            base_type = self._resolve_enum_type(field_desc.type_name, proto_file)
        else:
            base_type = get_python_type(field_desc.type)

        if field_desc.label == descriptor.FieldDescriptorProto.LABEL_REPEATED:
            return f"builtins.list[{base_type}]"
        if field_desc.type == descriptor.FieldDescriptorProto.TYPE_ENUM:
            return f"{base_type} | builtins.int | None"
        return f"{base_type} | None"

    def _resolve_message_type(self, type_name: str, proto_file: str) -> str:
        """
        Resolve a message type name to its Python class path.

        Args:
            type_name: Fully qualified proto name
            proto_file: Current proto file path

        Returns:
            Python class reference
        """
        fqn = type_name.lstrip(".")
        target_file = self._fqn_to_file.get(fqn)

        if not target_file:
            _logger.warning("Could not resolve message type: %s", type_name)
            return "typing.Any"

        class_path = self._fqn_to_class_path[fqn]

        # If in same file, use relative class path
        if target_file == proto_file:
            return class_path
        # Cross file reference - use fully qualified module + class path
        module_path = self._get_module_path(target_file)
        return f"{module_path}.{class_path}"

    def _resolve_enum_type(self, type_name: str, proto_file: str) -> str:
        """
        Resolve an enum type name to its Python class path.

        Args:
            type_name: Fully qualified proto name
            proto_file: Current proto file path

        Returns:
            Python class reference
        """
        fqn = type_name.lstrip(".")
        target_file = self._fqn_to_file.get(fqn)

        if not target_file:
            _logger.warning("Could not resolve enum type: %s", type_name)
            return "builtins.int"

        class_path = self._fqn_to_class_path[fqn]

        # If in same file, use relative class path
        if target_file == proto_file:
            return class_path
        # Cross file reference - use fully qualified module + class path
        module_path = self._get_module_path(target_file)
        return f"{module_path}.{class_path}"

    @classmethod
    def _get_field_default(cls, field_desc: descriptor.FieldDescriptorProto) -> str | None:
        """
        Get the default value for a field.

        Args:
            field_desc: Field descriptor

        Returns:
            Default value string or None
        """
        # Repeated fields default to empty list
        if field_desc.label == descriptor.FieldDescriptorProto.LABEL_REPEATED:
            return "dataclasses.field(default_factory=builtins.list)"

        # Optional fields, Message types, and oneof members default to None
        if (
            field_desc.type == descriptor.FieldDescriptorProto.TYPE_MESSAGE
            or field_desc.HasField("oneof_index")
            or field_desc.proto3_optional
        ):
            return "None"

        # Enum types default to 0
        if field_desc.type == descriptor.FieldDescriptorProto.TYPE_ENUM:
            return "0"

        # Primitive types use proto defaults
        return get_default_value(field_desc.type)


def _load_codec_source() -> str:
    """
    Load the source code for the codec module from its source file.

    Returns:
        Source code as a string
    """
    codec_src_path = Path(__file__).parent / "runtime" / "json_codec.py"
    try:
        return codec_src_path.read_text(encoding="utf-8")
    except Exception as e:
        _logger.error(
            "Failed to load codec module source from %s: %s",
            codec_src_path,
            e,
        )
        raise RuntimeError(f"Failed to load codec module source from {codec_src_path}") from e


def _find_common_root(paths: Iterable[str]) -> str:
    """
    Find the longest common directory prefix among the given paths.

    Args:
        paths: Iterable of file paths to analyze
    Returns:
        Common directory prefix as a string
    """
    if not paths:
        return ""

    # Split paths into components
    split_paths = [p.split("/")[:-1] for p in paths]
    if not split_paths:
        return ""

    # Find common prefix among components
    common = []
    for parts in zip(*split_paths):
        if all(p == parts[0] for p in parts):
            common.append(parts[0])
        else:
            break

    return "/".join(common)


def generate_code(
    request: plugin.CodeGeneratorRequest,
    package_transform: Callable[[str], str] = lambda p: p.replace("opentelemetry/proto/", "opentelemetry/proto_json/"),
) -> dict[str, str]:
    """
    Main entry point for code generation.

    Args:
        request: Protobuf compiler plugin request
        package_transform: Package transformation string or callable

    Returns:
        Dictionary mapping output file paths to generated code
    """
    generator = OtlpJsonGenerator(request, package_transform, version="0.0.0")
    return generator.generate_all()


def generate_plugin_response(
    request: plugin.CodeGeneratorRequest,
    package_transform: Callable[[str], str] = lambda p: p.replace("opentelemetry/proto/", "opentelemetry/proto_json/"),
) -> plugin.CodeGeneratorResponse:
    """
    Generate plugin response with all generated files.

    Args:
        request: Protobuf compiler plugin request
        package_transform: Package transformation string

    Returns:
        Plugin response with generated files
    """
    response = plugin.CodeGeneratorResponse()

    # Declare support for optional proto3 fields
    response.supported_features |= plugin.CodeGeneratorResponse.FEATURE_PROTO3_OPTIONAL
    response.supported_features |= plugin.CodeGeneratorResponse.FEATURE_SUPPORTS_EDITIONS

    response.minimum_edition = descriptor.EDITION_LEGACY
    response.maximum_edition = descriptor.EDITION_2024

    # Generate code
    generated_files = generate_code(request, package_transform)

    # Create response files
    for output_path, code in generated_files.items():
        file_response = response.file.add()
        file_response.name = output_path
        file_response.content = code

    return response
