"""The projection boundary between capability-declared runtime requirements
(``src.domain.capabilities.runtime``) and a concrete hosting provider's wiring.

``CapabilityRuntimeRequirements``/``MergedRuntimeRequirements`` describe WHAT a
project's runtime needs -- routes, platform services, config -- without knowing HOW
any of it gets served. ``RuntimeProjectionAdapter`` is where HOW lives: one
implementation per hosting provider (W4P + shared BFF, legacy Pages Functions).
Capability code (Auth, Forms, Feature Packs) never imports this module or anything
under ``src.domain.hosting.adapters`` -- enforced by
``tests/test_capability_import_boundaries.py``.

Logical operation ports are carried alongside the compatibility route prefix.  The
prefix selects the transport projection; each port ID proves that the generated-app
operation is actually supplied by the selected runtime.  Provider/internal paths
remain outside this module.
"""
from __future__ import annotations

from dataclasses import dataclass, field
from collections.abc import Awaitable, Callable
from typing import Literal, Protocol
from uuid import UUID

from src.domain.capabilities.runtime import MergedRuntimeRequirements
from src.integrations.platform_ports import CATALOG

# "unresolved" is a legitimate output, not an error -- a stub/unknown provider, or a
# real provider that hasn't wired a given route yet, reports it instead of guessing.
ProjectionStrategy = Literal["bff_proxy", "pages_functions", "external_origin", "unresolved"]


@dataclass(frozen=True)
class ProjectedRoute:
    """One ``RuntimeRouteRequirement`` turned into concrete hosting wiring."""

    path_prefix: str
    purpose: str
    strategy: ProjectionStrategy
    # Hosting-specific pointer: the KV field name, the Pages Functions directory,
    # etc. Never a secret value, same rule as the requirement it came from.
    target: str | None = None


@dataclass(frozen=True)
class RuntimeProjectionPlan:
    """One hosting provider's read-only plan for satisfying a project's merged
    runtime requirements -- diagnosable, not applied. ``unresolved`` lists any
    config/route keys this provider cannot yet satisfy, so a caller can block
    cleanly instead of silently deploying a half-wired project."""

    provider_id: str
    ports: tuple[str, ...] = ()
    missing_ports: tuple[str, ...] = ()
    routes: tuple[ProjectedRoute, ...] = ()
    # requirement key -> where it's exposed on this provider (e.g. an env var name,
    # a KV field, a Functions binding) -- never the value itself.
    public_config: dict[str, str] = field(default_factory=dict)
    trusted_config: dict[str, str] = field(default_factory=dict)
    unresolved: tuple[str, ...] = ()

    @property
    def ok(self) -> bool:
        return not self.unresolved


def _catalog_port_ids() -> frozenset[str]:
    return frozenset(
        item["port_id"] for item in CATALOG["ports"] if item.get("bff")
    )


def _projected_ports(requirements: MergedRuntimeRequirements) -> tuple[str, ...]:
    return tuple(port.port_id for port in requirements.ports)


def _missing_ports(requirements: MergedRuntimeRequirements) -> tuple[str, ...]:
    supported = _catalog_port_ids()
    return tuple(port.port_id for port in requirements.ports if port.port_id not in supported)


@dataclass(frozen=True)
class RuntimeProjectionReadback:
    """Provider readback for an applied projection.

    Providers return only non-secret observations. ``matches`` is deliberately
    computed by the provider boundary, so capability code cannot accidentally
    treat a successful write as a healthy runtime.
    """

    matches: bool
    details: dict[str, str] = field(default_factory=dict)
    issues: tuple[str, ...] = ()


@dataclass(frozen=True)
class RuntimeProjectionApplyContext:
    """Concrete I/O supplied by the hosting service to a projection adapter.

    Keeping the callbacks here makes the adapter executable without allowing it
    to reach into a specific Cloudflare client. The callbacks must be
    project/environment scoped and idempotent.
    """

    apply_plan: Callable[[UUID, RuntimeProjectionPlan], Awaitable[None]]
    readback: Callable[[UUID, RuntimeProjectionPlan], Awaitable[RuntimeProjectionReadback]]


class RuntimeProjectionAdapter(Protocol):
    """One hosting provider's way of planning and reconciling runtime wiring."""

    provider_id: str

    def plan(
        self, project_id: UUID, requirements: MergedRuntimeRequirements
    ) -> RuntimeProjectionPlan: ...

    def project_routes(
        self, project_id: UUID, requirements: MergedRuntimeRequirements
    ) -> tuple[ProjectedRoute, ...]: ...

    def project_public_config(
        self, project_id: UUID, requirements: MergedRuntimeRequirements
    ) -> dict[str, str]: ...

    def project_trusted_config(
        self, project_id: UUID, requirements: MergedRuntimeRequirements
    ) -> dict[str, str]: ...

    async def apply(
        self,
        project_id: UUID,
        requirements: MergedRuntimeRequirements,
        context: RuntimeProjectionApplyContext,
    ) -> RuntimeProjectionReadback: ...


class W4PSharedBFFRuntimeProjection:
    """Projects every route as a same-origin proxy to the project's Cloud BFF origin
    -- the ``bff_origin`` field already on the W4P KV routing descriptor (see
    ``src.domain.hosting.types.RoutingDescriptor``,
    ``docs/ARCHITECTURE_HOSTING_TWO_PLANE.md``). This is the real mechanism W4P
    already uses for ``/api/*`` passthrough; this adapter makes per-capability routes
    explicit instead of one blanket ``/api/*`` rule, so a future
    ``apply()`` can tell which capability a given KV route entry belongs to.
    """

    provider_id = "cloudflare_w4p_static"

    def project_routes(
        self, project_id: UUID, requirements: MergedRuntimeRequirements
    ) -> tuple[ProjectedRoute, ...]:
        return tuple(
            ProjectedRoute(
                path_prefix=route.path_prefix,
                purpose=route.purpose,
                strategy="bff_proxy",
                target="bff_origin",
            )
            for route in requirements.routes
        )

    def project_public_config(
        self, project_id: UUID, requirements: MergedRuntimeRequirements
    ) -> dict[str, str]:
        return {
            cfg.key: "generated_app_build_env"
            for cfg in requirements.config
            if cfg.visibility == "public"
        }

    def project_trusted_config(
        self, project_id: UUID, requirements: MergedRuntimeRequirements
    ) -> dict[str, str]:
        return {
            cfg.key: "cloud_bff_service_credential"
            for cfg in requirements.config
            if cfg.visibility == "trusted"
        }

    def plan(
        self, project_id: UUID, requirements: MergedRuntimeRequirements
    ) -> RuntimeProjectionPlan:
        return RuntimeProjectionPlan(
            provider_id=self.provider_id,
            ports=_projected_ports(requirements),
            missing_ports=_missing_ports(requirements),
            routes=self.project_routes(project_id, requirements),
            public_config=self.project_public_config(project_id, requirements),
            trusted_config=self.project_trusted_config(project_id, requirements),
            unresolved=tuple(f"port:{port}" for port in _missing_ports(requirements)),
        )

    async def apply(
        self,
        project_id: UUID,
        requirements: MergedRuntimeRequirements,
        context: RuntimeProjectionApplyContext,
    ) -> RuntimeProjectionReadback:
        plan = self.plan(project_id, requirements)
        if not plan.ok:
            return RuntimeProjectionReadback(False, issues=plan.unresolved)
        await context.apply_plan(project_id, plan)
        return await context.readback(project_id, plan)


class LegacyPagesRuntimeProjection:
    """Projects every route onto the matching Pages Functions path already present
    in the generated repo (``functions/api/<capability>``) -- today's real mechanism
    for existing, pre-W4P-cutover projects (see
    ``docs/ARCHITECTURE_HOSTING_TWO_PLANE.md``)."""

    provider_id = "cloudflare_pages"

    def project_routes(
        self, project_id: UUID, requirements: MergedRuntimeRequirements
    ) -> tuple[ProjectedRoute, ...]:
        return tuple(
            ProjectedRoute(
                path_prefix=route.path_prefix,
                purpose=route.purpose,
                strategy="pages_functions",
                target=f"functions{route.path_prefix}",
            )
            for route in requirements.routes
        )

    def project_public_config(
        self, project_id: UUID, requirements: MergedRuntimeRequirements
    ) -> dict[str, str]:
        return {
            cfg.key: "pages_build_env"
            for cfg in requirements.config
            if cfg.visibility == "public"
        }

    def project_trusted_config(
        self, project_id: UUID, requirements: MergedRuntimeRequirements
    ) -> dict[str, str]:
        return {
            cfg.key: "pages_functions_env"
            for cfg in requirements.config
            if cfg.visibility == "trusted"
        }

    def plan(
        self, project_id: UUID, requirements: MergedRuntimeRequirements
    ) -> RuntimeProjectionPlan:
        return RuntimeProjectionPlan(
            provider_id=self.provider_id,
            ports=_projected_ports(requirements),
            missing_ports=_missing_ports(requirements),
            routes=self.project_routes(project_id, requirements),
            public_config=self.project_public_config(project_id, requirements),
            trusted_config=self.project_trusted_config(project_id, requirements),
            unresolved=tuple(f"port:{port}" for port in _missing_ports(requirements)),
        )

    async def apply(
        self,
        project_id: UUID,
        requirements: MergedRuntimeRequirements,
        context: RuntimeProjectionApplyContext,
    ) -> RuntimeProjectionReadback:
        plan = self.plan(project_id, requirements)
        if not plan.ok:
            return RuntimeProjectionReadback(False, issues=plan.unresolved)
        await context.apply_plan(project_id, plan)
        return await context.readback(project_id, plan)


class StubRuntimeProjection:
    """Safe default when no hosting provider is configured (mirrors
    ``StubHostingProvider``): reports every route/config as unresolved rather than
    guessing a wiring strategy."""

    provider_id = "stub"

    def project_routes(
        self, project_id: UUID, requirements: MergedRuntimeRequirements
    ) -> tuple[ProjectedRoute, ...]:
        return tuple(
            ProjectedRoute(
                path_prefix=route.path_prefix,
                purpose=route.purpose,
                strategy="unresolved",
            )
            for route in requirements.routes
        )

    def project_public_config(
        self, project_id: UUID, requirements: MergedRuntimeRequirements
    ) -> dict[str, str]:
        return {}

    def project_trusted_config(
        self, project_id: UUID, requirements: MergedRuntimeRequirements
    ) -> dict[str, str]:
        return {}

    def plan(
        self, project_id: UUID, requirements: MergedRuntimeRequirements
    ) -> RuntimeProjectionPlan:
        return RuntimeProjectionPlan(
            provider_id=self.provider_id,
            ports=_projected_ports(requirements),
            missing_ports=_projected_ports(requirements),
            routes=self.project_routes(project_id, requirements),
            unresolved=tuple(
                [f"port:{port}" for port in _projected_ports(requirements)]
                + [route.path_prefix for route in requirements.routes]
            ),
        )

    async def apply(
        self,
        project_id: UUID,
        requirements: MergedRuntimeRequirements,
        context: RuntimeProjectionApplyContext,
    ) -> RuntimeProjectionReadback:
        plan = self.plan(project_id, requirements)
        return RuntimeProjectionReadback(
            False, issues=plan.unresolved or ("provider_unconfigured",)
        )
