"""Builder for flash_manifest.json."""

import importlib.util
import json
import logging
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional

from runpod_flash.core.resources.constants import (
    DEFAULT_PYTHON_VERSION,
    GPU_BASE_IMAGE_PYTHON_VERSION,
)

from .scanner import (
    RemoteFunctionMetadata,
    file_to_module_path,
    file_to_url_prefix,
)

logger = logging.getLogger(__name__)

RESERVED_PATHS = ["/execute", "/ping"]


def _serialize_network_volume(nv) -> dict:
    """Serialize a NetworkVolume to a manifest-safe dict."""
    nv_config: dict = {}
    if nv.name is not None:
        nv_config["name"] = nv.name
    if getattr(nv, "id", None) is not None:
        nv_config["id"] = nv.id
    if nv.size is not None:
        nv_config["size"] = nv.size
    if hasattr(nv, "dataCenterId") and nv.dataCenterId is not None:
        nv_config["dataCenterId"] = (
            nv.dataCenterId.value
            if hasattr(nv.dataCenterId, "value")
            else nv.dataCenterId
        )
    return nv_config


@dataclass
class ManifestFunction:
    """Function entry in manifest."""

    name: str
    module: str
    is_async: bool
    is_class: bool
    http_method: Optional[str] = None  # HTTP method for LB endpoints (GET, POST, etc.)
    http_path: Optional[str] = None  # HTTP path for LB endpoints (/api/process)
    is_load_balanced: bool = False  # Determined by isinstance() at scan time
    is_live_resource: bool = False  # LiveLoadBalancer vs LoadBalancerSlsResource
    config_variable: Optional[str] = None  # Variable name like "gpu_config"


@dataclass
class ManifestResource:
    """Resource config entry in manifest."""

    resource_type: str
    functions: List[ManifestFunction]
    is_load_balanced: bool = False  # Determined by isinstance() at scan time
    is_live_resource: bool = False  # LiveLoadBalancer vs LoadBalancerSlsResource
    config_variable: Optional[str] = None  # Variable name for config discovery
    is_load_balanced_endpoint: bool = False  # Flag for load-balanced endpoint
    is_explicit: bool = False  # Flag indicating explicit load balancer configuration
    main_file: Optional[str] = None  # Filename of main entry point
    app_variable: Optional[str] = None  # Variable name of FastAPI app
    imageName: Optional[str] = None  # Docker image name for auto-provisioning
    templateId: Optional[str] = None  # RunPod template ID for auto-provisioning
    gpuIds: Optional[list] = None  # GPU types/IDs for auto-provisioning
    workersMin: Optional[int] = None  # Min worker count for auto-provisioning
    workersMax: Optional[int] = None  # Max worker count for auto-provisioning


class ManifestBuilder:
    """Builds flash_manifest.json from discovered remote functions."""

    def __init__(
        self,
        project_name: str,
        remote_functions: List[RemoteFunctionMetadata],
        scanner=None,
        build_dir: Optional[Path] = None,
        python_version: Optional[str] = None,
    ):
        self.project_name = project_name
        self.remote_functions = remote_functions
        self.scanner = (
            scanner  # Optional: RemoteDecoratorScanner with resource config info
        )
        self.build_dir = build_dir
        self.python_version = (
            python_version or f"{sys.version_info.major}.{sys.version_info.minor}"
        )

    def _import_module(self, file_path: Path):
        """Import a module from file path, returning (module, cleanup_fn).

        Caller must invoke cleanup_fn when done with the module.
        Returns (None, noop) if the module cannot be loaded or executed.
        """
        noop = lambda: None  # noqa: E731
        spec = importlib.util.spec_from_file_location(file_path.stem, file_path)
        if not spec or not spec.loader:
            return None, noop

        module = importlib.util.module_from_spec(spec)
        sys.modules[spec.name] = module

        parent_dir = str(file_path.parent)
        added_to_path = parent_dir not in sys.path
        if added_to_path:
            sys.path.insert(0, parent_dir)

        def cleanup():
            if spec.name in sys.modules:
                del sys.modules[spec.name]
            if added_to_path:
                try:
                    sys.path.remove(parent_dir)
                except ValueError:
                    pass

        try:
            spec.loader.exec_module(module)
        except Exception:
            cleanup()
            return None, noop

        return module, cleanup

    def _extract_deployment_config(
        self, resource_name: str, config_variable: Optional[str], resource_type: str
    ) -> Dict[str, Any]:
        """Extract deployment config by importing the source module.

        Finds the source file by matching ``resource_config_name``, imports it,
        then reads the resource config object. For resources with a
        ``config_variable`` (e.g. ``gpu_config = LiveServerless(...)``), reads
        the named module attribute. For inline ``@Endpoint(...)`` decorators
        (no config_variable), reads ``__remote_config__`` from the decorated
        function. Both paths produce a fully-resolved config object with enum
        expansion and validation already applied.
        """
        config: Dict[str, Any] = {}

        # find the source file by resource name, not config_variable,
        # to avoid collisions when multiple files use the same variable name
        func_meta = None
        for func in self.remote_functions:
            if func.resource_config_name == resource_name:
                func_meta = func
                break

        if func_meta is None or not func_meta.file_path:
            return config
        if not func_meta.file_path.exists():
            return config

        try:
            module, cleanup = self._import_module(func_meta.file_path)
            if module is None:
                return config

            try:
                resource_config = None

                if config_variable and hasattr(module, config_variable):
                    resource_config = getattr(module, config_variable)
                else:
                    # inline @Endpoint() -- read __remote_config__ from the function
                    fn = getattr(module, func_meta.function_name, None)
                    if fn is not None:
                        remote_cfg = getattr(fn, "__remote_config__", None)
                        if isinstance(remote_cfg, dict):
                            resource_config = remote_cfg.get("resource_config")

                if resource_config is None:
                    return config

                # unwrap Endpoint facade to the internal resource config
                if hasattr(resource_config, "_build_resource_config"):
                    resource_config = resource_config._build_resource_config()

                self._extract_config_properties(config, resource_config)
            finally:
                cleanup()

        except Exception as e:
            logger.debug(
                f"Failed to extract deployment config for {resource_name}: {e}"
            )

        return config

    @staticmethod
    def _extract_config_properties(config: Dict[str, Any], resource_config) -> None:
        """Read deployment properties from a resource config object into config dict."""
        if hasattr(resource_config, "imageName") and resource_config.imageName:
            config["imageName"] = resource_config.imageName

        if hasattr(resource_config, "templateId") and resource_config.templateId:
            config["templateId"] = resource_config.templateId

        if hasattr(resource_config, "gpuIds") and resource_config.gpuIds:
            config["gpuIds"] = resource_config.gpuIds

        if hasattr(resource_config, "instanceIds") and resource_config.instanceIds:
            config["instanceIds"] = [
                i.value if hasattr(i, "value") else str(i)
                for i in resource_config.instanceIds
            ]

        if hasattr(resource_config, "workersMin"):
            config["workersMin"] = resource_config.workersMin

        if hasattr(resource_config, "workersMax"):
            config["workersMax"] = resource_config.workersMax

        if (
            hasattr(resource_config, "scalerType")
            and resource_config.scalerType is not None
        ):
            val = resource_config.scalerType
            config["scalerType"] = val.value if hasattr(val, "value") else val

        if (
            hasattr(resource_config, "scalerValue")
            and resource_config.scalerValue is not None
        ):
            config["scalerValue"] = resource_config.scalerValue

        if hasattr(resource_config, "locations") and resource_config.locations:
            config["locations"] = resource_config.locations

        if hasattr(resource_config, "env") and resource_config.env is not None:
            config["env"] = dict(resource_config.env)

        if (
            hasattr(resource_config, "networkVolumes")
            and resource_config.networkVolumes
        ):
            config["networkVolumes"] = [
                _serialize_network_volume(nv) for nv in resource_config.networkVolumes
            ]

        elif (
            hasattr(resource_config, "networkVolume") and resource_config.networkVolume
        ):
            config["networkVolume"] = _serialize_network_volume(
                resource_config.networkVolume
            )

        elif (
            hasattr(resource_config, "networkVolumeId")
            and resource_config.networkVolumeId
        ):
            config["networkVolumeId"] = resource_config.networkVolumeId

        if hasattr(resource_config, "template") and resource_config.template:
            template_obj = resource_config.template
            template_config = {}

            if hasattr(template_obj, "containerDiskInGb"):
                template_config["containerDiskInGb"] = template_obj.containerDiskInGb
            if hasattr(template_obj, "dockerArgs"):
                template_config["dockerArgs"] = template_obj.dockerArgs
            if hasattr(template_obj, "startScript"):
                template_config["startScript"] = template_obj.startScript
            if hasattr(template_obj, "advancedStart"):
                template_config["advancedStart"] = template_obj.advancedStart
            if hasattr(template_obj, "containerRegistryAuthId"):
                template_config["containerRegistryAuthId"] = (
                    template_obj.containerRegistryAuthId
                )

            if template_config:
                config["template"] = template_config

        return config

    def build(self) -> Dict[str, Any]:
        """Build the manifest dictionary.

        Resources are keyed by resource_config_name for runtime compatibility.
        Each resource entry includes file_path, local_path_prefix, and module_path
        for the dev server and LB handler generator.
        """
        # Group functions by resource_config_name
        resources: Dict[str, List[RemoteFunctionMetadata]] = {}

        for func in self.remote_functions:
            if func.resource_config_name not in resources:
                resources[func.resource_config_name] = []
            resources[func.resource_config_name].append(func)

        # Build manifest structure
        resources_dict: Dict[str, Dict[str, Any]] = {}
        function_registry: Dict[str, str] = {}
        routes_dict: Dict[
            str, Dict[str, str]
        ] = {}  # resource_name -> {route_key -> function_name}

        # Determine project root for path derivation.
        # build_dir is .flash/.build which *contains* the copied project files,
        # so use it directly (not its parent, which would be .flash/).
        project_root = self.build_dir if self.build_dir else Path.cwd()

        for resource_name, functions in sorted(resources.items()):
            # Use actual resource type from first function in group
            resource_type = (
                functions[0].resource_type if functions else "LiveServerless"
            )

            # Extract flags from first function (determined by isinstance() at scan time)
            is_load_balanced = functions[0].is_load_balanced if functions else False
            is_live_resource = functions[0].is_live_resource if functions else False

            # Derive path fields from the first function's source file.
            # All functions in a resource share the same source file per convention.
            first_file = functions[0].file_path if functions else None
            file_path_str = ""
            local_path_prefix = ""
            resource_module_path = functions[0].module_path if functions else ""

            if first_file and first_file.exists():
                try:
                    file_path_str = str(first_file.relative_to(project_root))
                    local_path_prefix = file_to_url_prefix(first_file, project_root)
                    resource_module_path = file_to_module_path(first_file, project_root)
                except ValueError:
                    # File is outside project root — fall back to module_path
                    file_path_str = str(first_file)
                    local_path_prefix = "/" + functions[0].module_path.replace(".", "/")
            elif first_file:
                # File path may be relative (in test scenarios)
                file_path_str = str(first_file)
                local_path_prefix = "/" + functions[0].module_path.replace(".", "/")

            # Validate and collect routing for LB endpoints
            resource_routes = {}
            if is_load_balanced:
                for f in functions:
                    if not f.http_method or not f.http_path:
                        raise ValueError(
                            f"{resource_type} endpoint '{resource_name}' requires "
                            f"method and path for function '{f.function_name}'. "
                            f"Got method={f.http_method}, path={f.http_path}"
                        )

                    # Check for route conflicts (same method + path)
                    route_key = f"{f.http_method} {f.http_path}"
                    if route_key in resource_routes:
                        raise ValueError(
                            f"Duplicate route '{route_key}' in resource '{resource_name}': "
                            f"both '{resource_routes[route_key]}' and '{f.function_name}' "
                            f"are mapped to the same route"
                        )
                    resource_routes[route_key] = f.function_name

                    # Check for reserved paths
                    if f.http_path in RESERVED_PATHS:
                        raise ValueError(
                            f"Function '{f.function_name}' cannot use reserved path '{f.http_path}'. "
                            f"Reserved paths: {', '.join(RESERVED_PATHS)}"
                        )

            # Extract config_variable from first function (all functions in same resource share same config)
            config_variable = functions[0].config_variable if functions else None

            functions_list = [
                {
                    "name": f.function_name,
                    "module": f.module_path,
                    "is_async": f.is_async,
                    "is_class": f.is_class,
                    **(
                        {"class_methods": f.class_methods}
                        if f.is_class and f.class_methods
                        else {}
                    ),
                    **(
                        {"http_method": f.http_method, "http_path": f.http_path}
                        if is_load_balanced
                        else {}
                    ),
                }
                for f in functions
            ]

            # Extract deployment config (imageName, templateId, etc.) for auto-provisioning
            deployment_config = self._extract_deployment_config(
                resource_name, config_variable, resource_type
            )

            # Determine if this resource makes remote calls
            makes_remote_calls = any(func.calls_remote_functions for func in functions)

            # One tarball serves all resources, so target_python_version must agree.
            # GPU resources are pinned to the base image's Python; CPU resources
            # use DEFAULT_PYTHON_VERSION (aligned to GPU to avoid ABI mismatch).
            _GPU_RESOURCE_TYPES = {
                "LiveServerless",
                "LiveLoadBalancer",
                "LoadBalancerSlsResource",
                "ServerlessEndpoint",
            }
            if resource_type in _GPU_RESOURCE_TYPES:
                target_python_version = GPU_BASE_IMAGE_PYTHON_VERSION
            else:
                target_python_version = DEFAULT_PYTHON_VERSION

            resources_dict[resource_name] = {
                "resource_type": resource_type,
                "file_path": file_path_str,
                "local_path_prefix": local_path_prefix,
                "module_path": resource_module_path,
                "functions": functions_list,
                "is_load_balanced": is_load_balanced,
                "is_live_resource": is_live_resource,
                "config_variable": config_variable,
                "makes_remote_calls": makes_remote_calls,
                "target_python_version": target_python_version,
                **deployment_config,  # Include imageName, templateId, gpuIds, workers config
            }

            if not is_load_balanced:
                resources_dict[resource_name]["handler_file"] = (
                    f"handler_{resource_name}.py"
                )

            # Store routes for LB endpoints
            if resource_routes:
                routes_dict[resource_name] = resource_routes

            # Build function registry for quick lookup
            for f in functions:
                if f.function_name in function_registry:
                    raise ValueError(
                        f"Duplicate function name '{f.function_name}' found in "
                        f"resources '{function_registry[f.function_name]}' and '{resource_name}'"
                    )
                function_registry[f.function_name] = resource_name

        manifest = {
            "version": "1.0",
            "python_version": self.python_version,
            "generated_at": datetime.now(timezone.utc)
            .isoformat()
            .replace("+00:00", "Z"),
            "project_name": self.project_name,
            "resources": resources_dict,
            "function_registry": function_registry,
        }

        # Add routes section if there are LB endpoints with routing
        if routes_dict:
            manifest["routes"] = routes_dict

        return manifest

    def write_to_file(self, output_path: Path) -> Path:
        """Write manifest to file."""
        manifest = self.build()
        output_path.write_text(json.dumps(manifest, indent=2))
        return output_path
