"""Install catalog-selected artifacts and pin their content for the pre-push guard."""

from __future__ import annotations

import hashlib
import json
import posixpath
from pathlib import Path

from src.domain.codegen.legacy_contact_generation import (
    LEGACY_CONTACT_SUBMIT_CONTEXT_KEY,
    resolve_legacy_contact_submit,
)
from src.domain.codegen.local_backup import copy_to_bak, purge_scanned_backups
from src.integrations.platform_ports import CATALOG_VERSION, CONTRACTS, entitled_ports

LOCK_PATH = "p2hs/platform-ports.lock.json"
CLOUD_CONTRACT_PATH = "p2hs/cloud-contract.json"
CONTRACT_VERIFIER_PATH = "p2hs/verify-cloud-contract.mjs"
_TRACKED_PLATFORM_FILES = (
    "platform-ports.lock.json",
)


def _gitignore_rule_wins(body: str, allow: str, ignore: str = "p2hs/*") -> bool:
    """True when the last matching allow rule comes after the last ignore rule."""
    last_ignore = -1
    last_allow = -1
    for index, line in enumerate(body.splitlines()):
        stripped = line.strip()
        if stripped == ignore:
            last_ignore = index
        elif stripped == allow:
            last_allow = index
    return last_allow > last_ignore


def _ensure_platform_contracts_tracked(root: Path) -> None:
    """Keep the W4P ownership contract visible to Git when p2hs/ is ignored.

    Git uses the last matching rule. A later ``p2hs/*`` hides an earlier
    ``!p2hs/platform-ports.lock.json``, and the W4P bundler then rejects the
    platform handlers because the lock is absent from the checkout.
    """
    path = root / ".gitignore"
    if not path.is_file():
        return
    try:
        body = path.read_text(encoding="utf-8")
    except OSError:
        return
    allows = [f"!p2hs/{name}" for name in _TRACKED_PLATFORM_FILES]
    if all(_gitignore_rule_wins(body, allow) for allow in allows):
        return
    updated = body.rstrip() + "\n\n# P2HS tracked W4P ownership contracts\n!p2hs/\np2hs/*\n"
    updated += "\n".join(allows) + "\n"
    try:
        path.write_text(updated, encoding="utf-8", newline="\n")
    except OSError:
        return


def materialize_checkout_platform_lock(repo_path: str, project_type: str) -> list[str]:
    """Write the ownership lock from the checkout's current platform files.

    Hashes are the bytes already in the tree. Regenerating adapter source here
    would change the application. Returns the repo-relative paths that changed.
    """
    root = Path(repo_path)
    ignore_path = root / ".gitignore"
    before_ignore = ignore_path.read_text(encoding="utf-8") if ignore_path.is_file() else None
    _ensure_platform_contracts_tracked(root)
    changed: list[str] = []
    if ignore_path.is_file() and ignore_path.read_text(encoding="utf-8") != before_ignore:
        changed.append(".gitignore")

    from src.integrations.platform_ports import CATALOG, catalog_bff_paths

    expected = catalog_adapter_files(project_type, repo_path=repo_path)
    expected_lock = json.loads(expected[LOCK_PATH])
    routes = list(expected_lock["routes"])
    for port in CATALOG["ports"]:
        public = (port.get("bff") or {}).get("path")
        if isinstance(public, str) and public and public not in routes:
            routes.append(public)
    owned_names = set(catalog_bff_paths())
    owned_names.update(name for name in expected if name != LOCK_PATH)
    owned = {
        name: hashlib.sha256((root / name).read_bytes()).hexdigest()
        for name in sorted(owned_names)
        if (root / name).is_file()
    }
    lock = {
        "catalog_version": expected_lock["catalog_version"],
        "ports": expected_lock["ports"],
        "routes": routes,
        "legacy_contact_submit": expected_lock["legacy_contact_submit"],
        "files": owned,
    }
    text = json.dumps(lock, indent=2) + "\n"
    destination = root / LOCK_PATH
    current = destination.read_text(encoding="utf-8") if destination.is_file() else None
    if current != text:
        destination.parent.mkdir(parents=True, exist_ok=True)
        destination.write_text(text, encoding="utf-8", newline="\n")
        changed.append(LOCK_PATH)
    return changed


def catalog_adapter_files(
    project_type: str,
    context: dict | None = None,
    repo_path: str | None = None,
) -> dict[str, str]:
    if project_type not in {"html", "react_app", "nuxt_app"}:
        return {}
    from src.domain.codegen.app_sdk import app_sdk_files
    from src.domain.codegen.auth_users_adapter import auth_users_adapter_files, password_admitted
    from src.domain.codegen.contact_adapter import contact_adapter_files
    from src.domain.codegen.forms_adapter import forms_adapter_files
    from src.domain.codegen.file_adapter import file_adapter_files
    from src.domain.codegen.record_adapter import record_adapter_files

    payload = dict(context) if context is not None else {}
    include_legacy_submit = resolve_legacy_contact_submit(payload, repo_path)
    payload.pop(LEGACY_CONTACT_SUBMIT_CONTEXT_KEY, None)
    contract = (
        payload
        if payload
        else {"ports": entitled_ports(), "catalog_version": CATALOG_VERSION}
    )
    if "ports" not in contract:
        contract = {
            **contract,
            "ports": entitled_ports(),
            "catalog_version": contract.get("catalog_version", CATALOG_VERSION),
        }
    ports = contract["ports"]
    runtime_contract = contract.get("runtime_contract")
    files = {}
    legacy = {
        **record_adapter_files(project_type),
        **file_adapter_files(project_type),
        **auth_users_adapter_files(project_type, runtime_contract=runtime_contract),
        **contact_adapter_files(
            project_type,
            runtime_contract=runtime_contract,
            include_submit_route=include_legacy_submit,
        ),
        **forms_adapter_files(project_type),
        **app_sdk_files(project_type, runtime_contract=runtime_contract),
    }
    adapters = {p["bff"]["adapter"] for p in ports}
    password_surface = password_admitted(runtime_contract)
    if password_surface is None:
        password_surface = "password" in adapters
    # Keep shared config/error helpers even when the records capability is disabled.
    for name, content in legacy.items():
        if "/api/records/" in name and "records" not in adapters:
            continue
        if "/api/admin/users" in name and "users" not in adapters:
            continue
        if ("forgot-password" in name or "reset-password" in name) and not password_surface:
            continue
        files[name] = content
    from src.domain.codegen.entity_ingest_adapter import entity_ingest_adapter_files

    entity_ingest_bindings = (runtime_contract or {}).get("capabilities", {}).get(
        "entity_ingest_bindings"
    ) or []
    files.update(entity_ingest_adapter_files(project_type, entity_ingest_bindings))
    runtime = (
        Path(__file__).with_name("runtime").joinpath("p2hs-ports.mjs").read_text(encoding="utf-8")
    )
    frameworks = [("pages", "functions/_lib")]
    if project_type == "nuxt_app":
        frameworks.append(("nuxt", "server/utils"))
    for framework, lib in frameworks:
        files[f"{lib}/p2hs-ports.mjs"] = runtime
        files[f"{lib}/p2hs-port-contract.mjs"] = (
            "// Generated Platform Port Catalog projection.\nexport const platformPortContract = "
            + json.dumps(contract, separators=(",", ":"))
            + ";\n"
        )
        for port in ports:
            if port["bff"]["adapter"] != "generic":
                continue
            target = port["bff"]["files"][framework]
            relative = posixpath.relpath(lib, posixpath.dirname(target))
            if not relative.startswith("."):
                relative = "./" + relative
            template = "ports-pages-fn.js" if framework == "pages" else "ports-route.ts"
            content = (
                Path(__file__).with_name("runtime").joinpath(template).read_text(encoding="utf-8")
            )
            content = content.replace("__P2HS_LIB__", relative)
            # A dedicated adapter already owns this path. The generic proxy
            # JSON-parses upstream bodies, so it must not replace the magic-link
            # consume handoff (HTML plus app-origin session cookies).
            if target in files:
                continue
            files[target] = content
    cloud_contract = {
        "schema_version": 1,
        "catalog_version": CATALOG_VERSION,
        "authority": "P2HS Core policy + P2HS Cloud execution",
        "source": "P2HS Core injected Platform Port Catalog projection",
        "project_id": contract.get("project_id"),
        "workspace_id": contract.get("workspace_id"),
        "validation": {
            "command": "node p2hs/verify-cloud-contract.mjs",
            "network_required": False,
            "purpose": "Validate installed routes and request/response schemas before codegen completion.",
        },
        "ports": [
            {
                "port_id": port["port_id"],
                "entitlement": port.get("entitlement"),
                "bff": {
                    key: port["bff"].get(key)
                    for key in ("method", "path", "auth", "adapter")
                    if key in port.get("bff", {})
                },
                "cloud": {
                    key: port["cloud"].get(key)
                    for key in ("transport", "method", "path", "request_type")
                    if key in port.get("cloud", {})
                },
                "schema": CONTRACTS[port["port_id"]],
            }
            for port in ports
            if port["port_id"] in CONTRACTS
        ],
    }
    files[CLOUD_CONTRACT_PATH] = json.dumps(cloud_contract, indent=2) + "\n"
    if isinstance(runtime_contract, dict):
        # Resolver-owned architecture facts are pinned alongside the platform
        # adapters. Codegen may consume this file, never rewrite it to fit code.
        files["p2hs/runtime-contract.json"] = json.dumps(
            runtime_contract, indent=2
        ) + "\n"
    from src.domain.codegen.dependency_contract import dependency_contract_manifest

    files["p2hs/dependency-build.json"] = json.dumps(
        dependency_contract_manifest(), indent=2
    ) + "\n"
    # The managed-runtime manifest is the ownership boundary used by migration
    # scanners. Keep every generated auth surface in the manifest, including the
    # shared password adapter when password routes are intentionally absent but
    # the admin-users adapter still imports it.
    manifest_path = "p2hs/managed-runtime.json"
    if manifest_path in files:
        manifest = json.loads(files[manifest_path])
        auth_paths = sorted(
            name for name in files
            if (
                "p2hs-auth-users" in name
                or "p2hs-auth-password" in name
                or "/api/auth/forgot-password" in name
                or "/api/auth/reset-password" in name
                or name.endswith("/api/admin/users.js")
                or name.endswith("/api/admin/users/[[path]].js")
                or name.endswith("/api/admin/users.ts")
                or name.endswith("/api/admin/users/[...path].ts")
            )
        )
        manifest.setdefault("components", {})["auth_bff"] = {
            "ownership": "p2hs_managed",
            "paths": auth_paths,
            "expected_fingerprints": {
                name: "sha256:" + hashlib.sha256(files[name].encode("utf-8")).hexdigest()
                for name in auth_paths
            },
            "extension_policy": "locked",
        }
        manifest["components"].setdefault("auth_password_surface", {
            "ownership": "p2hs_managed",
            "admitted": bool(password_surface),
            "paths": [
                name for name in auth_paths
                if "password" in name or "forgot-password" in name or "reset-password" in name
            ],
            "extension_policy": "contract_gated",
        })
        files[manifest_path] = json.dumps(manifest, indent=2) + "\n"
    files[CONTRACT_VERIFIER_PATH] = (
        Path(__file__).with_name("runtime").joinpath("verify-cloud-contract.mjs")
        .read_text(encoding="utf-8")
    )
    routes = [p["bff"]["path"] for p in ports]
    extras = ["/api/public-config", "/api/contact/inbox", "/api/auth/magic-link/consume"]
    if include_legacy_submit:
        extras.insert(0, "/api/contact")
    for extra in extras:
        if extra not in routes:
            routes.append(extra)
    lock = {
        "catalog_version": CATALOG_VERSION,
        "ports": [p["port_id"] for p in ports],
        "routes": routes,
        "legacy_contact_submit": include_legacy_submit,
        "files": {
            name: hashlib.sha256(content.encode()).hexdigest() for name, content in files.items()
        },
    }
    files[LOCK_PATH] = json.dumps(lock, indent=2) + "\n"
    return files


def port_install_status(
    repo_path: str, project_type: str, context: dict | None = None
) -> dict:
    """Compare a checkout to the current catalog projection without writing."""
    expected = catalog_adapter_files(project_type, context, repo_path=repo_path)
    root = Path(repo_path)
    installed_version = None
    lock_path = root / LOCK_PATH
    if lock_path.is_file():
        try:
            installed_version = json.loads(lock_path.read_text(encoding="utf-8")).get(
                "catalog_version"
            )
        except (OSError, json.JSONDecodeError, TypeError):
            installed_version = None
    changed = [
        name
        for name, content in expected.items()
        if not (root / name).is_file()
        or (root / name).read_text(encoding="utf-8") != content
    ]
    catalog_version = None
    if LOCK_PATH in expected:
        catalog_version = json.loads(expected[LOCK_PATH]).get("catalog_version")
    return {
        "catalog_version": catalog_version,
        "installed_version": installed_version,
        "stale": bool(changed),
        "changed_files": changed,
    }


def runtime_files_for_host(
    files: dict[str, str],
    server_function_surface: str | None,
) -> dict[str, str]:
    """Keep Nitro sources. Pages Functions are only one host's server surface."""

    if server_function_surface != "none":
        return files
    return {
        name: content
        for name, content in files.items()
        if not name.startswith("functions/")
    }


def install_catalog_adapters(
    repo_path: str, project_type: str, context: dict | None = None
) -> dict:
    from src.domain.codegen.app_sdk import validate_app_block_plan_for_execution
    from src.domain.codegen.auth_users_adapter import remove_unadmitted_password_routes
    from src.domain.codegen.backend_guard import migrate_legacy_app_paths, prepare_port_routes
    from src.domain.codegen.route_identity import retire_colliding_application_routes

    validate_app_block_plan_for_execution(repo_path)
    root = Path(repo_path)
    runtime_contract = (context or {}).get("runtime_contract")
    files = catalog_adapter_files(project_type, context, repo_path=repo_path)
    from src.domain.hosting.profile import find_hosting_profile, projection_for_provider

    provider_id = (context or {}).get("hosting_provider")
    profile = find_hosting_profile(provider_id) if provider_id else None
    # Every API file the catalog owns claims its route before anything is
    # filtered or written. A second file with a different parameter spelling
    # is the same route to the W4P bundler.
    catalog_routes = {
        name
        for name in files
        if name.startswith(("functions/api/", "server/api/"))
    }
    retired_routes = retire_colliding_application_routes(repo_path, catalog_routes)
    if profile is not None:
        files = runtime_files_for_host(files, profile.server_function_surface)
    if not files:
        return {
            "written": [],
            "backed_up": retired_routes,
            "retired_form_aliases": retired_routes,
            "skipped": [],
            "projection": None,
        }
    lock = json.loads(files[LOCK_PATH])
    backed = prepare_port_routes(repo_path, lock)
    from src.domain.hosting.managed_files import (
        content_hash,
        load_manifest,
        plan_managed_updates,
        save_manifest,
    )

    manifest = load_manifest(root)
    conflicts: list[str] = []
    written: list[str] = []
    for name, action in plan_managed_updates(root, files):
        content = files[name]
        if action == "conflict":
            conflicts.append(name)
            continue
        if action == "unchanged":
            manifest[name] = {
                "ownership": "p2hs",
                "policy": "regenerate",
                "sha256": content_hash(content),
            }
            written.append(name)
            continue
        target = root / name
        target.parent.mkdir(parents=True, exist_ok=True)
        if action == "regenerate" and target.is_file():
            copy_to_bak(root, name)
        target.write_text(content, encoding="utf-8", newline="\n")
        manifest[name] = {
            "ownership": "p2hs",
            "policy": "regenerate",
            "sha256": content_hash(content),
        }
        written.append(name)
    save_manifest(root, manifest)
    _ensure_platform_contracts_tracked(root)
    removed_password_routes = remove_unadmitted_password_routes(
        repo_path, runtime_contract
    )
    purge_scanned_backups(root)
    if runtime_contract is not None:
        # Dependency-free checks (backend_guard.py, run via `python -c` inside the Actions
        # runner with no DB access) can only see what's on disk -- this is that surface.
        contract_target = root / "p2hs" / "runtime-contract.json"
        contract_target.parent.mkdir(parents=True, exist_ok=True)
        contract_target.write_text(
            json.dumps(runtime_contract, indent=2) + "\n", encoding="utf-8", newline="\n"
        )
    plan_path = root / "p2hs" / "app-blocks-plan.json"
    if plan_path.is_file():
        from src.domain.app_blocks.runtime_dependencies import (
            build_runtime_dependency_manifest,
            runtime_dependency_module,
        )
        from src.domain.app_blocks.schemas import AppBlockPlan

        plan = AppBlockPlan.model_validate(json.loads(plan_path.read_text(encoding="utf-8")))
        dependency_manifest = build_runtime_dependency_manifest(
            plan, runtime_contract=runtime_contract
        )
        dependency_path = root / "p2hs" / "runtime-dependencies.json"
        dependency_path.parent.mkdir(parents=True, exist_ok=True)
        dependency_path.write_text(
            json.dumps(dependency_manifest, indent=2) + "\n",
            encoding="utf-8",
            newline="\n",
        )
        module_path = root / "lib" / "runtime-dependencies.js"
        module_path.parent.mkdir(parents=True, exist_ok=True)
        module_path.write_text(
            runtime_dependency_module(dependency_manifest), encoding="utf-8", newline="\n"
        )
    from src.domain.codegen.app_sdk import (
        ensure_required_packs_from_plan,
        publish_experience_asset,
        staff_shell_files,
    )

    ensure_required_packs_from_plan(repo_path)
    publish_experience_asset(repo_path, project_type)
    for name, content in staff_shell_files(
        project_type, repo_path, runtime_contract=runtime_contract
    ).items():
        target = root / name
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(content, encoding="utf-8", newline="\n")
    migrated = migrate_legacy_app_paths(repo_path)
    projection = None
    if profile is not None:
        from uuid import UUID, uuid4

        from src.domain.capabilities.runtime import MergedRuntimeRequirements

        project_key = (context or {}).get("project_id")
        try:
            project_uuid = UUID(str(project_key))
        except (TypeError, ValueError):
            project_uuid = uuid4()
        planned = projection_for_provider(profile.provider_id).plan(
            project_uuid,
            MergedRuntimeRequirements(
                contract_version=1,
                routes=(),
                ports=(),
                platform_services=(),
                config=(),
                health_checks=(),
                feature_flags=(),
            ),
        )
        projection = {
            "provider_id": planned.provider_id,
            "server_function_surface": profile.server_function_surface,
            "ok": planned.ok,
        }
    return {
        "written": written,
        "backed_up": [*retired_routes, *backed],
        "retired_form_aliases": retired_routes,
        "migrated": migrated,
        "removed": removed_password_routes,
        "conflicts": conflicts,
        "skipped": [],
        "projection": projection,
    }
