"""One-time: install the Platform Port Catalog BFF and Direct-Upload each Pages Worker.

Prompt-only codegen does not touch live Workers. This script walks every
HTML/React/Nuxt project checkout, writes the current catalog adapters (jobs,
auth, records, storage, …), syncs CLOUD_* env, and ``wrangler pages deploy``s
so the running Worker executes Cloud instead of homemade routes.

Homemade files that shadow a catalog route are backed up as ``*.p2hs-local.bak``.
``--ports-only`` skips shadow-store retirement and reuses an existing Pages
output when it is already deployable.

W4P proxy Workers are not updated — they only forward to Pages.

Usage (P2HS-BE root, same .env as the API). ``--apply`` finds nvm Node 22
itself and unsets ``npm_config_prefix`` (that var blocks ``nvm use``).
Host Node 16 cannot build Nuxt/Vite or run current nuxi/wrangler.

    python -m scripts.redeploy_pages_cloud_runtime
    python -m scripts.redeploy_pages_cloud_runtime --apply --ports-only
    python -m scripts.redeploy_pages_cloud_runtime --apply --project-id <uuid>
"""
from __future__ import annotations  # noqa: I001

import argparse
import asyncio
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
from uuid import UUID

from src.core.orm_import import import_all_orm_models

import_all_orm_models()

from src.config import get_settings  # noqa: E402
from src.core.database import async_session_factory  # noqa: E402
from src.domain.codegen.legacy_store_retire import retire_shadow_store_files  # noqa: E402
from src.domain.codegen.port_adapter import (  # noqa: E402
    install_catalog_adapters,
    port_install_status,
)
from src.domain.hosting.deployment_targets import (  # noqa: E402
    get_pages_build_config,
    pages_project_name,
)
from src.domain.projects.models import MVP_PROJECT_TYPES, Project  # noqa: E402
from src.domain.projects.paths import get_project_repo_path  # noqa: E402
from src.domain.projects.platform_files_sync import write_managed_files  # noqa: E402
from src.workers.tasks.env_sync import sync_project_env_vars  # noqa: E402

_DEFAULT_BRANCHES = ("preview", "main")
_NPM_TIMEOUT_S = 15 * 60
_DEPLOY_TIMEOUT_S = 10 * 60


def node_major(version: str) -> int:
    text = (version or "").strip().lstrip("v")
    if not text:
        return 0
    try:
        return int(text.split(".", 1)[0])
    except ValueError:
        return 0


def _node_version(node_bin: str) -> str:
    raw = subprocess.run(
        [node_bin, "-v"],
        check=False,
        capture_output=True,
        text=True,
    )
    return (raw.stdout or raw.stderr or "").strip()


def require_node(min_major: int = 20) -> str:
    """Abort on the API box's Node 16 — nuxi/Vite/wrangler need 20+."""
    node = shutil.which("node")
    if not node:
        raise RuntimeError("node is not on PATH. Use Node 20+ (nvm use 22).")
    version = _node_version(node)
    major = node_major(version)
    if major < min_major:
        raise RuntimeError(
            f"{node} reports {version or 'unknown'}; Nuxt/Vite/wrangler need Node {min_major}+. "
            "Do not ignore EBADENGINE / styleText / structuredClone on Node 16."
        )
    return version


def nvm_dir() -> Path:
    raw = (os.environ.get("NVM_DIR") or "").strip()
    return Path(raw) if raw else Path.home() / ".nvm"


def list_nvm_node_bins(min_major: int = 20) -> list[Path]:
    versions = nvm_dir() / "versions" / "node"
    if not versions.is_dir():
        return []
    ranked: list[tuple[tuple[int, ...], Path]] = []
    for child in versions.iterdir():
        node = child / "bin" / "node"
        major = node_major(child.name)
        if major >= min_major and node.is_file():
            ranked.append((_version_key(child.name), child / "bin"))
    ranked.sort(key=lambda item: item[0], reverse=True)
    return [path for _, path in ranked]


def _version_key(version: str) -> tuple[int, ...]:
    parts: list[int] = []
    for bit in (version or "").lstrip("v").split("."):
        try:
            parts.append(int(bit))
        except ValueError:
            parts.append(0)
    return tuple(parts)


def activate_modern_node(min_major: int = 20) -> str:
    """Put the newest nvm Node first. Do not keep a PATH Node 20 when 22 exists."""
    os.environ.pop("npm_config_prefix", None)
    os.environ.pop("NPM_CONFIG_PREFIX", None)

    for bin_dir in list_nvm_node_bins(min_major):
        os.environ["PATH"] = f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}"
        node = shutil.which("node")
        version = _node_version(node) if node else ""
        if node and node_major(version) >= min_major:
            return version

    node = shutil.which("node")
    version = _node_version(node) if node else ""
    if node and node_major(version) >= min_major:
        return version

    raise RuntimeError(
        f"Need Node {min_major}+ (found {version or 'none'}). "
        "Install Node 22 with nvm, then rerun. Unset npm_config_prefix first — "
        "that variable makes `nvm use` a no-op on this host."
    )


def pages_deploy_command(upload_dir: Path, project_name: str, branch: str) -> list[str]:
    node = shutil.which("node")
    wrangler = shutil.which("wrangler")
    same_tree = bool(
        node and wrangler and Path(wrangler).resolve().parent == Path(node).resolve().parent
    )
    prefix = (
        [wrangler]
        if same_tree
        else [shutil.which("npx") or "npx", "--yes", "wrangler@4"]
    )
    return [
        *prefix,
        "pages",
        "deploy",
        str(upload_dir),
        f"--project-name={project_name}",
        f"--branch={branch}",
        "--commit-dirty=true",
    ]


def npm_install_command(repo: Path) -> list[str]:
    """Generated repos often have drifted locks; ``npm ci`` fails there."""
    return ["npm", "install"]


_ERROR_LINE = re.compile(
    r"(?i)\b(error|fatal|failed|cannot find|enoent|rolluperror|killed|oom)\b"
)


def command_failure_detail(stderr: str, stdout: str, *, limit: int = 2000) -> str:
    """Prefer stderr and explicit ERROR lines; ignore huge Vite asset lists."""
    err = (stderr or "").strip()
    out = (stdout or "").strip()
    chunks: list[str] = []
    if err:
        chunks.append(err if len(err) <= limit * 2 else f"{err[:limit]}\n...\n{err[-limit:]}")
    interesting = [
        line
        for line in f"{err}\n{out}".splitlines()
        if _ERROR_LINE.search(line) and "vite:reporter" not in line
    ]
    if interesting:
        chunks.append("---- errors ----\n" + "\n".join(interesting[-40:]))
    elif out and not err:
        chunks.append(out if len(out) <= limit * 2 else f"{out[:limit]}\n...\n{out[-limit:]}")
    return "\n".join(chunks) or "no command output"


def pages_output_ready(repo: Path, project_type: str) -> bool:
    dest = get_pages_build_config(project_type).destination_dir
    upload = repo / dest if dest else repo
    if not upload.is_dir():
        return False
    if any(
        (upload / name).exists()
        for name in ("index.html", "200.html", "_worker.js", "_worker.mjs", "404.html")
    ):
        return True
    hashed = upload / "_nuxt"
    return hashed.is_dir() and any(hashed.iterdir())


def assemble_pages_output(repo: Path) -> Path | None:
    """Turn a partial Nuxt build into a Direct Upload dir (static + Pages Functions)."""
    public = repo / ".output" / "public"
    client = repo / ".nuxt" / "dist" / "client"
    if client.is_dir():
        public.mkdir(parents=True, exist_ok=True)
        shutil.copytree(client, public, dirs_exist_ok=True)
        for name in ("index.html", "200.html"):
            if (client / name).is_file() and not (public / name).is_file():
                shutil.copy2(client / name, public / name)
    if not public.is_dir():
        return None
    if not (public / "index.html").is_file() and (public / "200.html").is_file():
        shutil.copy2(public / "200.html", public / "index.html")
    if (public / "index.html").is_file() or (public / "200.html").is_file():
        return public
    return None


def clean_js_build_tree(repo: Path) -> list[str]:
    """Drop Node-16 installs and stale Nuxt caches before a Node 22 build."""
    removed: list[str] = []
    for name in ("node_modules", ".nuxt", ".output"):
        path = repo / name
        if path.is_dir():
            shutil.rmtree(path)
            removed.append(name)
    return removed


def platform_build_env(
    *,
    project_id: str,
    project_ref: str,
    service_token: str | None,
) -> dict[str, str]:
    """Cloud/BFF vars for ``nuxt generate`` prerender — never written to the public checkout."""
    settings = get_settings()
    cloud = (settings.cloud_public_api_url or settings.cloud_control_api_url or "").rstrip("/")
    core = (settings.api_url or "").rstrip("/")
    client_id = f"cli_{project_ref or project_id}"
    env = {
        "PROJECT_ID": project_id,
        "NUXT_PUBLIC_P2HS_PROJECT_ID": project_id,
        "VITE_P2HS_PROJECT_ID": project_id,
        "NUXT_PUBLIC_P2HS_CLIENT_ID": client_id,
        "VITE_P2HS_CLIENT_ID": client_id,
        "NODE_OPTIONS": os.environ.get("NODE_OPTIONS") or "--max-old-space-size=2048",
    }
    if cloud:
        env["CLOUD_GATEWAY_URL"] = cloud
    if service_token:
        env["CLOUD_SERVICE_TOKEN"] = service_token
    if core:
        env["P2HS_CORE_URL"] = core
    return env


def checkout_skip_reason(repo: Path, project_type: str) -> str | None:
    if not repo.is_dir():
        return "checkout missing"
    if project_type == "html":
        if (repo / "index.html").is_file() or (repo / "functions").is_dir():
            return None
        return "no index.html or functions/"
    if not (repo / "package.json").is_file():
        return "no package.json"
    return None


def merge_pages_functions(repo: Path, upload_dir: Path) -> None:
    """Copy ``functions/`` into the upload dir unless Nitro already shipped ``_worker.js``."""
    source = repo / "functions"
    if not source.is_dir():
        return
    if (upload_dir / "_worker.js").exists() or (upload_dir / "_worker.mjs").exists():
        return
    dest = upload_dir / "functions"
    if dest.resolve() == source.resolve():
        return
    if dest.exists():
        shutil.rmtree(dest)
    shutil.copytree(source, dest)


def resolve_upload_dir(repo: Path, project_type: str) -> Path:
    dest = get_pages_build_config(project_type).destination_dir
    upload = repo / dest if dest else repo
    if not upload.is_dir():
        raise RuntimeError(f"Build output missing: {upload}")
    merge_pages_functions(repo, upload)
    return upload


def _parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Direct-upload Cloud runtime onto existing CF Pages Workers.",
    )
    parser.add_argument(
        "--apply",
        action="store_true",
        help="Write files, sync env, build, and wrangler pages deploy. Default is dry-run.",
    )
    parser.add_argument("--project-id", action="append", help="Limit to one Core project UUID.")
    parser.add_argument(
        "--branch",
        action="append",
        dest="branches",
        help="Pages branch to deploy (repeatable). Default: preview and main.",
    )
    parser.add_argument(
        "--no-build",
        action="store_true",
        help="Skip npm install/build and deploy current output (html / already-built apps).",
    )
    parser.add_argument(
        "--no-env-sync",
        action="store_true",
        help="Skip CLOUD_* env sync (use after backfill_cloud_service_token).",
    )
    parser.add_argument(
        "--include-error-status",
        action="store_true",
        help="Also attempt projects whose Core status is error (default: skip them).",
    )
    parser.add_argument(
        "--build-command",
        help="Override the framework build (e.g. 'npm run build').",
    )
    parser.add_argument(
        "--no-clean",
        action="store_true",
        help="Reuse existing node_modules/.nuxt/.output instead of wiping them.",
    )
    parser.add_argument(
        "--ports-only",
        action="store_true",
        help=(
            "Install catalog BFF ports without retiring unrelated local stores. "
            "Skip npm build when Pages output is already deployable."
        ),
    )
    return parser.parse_args()


def _write_build_log(log_path: Path | None, title: str, result: subprocess.CompletedProcess[str]) -> None:
    if log_path is None:
        return
    log_path.parent.mkdir(parents=True, exist_ok=True)
    with log_path.open("a", encoding="utf-8") as handle:
        handle.write(f"\n===== {title} exit={result.returncode} =====\n")
        handle.write(result.stdout or "")
        handle.write(result.stderr or "")


def _run(
    cmd: list[str],
    *,
    cwd: Path,
    timeout: int,
    env: dict[str, str] | None = None,
    log_path: Path | None = None,
    stream: bool = False,
) -> None:
    if stream:
        if log_path is not None:
            log_path.parent.mkdir(parents=True, exist_ok=True)
        log = log_path.open("a", encoding="utf-8") if log_path is not None else None
        try:
            if log:
                log.write(f"\n===== {' '.join(cmd[:6])} =====\n")
                log.flush()
            proc = subprocess.Popen(
                cmd,
                cwd=str(cwd),
                env=env,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
                bufsize=1,
            )
            assert proc.stdout is not None
            for line in proc.stdout:
                sys.stdout.write(line)
                sys.stdout.flush()
                if log:
                    log.write(line)
            try:
                proc.wait(timeout=timeout)
            except subprocess.TimeoutExpired as exc:
                proc.kill()
                raise RuntimeError(f"{' '.join(cmd[:4])} timed out") from exc
        finally:
            if log:
                log.close()
        if proc.returncode != 0:
            hint = f"; full log {log_path}" if log_path is not None else ""
            raise RuntimeError(f"{' '.join(cmd[:4])} failed ({proc.returncode}){hint}")
        return

    result = subprocess.run(
        cmd,
        cwd=str(cwd),
        timeout=timeout,
        check=False,
        capture_output=True,
        text=True,
        env=env,
    )
    _write_build_log(log_path, " ".join(cmd[:6]), result)
    if result.returncode != 0:
        detail = command_failure_detail(result.stderr or "", result.stdout or "")
        raise RuntimeError(f"{' '.join(cmd[:4])} failed ({result.returncode}): {detail}")


def _build_checkout(
    repo: Path,
    project_type: str,
    *,
    env: dict[str, str] | None = None,
    build_command: str | None = None,
    clean: bool = True,
) -> None:
    cfg = get_pages_build_config(project_type)
    command = (build_command or cfg.build_command or "").strip()
    if not command:
        return
    if shutil.which("npm") is None:
        raise RuntimeError("npm is not on PATH")
    if clean:
        removed = clean_js_build_tree(repo)
        if removed:
            print(f"[clean {' '.join(removed)}]", end=" ", flush=True)
    merged = {**os.environ, **(env or {})}
    if project_type == "nuxt_app" and not build_command:
        # Skip `nuxt generate` (prerender 500s) and cloudflare-pages (worker OOM/bundle).
        # Static client + functions/ is the Direct Upload BFF layout.
        command = "npm run build"
        merged.setdefault("NITRO_PRESET", "static")
        print("[nuxt NITRO_PRESET=static]", end=" ", flush=True)
    log_path = repo / ".p2hs" / "redeploy-build.log"
    if log_path.exists():
        log_path.unlink()
    _run(npm_install_command(repo), cwd=repo, timeout=_NPM_TIMEOUT_S, env=merged, log_path=log_path)
    try:
        _run(
            command.split(),
            cwd=repo,
            timeout=_NPM_TIMEOUT_S,
            env=merged,
            log_path=log_path,
            stream=True,
        )
        return
    except RuntimeError as first:
        assembled = assemble_pages_output(repo)
        if assembled is not None:
            print(f"[partial Nuxt build, deploying {assembled} + functions/]", end=" ", flush=True)
            return
        raise first


def _install_runtime(
    repo: Path,
    project_type: str,
    context: dict | None = None,
    *,
    retire: bool = True,
) -> dict[str, list[str]]:
    managed = write_managed_files(str(repo), project_type)
    adapters = install_catalog_adapters(str(repo), project_type, context)
    retired: list[str] = []
    if retire:
        retired = retire_shadow_store_files(str(repo), refresh_stubs=True)
    adapters["managed"] = managed
    adapters["retired"] = retired
    return adapters


def _deploy_branches(
    upload_dir: Path,
    project_ref: str,
    branches: list[str],
    *,
    account_id: str,
    api_token: str,
) -> None:
    env = {
        **os.environ,
        "CLOUDFLARE_ACCOUNT_ID": account_id,
        "CLOUDFLARE_API_TOKEN": api_token,
    }
    name = pages_project_name(project_ref)
    for branch in branches:
        cmd = pages_deploy_command(upload_dir, name, branch)
        result = subprocess.run(
            cmd,
            cwd=str(upload_dir),
            timeout=_DEPLOY_TIMEOUT_S,
            check=False,
            capture_output=True,
            text=True,
            env=env,
        )
        if result.returncode != 0:
            detail = command_failure_detail(result.stderr or "", result.stdout or "")
            raise RuntimeError(f"wrangler pages deploy --branch={branch} failed: {detail}")


async def _service_token_for_build(project: Project) -> str | None:
    from src.domain.edge_functions.repository import ProjectFunctionTokenRepository
    from src.domain.edge_functions.runtime_token import ensure_project_cloud_service_token
    from src.integrations.cloud_control_client import get_cloud_client_for_workspace

    async with async_session_factory() as db:
        cloud_client = await get_cloud_client_for_workspace(project.workspace_id, db)
        token = await ensure_project_cloud_service_token(
            ProjectFunctionTokenRepository(db),
            cloud_client,
            project.id,
            always_push=False,
        )
        await db.commit()
    return token


async def _list_projects(project_ids: list[UUID]) -> list[Project]:
    from sqlalchemy import select

    async with async_session_factory() as db:
        stmt = (
            select(Project)
            .where(Project.deleted_at.is_(None))
            .where(Project.type.in_(MVP_PROJECT_TYPES))
            .order_by(Project.created_at.asc())
        )
        if project_ids:
            stmt = stmt.where(Project.id.in_(project_ids))
        return list((await db.execute(stmt)).scalars().all())


async def _port_context(project: Project, *, persist: bool = True) -> dict:
    from src.domain.cloud.facades import project_port_context

    async with async_session_factory() as db:
        context = await project_port_context(db, project)
        if persist:
            await db.commit()
        else:
            await db.rollback()
        return context


async def run(args: argparse.Namespace) -> int:
    settings = get_settings()
    if args.apply:
        version = activate_modern_node(20)
        print(f"Node {version} ({shutil.which('node')})")
    ids = [UUID(value) for value in (args.project_id or [])]
    branches = list(args.branches or _DEFAULT_BRANCHES)
    skip_error = not args.include_error_status
    projects = await _list_projects(ids)
    print(f"Found {len(projects)} HTML/React/Nuxt project(s).")
    deployed = skipped = stale = errors = 0
    for project in projects:
        ref = project.project_ref or ""
        repo = Path(
            get_project_repo_path(settings.repo_workspace, project.workspace_id, project.id)
        )
        label = (
            f"  {project.name!r}  ({project.id})  type={project.type}  "
            f"ref={ref or '-'}  status={project.status}"
        )
        print(label, end=" ")
        if skip_error and str(project.status) == "error":
            print("[skip: status=error]")
            skipped += 1
            continue
        if not ref:
            print("[skip: no project_ref]")
            skipped += 1
            continue
        reason = checkout_skip_reason(repo, str(project.type))
        if reason:
            print(f"[skip: {reason}]")
            skipped += 1
            continue
        try:
            context = await _port_context(project, persist=args.apply)
            status = port_install_status(str(repo), str(project.type), context)
            if status["stale"]:
                stale += 1
            if not args.apply:
                print(
                    f"[dry-run {'stale' if status['stale'] else 'current'} "
                    f"catalog={status['installed_version'] or 'none'}->"
                    f"{status['catalog_version']} files={len(status['changed_files'])} "
                    f"checkout={repo}]"
                )
                skipped += 1
                continue
            _install_runtime(
                repo,
                str(project.type),
                context,
                retire=not args.ports_only,
            )
            if not args.no_env_sync:
                await sync_project_env_vars({}, project_id=str(project.id))
            skip_build = args.no_build or (
                args.ports_only and pages_output_ready(repo, str(project.type))
            )
            if skip_build and args.ports_only and not args.no_build:
                print("[reuse Pages output]", end=" ")
            if not skip_build:
                token = await _service_token_for_build(project)
                _build_checkout(
                    repo,
                    str(project.type),
                    env=platform_build_env(
                        project_id=str(project.id),
                        project_ref=ref,
                        service_token=token,
                    ),
                    build_command=args.build_command,
                    clean=not args.no_clean,
                )
            upload = resolve_upload_dir(repo, str(project.type))
            if not settings.cloudflare_api_token or not settings.cloudflare_account_id:
                raise RuntimeError("CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID are not set")
            _deploy_branches(
                upload,
                ref,
                branches,
                account_id=settings.cloudflare_account_id,
                api_token=settings.cloudflare_api_token,
            )
            print(f"[deployed {upload} branches={','.join(branches)}]")
            deployed += 1
        except Exception as exc:
            print(f"[ERROR: {exc}]")
            errors += 1
    print(
        f"\nDone. deployed={deployed}  stale={stale}  skipped={skipped}  errors={errors}"
    )
    if not args.apply:
        print("Re-run with --apply --ports-only to install catalog BFF ports and Direct-Upload.")
    return 1 if errors else 0


def main() -> None:
    raise SystemExit(asyncio.run(run(_parse_args())))


if __name__ == "__main__":
    main()
