"""``mgf-cloud-provision ci-host`` — stand up the federation's Woodpecker CI host.

Absorbed from ``mgf-fed provision-ci-host`` (Wave-2 P0.4 — one provisioner; the
mgf-fed verb is now a deprecation shim delegating here). Unlike the mgf-fed
original, the rendered bundle is the **current** production topology: the
Postgres compose (``examples/woodpecker-compose.yml`` — SQLite was migrated out
2026-06-21) and the two-vhost Caddyfile (web/API + the ``grpc.<domain>`` h2c
endpoint remote agents dial). A rebuild therefore reproduces what actually runs,
not the pre-migration single-writer topology.

The agent-VM spec ships alongside: :func:`agent_service_spec` loads the packaged
``woodpecker-agent.toml`` (byte-pinned to ``examples/``), and ``--with-agent``
converges ``woodpecker-agent2`` in the same run.

Provisioning flows through this repo's own converge engine
(:func:`mgf.cloud_provision._provision.plan` / ``apply``) so CREATE / NOOP /
REPLACE semantics match ``plan``/``apply`` — plus the one extra the engine does
not cover: the CI security group (22/80/443) is ensured first. Secrets are never
baked into user-data (the metadata service exposes it); ``.env`` / ``netrc`` are
pushed post-provision over SSH — see ``docs/woodpecker-ci-runbook.md`` §10 and
``docs/backup-restore-runbook.md`` for the restore path.
"""

from __future__ import annotations

import base64
from pathlib import Path
from typing import TYPE_CHECKING

from mgf.common.exceptions import ResourceAlreadyExistsError

from mgf.cloud_provision._provision import apply as _engine_apply
from mgf.cloud_provision._spec import load_spec
from mgf.cloud_provision.models import ServiceSpec

if TYPE_CHECKING:
    from mgf.cloud.providers.base import CloudProvider

__all__ = [
    "BUNDLE_FILES",
    "DEFAULT_ENV",
    "DEFAULT_FLAVOR",
    "DEFAULT_HOSTNAME",
    "SECGROUP_PORTS",
    "agent_service_spec",
    "ci_host_service_spec",
    "provision_ci_host",
    "render_ci_host_cloud_init",
]

DEFAULT_HOSTNAME = "woodpecker"
DEFAULT_ENV = "prod"
# The LIVE estate sizing (woodpecker-prod, runbook §2) — a rebuild reproduces
# production, not the original a1-ram4 experiment that proved too small.
DEFAULT_FLAVOR = "a4-ram8-disk20-perf1"
DEFAULT_IMAGE = "Ubuntu 24.04 LTS Noble Numbat"
DEFAULT_NETWORK = "ext-net1"  # direct-public — no floating IP needed
DEFAULT_VOLUME_GB = 40  # build images/layers need room; the disk20 flavor is tight
SECGROUP_PORTS: tuple[int, ...] = (22, 80, 443)
OWNER = "mgf-cloud-provision"
# Non-secret bundle files embedded into cloud-init (the .env is pushed separately).
BUNDLE_FILES: tuple[str, ...] = ("docker-compose.yml", "Caddyfile")


def _data_dir() -> Path:
    """The packaged bundle (byte-pinned to ``examples/`` by the unit tests)."""
    return Path(__file__).resolve().parent / "data" / "woodpecker"


def _load_bundle(bundle_dir: Path | None) -> dict[str, str]:
    """Read the non-secret bundle files (raises FileNotFoundError if any is missing)."""
    base = bundle_dir or _data_dir()
    out: dict[str, str] = {}
    for name in BUNDLE_FILES:
        path = base / name
        if not path.is_file():
            raise FileNotFoundError(f"woodpecker bundle file missing: {path}")
        out[name] = path.read_text(encoding="utf-8")
    return out


def render_ci_host_cloud_init(bundle: dict[str, str]) -> str:
    """Render a #cloud-config that installs Docker, opens ufw, and drops the bundle.

    File contents are base64-embedded (``encoding: b64``) so arbitrary YAML/compose
    text needs no escaping. The stack is **not** started here — the operator pushes
    ``.env`` (+ ``netrc``) and runs ``docker compose up -d`` (secrets stay out of
    user-data, which the metadata service exposes).
    """
    write_files = []
    for name, content in bundle.items():
        b64 = base64.b64encode(content.encode("utf-8")).decode("ascii")
        write_files.append(
            f"  - path: /opt/woodpecker/{name}\n"
            f"    encoding: b64\n"
            f"    permissions: '0644'\n"
            f"    content: {b64}\n"
        )
    ufw = "\n".join(f'  - [ ufw, allow, "{port}/tcp" ]' for port in SECGROUP_PORTS)
    return (
        "#cloud-config\n"
        "package_update: true\n"
        "packages:\n"
        "  - ca-certificates\n"
        "  - curl\n"
        "  - ufw\n"
        "write_files:\n" + "".join(write_files) + "runcmd:\n"
        "  - [ ufw, default, deny, incoming ]\n"
        "  - [ ufw, default, allow, outgoing ]\n" + ufw + "\n"
        '  - [ ufw, deny, out, "25" ]\n'
        "  - [ ufw, --force, enable ]\n"
        "  - sh -c 'command -v docker >/dev/null 2>&1 || "
        "(curl -fsSL https://get.docker.com | sh)'\n"
        "  - [ systemctl, enable, --now, docker ]\n"
        'final_message: "woodpecker host ready — push .env (examples/woodpecker.env.example) '
        'to /opt/woodpecker/.env then: cd /opt/woodpecker && docker compose up -d"\n'
    )


def ci_host_service_spec(
    *,
    hostname: str = DEFAULT_HOSTNAME,
    env: str = DEFAULT_ENV,
    flavor: str | None = None,
    image: str | None = None,
    network: str | None = None,
    volume_size_gb: int | None = DEFAULT_VOLUME_GB,
    floating_ip: bool = False,
    key_name: str | None = None,
    bundle_dir: Path | None = None,
) -> ServiceSpec:
    """The CI-host (server VM) spec — pure; reads only the bundle files."""
    bundle = _load_bundle(bundle_dir)
    return ServiceSpec(
        name=f"{hostname}-{env}",
        flavor=flavor or DEFAULT_FLAVOR,
        image=image or DEFAULT_IMAGE,
        provider="infomaniak",
        network=network or DEFAULT_NETWORK,
        key_name=key_name,
        security_groups=(f"{hostname}-{env}-ci",),
        volume_size_gb=volume_size_gb,
        assign_floating_ip=floating_ip,
        cloud_init=render_ci_host_cloud_init(bundle),
        owner=OWNER,
    )


def agent_service_spec() -> ServiceSpec:
    """The second CI agent VM (``woodpecker-agent2``) — the packaged spec.

    Loaded from the packaged copy of ``examples/woodpecker-agent.toml`` (+ its
    cloud-init), which the unit tests byte-pin to the ``examples/`` originals, so
    the documented example and the code path can never drift.
    """
    return load_spec(_data_dir() / "woodpecker-agent.toml")


def _render_plan(specs: tuple[ServiceSpec, ...]) -> str:
    """A human-readable summary for ``--dry-run`` (no cloud access)."""
    lines = ["ci-host plan:"]
    for spec in specs:
        vol = f"{spec.volume_size_gb} GB boot volume" if spec.volume_size_gb else "ephemeral disk"
        ci_len = len(spec.cloud_init or "")
        lines += [
            f"  server      {spec.name}",
            f"    flavor    {spec.flavor}",
            f"    image     {spec.image}",
            f"    network   {spec.network}  (floating IP: {'yes' if spec.assign_floating_ip else 'no'})",
            f"    disk      {vol}",
            f"    key       {spec.key_name or '(none)'}",
            f"    secgroups {', '.join(spec.security_groups) or '(none)'}"
            + (f"  ports {'/'.join(map(str, SECGROUP_PORTS))}" if spec.security_groups else ""),
            f"    cloud-init {ci_len} bytes (no secrets)",
        ]
    return "\n".join(lines)


def _ensure_security_group(provider: CloudProvider, name: str) -> None:
    """Create the CI security group (22/80/443 ingress); reuse it if it exists."""
    from mgf.cloud.models import SecurityRule

    rules = tuple(
        SecurityRule(direction="ingress", protocol="tcp", port_min=port, port_max=port)
        for port in SECGROUP_PORTS
    )
    try:
        provider.create_security_group(name, "woodpecker CI host", rules)
    except ResourceAlreadyExistsError as exc:  # EH-07: ONLY already-exists is fine — reuse
        print(f"  secgroup {name}: already exists, reusing ({exc})", flush=True)


def _resolve_provider(
    provider: CloudProvider | None,
    *,
    provider_name: str,
    clouds_yaml: str | None,
    cloud: str | None,
) -> CloudProvider:
    if provider is not None:
        return provider
    from mgf.cloud.providers import get_provider

    if provider_name == "fake":
        return get_provider("fake")
    # Live credential resolution — needs a working clouds.yaml + network; covered
    # by mgf-cloud's live integration test, not here.
    from mgf.cloud import config  # pragma: no cover

    credentials = config.load(
        clouds_yaml=clouds_yaml, cloud=cloud or "openstack"
    )  # pragma: no cover
    return get_provider(provider_name, credentials)  # pragma: no cover


def _next_steps(server_name: str, address: str | None, *, with_agent: bool) -> str:
    where = address or "<host-ip>"
    steps = [
        "",
        "Next (operator + one-time human steps — runbook: docs/woodpecker-ci-runbook.md §10):",
        f"  1. point DNS at {where} (or use {where}.sslip.io — Caddy needs a name for TLS)",
        "  2. create a Codeberg OAuth2 app (CONFIDENTIAL client; redirect https://<domain>/authorize)",
        "  3. fill .env from examples/woodpecker.env.example (incl. WOODPECKER_POSTGRES_PASSWORD)",
        f"  4. push it:  mgf-cloud remote push .env /opt/woodpecker/.env --host {where}",
        f"     + netrc:  mgf-cloud remote push netrc /opt/woodpecker/netrc --host {where}",
        f"  5. start it: mgf-cloud remote exec 'cd /opt/woodpecker && docker compose up -d' --host {where}",
        "  6. REBUILD? restore Postgres from last night's dump + re-enter the org secrets",
        "     (docs/backup-restore-runbook.md — the org secrets live only in that DB)",
    ]
    if not with_agent:
        steps.append(
            "  7. second agent: mgf-cloud-provision apply examples/woodpecker-agent.toml "
            "--provider infomaniak --yes"
        )
    steps.append(
        f"  {'7' if with_agent else '8'}. deploy the self-heal + reconciler crons (ops/) and the "
        "backup crons (backups/) — see the runbooks"
    )
    return "\n".join(steps)


def provision_ci_host(
    *,
    hostname: str = DEFAULT_HOSTNAME,
    env: str = DEFAULT_ENV,
    flavor: str | None = None,
    image: str | None = None,
    network: str | None = None,
    volume_size_gb: int | None = DEFAULT_VOLUME_GB,
    floating_ip: bool = False,
    key_name: str | None = None,
    bundle_dir: Path | None = None,
    with_agent: bool = False,
    provider_name: str = "infomaniak",
    clouds_yaml: str | None = None,
    cloud: str | None = None,
    dry_run: bool = False,
    yes: bool = False,
    allow_replace: bool = False,
    provider: CloudProvider | None = None,
) -> int:
    """Plan (and optionally apply) the Woodpecker CI host. Returns a process code.

    Contract (kept from the absorbed ``mgf-fed provision-ci-host``, whose shim
    delegates here): ``dry_run`` prints the plan and touches no cloud (rc 0); a
    mutating run without ``yes`` refuses (rc 1). ``with_agent`` also converges
    ``woodpecker-agent2`` from the packaged agent spec.
    """
    try:
        specs: tuple[ServiceSpec, ...] = (
            ci_host_service_spec(
                hostname=hostname,
                env=env,
                flavor=flavor,
                image=image,
                network=network,
                volume_size_gb=volume_size_gb,
                floating_ip=floating_ip,
                key_name=key_name,
                bundle_dir=bundle_dir,
            ),
        )
        if with_agent:
            specs += (agent_service_spec(),)
    except FileNotFoundError as exc:
        print(f"mgf-cloud-provision: {exc}", flush=True)
        return 1

    print(_render_plan(specs), flush=True)
    if dry_run:
        return 0
    if not yes:
        print(
            "\nmgf-cloud-provision: refusing to provision without --yes (or --dry-run to plan)",
            flush=True,
        )
        return 1

    prov = _resolve_provider(
        provider, provider_name=provider_name, clouds_yaml=clouds_yaml, cloud=cloud
    )
    for spec in specs:
        for group in spec.security_groups:
            _ensure_security_group(prov, group)
        result = _engine_apply(spec, prov, allow_replace=allow_replace)
        ips = ", ".join(result.ip_addresses) or "no address yet"
        print(f"\n✅ {result.name}: {result.action.value.upper()} — {ips}", flush=True)
    address = None
    server = next((s for s in prov.list_servers() if s.name == specs[0].name), None)
    if server is not None:
        address = (
            server.floating_ips[0]
            if server.floating_ips
            else (server.private_ips[0] if server.private_ips else None)
        )
    print(_next_steps(specs[0].name, address, with_agent=with_agent), flush=True)
    return 0
