"""W4P static deployment adapter: Workers Static Assets in a dispatch namespace.

Deploys an already-built static site (a directory) as a user Worker in a W4P dispatch namespace,
then points the hostname's KV routing record at it. Rollback is a KV change back to the previous
version's script name, so no files are re-uploaded.

Versions are separate scripts: the live script for a project is ``p2hs-prod-<ref>`` (or
``p2hs-prev-<ref>``), and each deploy also uploads ``<live>-v<sha8>``. The dispatcher only reads
``script_name`` from KV, so the routing contract is unchanged.

Build is not done here. ``deploy_artifact`` takes ``build_metadata["static_dir"]``, the output of
a build already run by CI or the worker.

Upload wire format follows the Workers Static Assets direct-upload flow. The batch body encoding
and the asset hash input are checked against a live spike before rollout; see the tests and the
migration plan.
"""

from __future__ import annotations

import base64
import hashlib
import json
import mimetypes
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any

import httpx
import structlog

from src.domain.hosting.types import DeployArtifact, DeployJobContext, DeploymentOutcome, DeploymentTarget

logger = structlog.get_logger()

CF_API = "https://api.cloudflare.com/client/v4"
MAIN_MODULE = "worker.js"
def worker_source() -> str:
    """Canonical Worker module. Identical to the script GitHub Actions uploads."""
    from src.domain.hosting.runtime.worker_source import build_worker_source

    return build_worker_source()
# Generated apps use client-side routes (for example ``/request-quote``), so
# direct navigations must receive the app shell rather than a provider 404.
# Keep this identical to the GitHub Actions W4P uploader in w4p_workflow.py.
ASSET_CONFIG = {
    "html_handling": "auto-trailing-slash",
    "not_found_handling": "single-page-application",
}
MAX_BATCH_BYTES = 40 * 1024 * 1024


class W4PStaticError(RuntimeError):
    pass


@dataclass(frozen=True)
class StaticFile:
    path: str            # URL path, leading slash, e.g. "/index.html"
    content: bytes


def asset_hash(path: str, content: bytes) -> str:
    """32-hex identifier for a file, derived from its base64 body and extension."""
    extension = os.path.splitext(path)[1]
    body = base64.b64encode(content).decode("ascii")
    return hashlib.sha256((body + extension).encode("utf-8")).hexdigest()[:32]


def collect_static_files(root: str | Path) -> list[StaticFile]:
    """Every regular file under ``root``, as URL paths. Hidden files and symlinks are skipped."""
    base = Path(root)
    if not base.is_dir():
        raise W4PStaticError(f"static_dir_missing:{base}")
    files: list[StaticFile] = []
    for path in sorted(base.rglob("*")):
        rel = path.relative_to(base)
        if path.is_symlink() or not path.is_file():
            continue
        if any(part.startswith(".") for part in rel.parts):
            continue
        url_path = "/" + rel.as_posix()
        files.append(StaticFile(path=url_path, content=path.read_bytes()))
    if not files:
        raise W4PStaticError("static_dir_empty")
    if not any(f.path == "/index.html" for f in files):
        raise W4PStaticError("static_dir_has_no_index_html")
    return files


def build_manifest(files: list[StaticFile]) -> tuple[dict[str, dict[str, Any]], dict[str, StaticFile]]:
    """Manifest for the upload session, and a map of hash to file for the batch upload."""
    manifest: dict[str, dict[str, Any]] = {}
    by_hash: dict[str, StaticFile] = {}
    for f in files:
        h = asset_hash(f.path, f.content)
        manifest[f.path] = {"hash": h, "size": len(f.content)}
        by_hash[h] = f
    return manifest, by_hash


def version_script_name(live_script: str, git_sha: str) -> str:
    return f"{live_script}-v{(git_sha or 'unknown')[:8]}"


def version_scripts_to_delete(names: list[str], *, prefix: str, keep: set[str]) -> list[str]:
    """Old version scripts for one project. Never the live script, the previous one, or any referenced name.

    ``prefix`` is the unversioned live name (``p2hs-prod-<ref>``). Only ``<prefix>-v*`` names are eligible.
    The dispatch namespace itself is never a name in this list.
    """
    needle = f"{prefix}-v"
    return sorted(name for name in names if name.startswith(needle) and name not in keep)


def asset_content_type(path: str) -> str:
    """Return the MIME type Cloudflare should attach when serving an asset."""
    content_type, _ = mimetypes.guess_type(path, strict=False)
    return content_type or "application/octet-stream"


def _script_path(account_id: str, namespace: str, script: str, suffix: str = "") -> str:
    return f"{CF_API}/accounts/{account_id}/workers/dispatch/namespaces/{namespace}/scripts/{script}{suffix}"


@dataclass
class W4PStaticClient:
    """Cloudflare calls for one dispatch namespace on one account. Transport is injectable."""

    account_id: str
    token: str
    namespace: str
    transport: httpx.AsyncBaseTransport | None = None
    timeout: float = 60.0

    def _headers(self, token: str | None = None) -> dict[str, str]:
        return {"Authorization": f"Bearer {token or self.token}"}

    async def _check(self, response: httpx.Response, what: str) -> dict[str, Any]:
        try:
            body = response.json()
        except ValueError:
            body = {}
        if response.status_code >= 300 or not body.get("success", False):
            errors = [e.get("message") for e in body.get("errors", [])][:1]
            raise W4PStaticError(f"{what}: HTTP {response.status_code} {errors}")
        return body.get("result") or {}

    async def create_upload_session(self, script: str, manifest: dict[str, dict[str, Any]]) -> tuple[str, list]:
        url = _script_path(self.account_id, self.namespace, script, "/assets-upload-session")
        async with httpx.AsyncClient(transport=self.transport, timeout=self.timeout) as c:
            r = await c.post(url, headers=self._headers(), json={"manifest": manifest})
        result = await self._check(r, "assets_upload_session")
        return result["jwt"], list(result.get("buckets") or [])

    async def upload_batch(self, upload_jwt: str, files_by_hash: dict[str, StaticFile], buckets: list) -> str:
        """Upload the files the session asked for. Returns the completion token (the last batch's JWT).

        Cloudflare returns ``buckets`` as a list of batches, each a list of hashes. A flat list of
        hashes is accepted too, and split by size."""
        completion = upload_jwt
        url = f"{CF_API}/accounts/{self.account_id}/workers/assets/upload?base64=true"
        for batch in _normalize_buckets(buckets, files_by_hash):
            form = {
                h: (
                    h,
                    base64.b64encode(files_by_hash[h].content).decode("ascii"),
                    asset_content_type(files_by_hash[h].path),
                )
                for h in batch
            }
            async with httpx.AsyncClient(transport=self.transport, timeout=self.timeout) as c:
                r = await c.post(url, headers=self._headers(upload_jwt), files=form)
            result = await self._check(r, "assets_upload_batch")
            if result.get("jwt"):
                completion = result["jwt"]
        return completion

    async def put_worker(
        self,
        script: str,
        completion_jwt: str,
        compatibility_date: str,
        source: str | None = None,
    ) -> dict[str, Any]:
        metadata = {
            "main_module": MAIN_MODULE,
            "compatibility_date": compatibility_date,
            "assets": {"jwt": completion_jwt, "config": ASSET_CONFIG},
            # The binding must be declared explicitly in direct-upload metadata.
            # Secrets are not plaintext bindings. Core sets them after upload.
            "bindings": [{"name": "ASSETS", "type": "assets"}],
        }
        url = _script_path(self.account_id, self.namespace, script)
        files = {
            "metadata": (None, json.dumps(metadata), "application/json"),
            MAIN_MODULE: (
                MAIN_MODULE,
                source if source is not None else worker_source(),
                "application/javascript+module",
            ),
        }
        async with httpx.AsyncClient(transport=self.transport, timeout=self.timeout) as c:
            r = await c.put(url, headers=self._headers(), files=files)
        return await self._check(r, "put_worker")

    async def put_secret(self, script: str, name: str, text: str) -> None:
        """Store a Worker secret. The value is not part of the upload metadata."""
        url = _script_path(self.account_id, self.namespace, script, "/secrets")
        async with httpx.AsyncClient(transport=self.transport, timeout=self.timeout) as c:
            r = await c.put(
                url,
                headers={**self._headers(), "Content-Type": "application/json"},
                json={"name": name, "text": text, "type": "secret_text"},
            )
        await self._check(r, "put_secret")

    async def list_script_names(self) -> list[str]:
        url = f"{CF_API}/accounts/{self.account_id}/workers/dispatch/namespaces/{self.namespace}/scripts"
        async with httpx.AsyncClient(transport=self.transport, timeout=self.timeout) as c:
            r = await c.get(url, headers=self._headers())
        result = await self._check(r, "list_scripts")
        rows = result if isinstance(result, list) else result.get("scripts") or result.get("result") or []
        names: list[str] = []
        for row in rows:
            if isinstance(row, str):
                names.append(row)
            elif isinstance(row, dict) and row.get("id"):
                names.append(str(row["id"]))
            elif isinstance(row, dict) and row.get("name"):
                names.append(str(row["name"]))
        return names

    async def delete_script(self, script: str) -> None:
        url = _script_path(self.account_id, self.namespace, script)
        async with httpx.AsyncClient(transport=self.transport, timeout=self.timeout) as c:
            r = await c.delete(url, headers=self._headers())
        if r.status_code == 404:
            return
        await self._check(r, "delete_script")

    async def deploy(
        self,
        script: str,
        files: list[StaticFile],
        compatibility_date: str,
        source: str | None = None,
    ) -> dict[str, Any]:
        manifest, by_hash = build_manifest(files)
        jwt, buckets = await self.create_upload_session(script, manifest)
        completion = await self.upload_batch(jwt, by_hash, buckets) if buckets else jwt
        return await self.put_worker(script, completion, compatibility_date, source=source)


def _normalize_buckets(buckets: list, files_by_hash: dict[str, StaticFile]):
    """Batches as lists of hashes. Nested batches pass through; a flat list is split by size."""
    if buckets and all(isinstance(b, list) for b in buckets):
        for batch in buckets:
            if batch:
                yield batch
        return
    yield from _batches([str(h) for h in buckets], files_by_hash)


def _batches(hashes: list[str], files_by_hash: dict[str, StaticFile]):
    batch: list[str] = []
    size = 0
    for h in hashes:
        n = len(files_by_hash[h].content)
        if batch and size + n > MAX_BATCH_BYTES:
            yield batch
            batch, size = [], 0
        batch.append(h)
        size += n
    if batch:
        yield batch


class W4PStaticDeploymentProvider:
    """Deployment plane for ``cloudflare_w4p_static``. Routing stays with the routing plane.

    ``deploy_artifact`` uploads the version script. ``activate_deployment`` points the hostname's
    KV record at it, and ``rollback`` points it back at the previous version's script.
    """

    def __init__(self, *, account_id: str, token: str, namespace: str, kv_write, kv_read, kv_delete=None, compatibility_date: str,
                 transport: httpx.AsyncBaseTransport | None = None) -> None:
        self._client = W4PStaticClient(account_id=account_id, token=token, namespace=namespace, transport=transport)
        self._kv_write = kv_write      # async (key, value) -> None
        self._kv_read = kv_read        # async (key) -> str | None
        self._kv_delete = kv_delete    # async (key) -> None
        self._compat = compatibility_date

    async def ensure_project_environment(self, ctx: DeployJobContext) -> DeploymentOutcome:
        return DeploymentOutcome(ok=True, last_completed_step="ensure_project_environment",
                                 provider_namespace=self._client.namespace, metadata={"adapter": "w4p_static"})

    async def deploy_artifact(self, ctx: DeployJobContext, artifact: DeployArtifact, target: DeploymentTarget) -> DeploymentOutcome:
        static_dir = (artifact.build_metadata or {}).get("static_dir")
        if not static_dir:
            return DeploymentOutcome(ok=False, last_completed_step="deploy_artifact", message="static_dir_missing")
        live = f"p2hs-{'prev' if target.environment == 'preview' else 'prod'}-{ctx.project_ref}"
        version = version_script_name(live, ctx.git_sha)
        files = collect_static_files(static_dir)
        repo_root = (artifact.build_metadata or {}).get("repo_root")
        source = None
        if repo_root:
            from src.domain.hosting.runtime.worker_source import (
                ApplicationRouteError,
                build_worker_source,
            )

            try:
                source = build_worker_source(repo_root=repo_root)
            except ApplicationRouteError as exc:
                return DeploymentOutcome(ok=False, last_completed_step="deploy_artifact", message=str(exc))
        try:
            await self._client.deploy(version, files, self._compat, source=source)
        except W4PStaticError as exc:
            logger.error("w4p_static_deploy_failed", script=version, error=str(exc))
            return DeploymentOutcome(ok=False, last_completed_step="deploy_artifact", message=str(exc))
        logger.info("w4p_static_version_deployed", script=version, files=len(files))
        return DeploymentOutcome(ok=True, last_completed_step="deploy_artifact", provider_resource_id=version,
                                 provider_version_id=ctx.git_sha, provider_namespace=self._client.namespace,
                                 metadata={"adapter": "w4p_static", "files": len(files)})

    async def activate_deployment(self, ctx: DeployJobContext, version_script: str, live_script: str,
                                  hostname: str, descriptor: dict[str, Any]) -> DeploymentOutcome:
        """Point the hostname's routing record at ``version_script``. The previous record is kept
        so rollback can restore it."""
        key = f"host:{hostname}"
        previous = await self._kv_read(key)
        record = {**descriptor, "script_name": version_script, "previous_record": previous}
        await self._kv_write(key, json.dumps(record))
        await self._kv_write(f"project:{ctx.project_ref}:{descriptor.get('environment', 'production')}", hostname)
        logger.info("w4p_static_activated", hostname=hostname, script=version_script)
        return DeploymentOutcome(ok=True, last_completed_step="activate_deployment", activation_status="live",
                                 provider_resource_id=version_script, metadata={"adapter": "w4p_static"})

    async def rollback(self, hostname: str) -> DeploymentOutcome:
        """Restore the hostname's previous routing record. No files are uploaded."""
        key = f"host:{hostname}"
        current = await self._kv_read(key)
        if not current:
            return DeploymentOutcome(ok=False, last_completed_step="rollback", message="no_routing_record")
        record = json.loads(current)
        previous = record.get("previous_record")
        if not previous:
            return DeploymentOutcome(ok=False, last_completed_step="rollback", message="no_previous_version")
        await self._kv_write(key, previous)
        return DeploymentOutcome(ok=True, last_completed_step="rollback", activation_status="rolled_back",
                                 metadata={"adapter": "w4p_static"})

    async def read_route(self, hostname: str) -> str | None:
        return await self._kv_read(f"host:{hostname}")

    async def restore_route(self, hostname: str, raw: str | None) -> None:
        key = f"host:{hostname}"
        if raw is None:
            if self._kv_delete is None:
                raise W4PStaticError("kv_delete_unavailable")
            await self._kv_delete(key)
        else:
            await self._kv_write(key, raw)

    async def copy_route(self, source_hostname: str, dest_hostname: str) -> None:
        """Point a custom hostname at the same script, project, and api_execution as the canonical host."""
        raw = await self._kv_read(f"host:{source_hostname}")
        if not raw:
            raise W4PStaticError(f"no_routing_record:{source_hostname}")
        await self._kv_write(f"host:{dest_hostname}", raw)

    async def bind_runtime_secrets(self, script: str, secrets: dict[str, str]) -> None:
        """Worker-side configuration. Values stay out of the script upload metadata."""
        for name, text in secrets.items():
            if text:
                await self._client.put_secret(script, name, text)

    async def prune_old_versions(self, prefix: str, keep: set[str]) -> list[str]:
        names = await self._client.list_script_names()
        doomed = version_scripts_to_delete(names, prefix=prefix, keep=keep)
        for name in doomed:
            await self._client.delete_script(name)
        return doomed

    async def get_deployment_status(self, ctx: DeployJobContext) -> DeploymentOutcome:
        return DeploymentOutcome(ok=True, last_completed_step="get_deployment_status",
                                 provider_version_id=ctx.git_sha, metadata={"adapter": "w4p_static"})
