"""``mgf-fed provision-ci-host`` — stand up the self-hosted Woodpecker VM (WF-11).

Provisions the federation's CI host end-to-end through **mgf-cloud**: a security
group (22/80/443) → a VM whose cloud-init installs Docker and drops the
``deploy/woodpecker/`` bundle into ``/opt/woodpecker/`` → a floating IP. The
operator then pushes the secret ``.env`` and brings the stack up (the secrets are
deliberately *not* baked into user-data, which the metadata service exposes).

``mgf-cloud`` is an **optional** dependency (the ``[cloud]`` extra). Importing this
module pulls nothing — the cloud is touched only on apply, and a missing extra
yields an actionable message (graceful degradation, DESIGN §9). Plan-building is
pure (stdlib) so ``--dry-run`` and the bundle/cloud-init rendering are fully
testable without the extra.
"""

from __future__ import annotations

import base64
from dataclasses import dataclass
from pathlib import Path

from mgf.common.exceptions import ResourceAlreadyExistsError

from mgf.fed._logging import log_operation

__all__ = [
    "DEFAULT_ENV",
    "DEFAULT_HOSTNAME",
    "SECGROUP_PORTS",
    "ProvisionPlan",
    "build_plan",
    "provision_ci_host",
]

DEFAULT_HOSTNAME = "woodpecker"
DEFAULT_ENV = "prod"
SECGROUP_PORTS: tuple[int, ...] = (22, 80, 443)
OWNER = "mgf-fed"
# Non-secret bundle files embedded into cloud-init (the .env is pushed separately).
_BUNDLE_FILES: tuple[str, ...] = ("docker-compose.yml", "Caddyfile")


def _repo_root() -> Path:
    # src/mgf/fed/_provision.py -> parents[3] == the mgf-fed repo root.
    return Path(__file__).resolve().parents[3]


def _default_bundle_dir() -> Path:
    return _repo_root() / "deploy" / "woodpecker"


@dataclass(frozen=True)
class ProvisionPlan:
    """A fully-resolved provisioning plan — what apply will create."""

    server_name: str
    flavor: str
    image: str
    network: str
    volume_size_gb: int | None
    floating_ip: bool
    key_name: str | None
    secgroup_name: str
    secgroup_ports: tuple[int, ...]
    owner: str
    bundle_dir: str
    bundle_files: tuple[str, ...]
    cloud_init: str

    def render(self) -> str:
        """A human-readable summary for ``--dry-run``."""
        vol = f"{self.volume_size_gb} GB boot volume" if self.volume_size_gb else "ephemeral disk"
        lines = [
            "provision-ci-host plan:",
            f"  server      {self.server_name}",
            f"  flavor      {self.flavor}",
            f"  image       {self.image}",
            f"  network     {self.network}  (floating IP: {'yes' if self.floating_ip else 'no'})",
            f"  disk        {vol}",
            f"  key         {self.key_name or '(none)'}",
            f"  secgroup    {self.secgroup_name}  ports {'/'.join(map(str, self.secgroup_ports))}",
            f"  owner tag   {self.owner}",
            f"  bundle      {self.bundle_dir} -> /opt/woodpecker/ ({', '.join(self.bundle_files)})",
            f"  cloud-init  {len(self.cloud_init)} bytes (Docker + ufw + bundle; no secrets)",
        ]
        return "\n".join(lines)


def _load_bundle(bundle_dir: Path) -> dict[str, str]:
    """Read the non-secret bundle files (raises if any is missing)."""
    out: dict[str, str] = {}
    for name in _BUNDLE_FILES:
        path = bundle_dir / 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_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`` and runs ``docker compose up -d`` (secrets stay out of user-data).
    """
    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 deploy/woodpecker/.env to '
        '/opt/woodpecker/.env then: cd /opt/woodpecker && docker compose up -d"\n'
    )


def build_plan(
    *,
    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 = 20,
    floating_ip: bool = False,
    key_name: str | None = None,
    bundle_dir: Path | None = None,
) -> ProvisionPlan:
    """Resolve a :class:`ProvisionPlan` (pure — no cloud access).

    ``flavor``/``image``/``network`` default to ``mgf.cloud.defaults`` when that
    extra is installed, else to the documented Infomaniak values, so plan-building
    works without the cloud extra.
    """
    f_default, i_default, n_default = _catalogue_defaults()
    bundle_path = bundle_dir or _default_bundle_dir()
    bundle = _load_bundle(bundle_path)
    return ProvisionPlan(
        server_name=f"{hostname}-{env}",
        flavor=flavor or f_default,
        image=image or i_default,
        network=network or n_default,
        volume_size_gb=volume_size_gb,
        floating_ip=floating_ip,
        key_name=key_name,
        secgroup_name=f"{hostname}-{env}-ci",
        secgroup_ports=SECGROUP_PORTS,
        owner=OWNER,
        bundle_dir=str(bundle_path),
        bundle_files=tuple(bundle),
        cloud_init=_render_cloud_init(bundle),
    )


def _catalogue_defaults() -> tuple[str, str, str]:
    """(flavor, image, network) — from mgf.cloud.defaults if present, else literals."""
    try:
        from mgf.cloud import defaults
    except ImportError:
        # Documented Infomaniak values (mirrors mgf.cloud.defaults; DESIGN §6).
        return ("a1-ram4-disk20-perf1", "Ubuntu 24.04 LTS Noble Numbat", "ext-net1")
    return (defaults.DEFAULT_FLAVOR, defaults.DEFAULT_IMAGE, defaults.DEFAULT_NETWORK)


class CloudExtraMissing(RuntimeError):
    """Raised on apply when the ``[cloud]`` extra (mgf-cloud) is not installed."""


def _resolve_provider(
    provider: object | None,
    *,
    provider_name: str,
    clouds_yaml: str | None,
    cloud: str,
) -> object:
    if provider is not None:
        return provider
    try:
        from mgf.cloud import config, get_provider
    except ImportError as exc:
        raise CloudExtraMissing(
            "provision-ci-host needs the cloud extra: pip install 'mgf-fed[cloud]'"
        ) from exc
    # Live credential resolution — needs working clouds.yaml + network; covered by
    # the mgf-cloud live integration test, not here.
    creds = (
        None
        if provider_name == "fake"
        else config.load(  # pragma: no cover
            clouds_yaml=clouds_yaml, cloud=cloud
        )
    )
    return get_provider(provider_name, creds)  # pragma: no cover


def _next_steps(server_name: str, address: str | None) -> str:
    where = address or "<host-ip>"
    return (
        "\nNext (WF-11 — operator + one-time human steps):\n"
        f"  1. point a DNS A/AAAA record at {where} (Caddy needs it for TLS)\n"
        "  2. create a Codeberg OAuth2 app (redirect https://<domain>/authorize)\n"
        "  3. fill deploy/woodpecker/.env from .env.example (+ a WOODPECKER_AGENT_SECRET)\n"
        f"  4. push it:  mgf-cloud remote push deploy/woodpecker/.env "
        f"/opt/woodpecker/.env --host {where}\n"
        f"  5. start it: mgf-cloud remote exec 'cd /opt/woodpecker && docker compose "
        f"up -d' --host {where}\n"
        "  6. log in, authorize Codeberg, enable the mgf-* repos (or give me an API token)\n"
    )


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 = 20,
    floating_ip: bool = False,
    key_name: str | None = None,
    bundle_dir: Path | None = None,
    provider_name: str = "infomaniak",
    clouds_yaml: str | None = None,
    cloud: str = "openstack",
    dry_run: bool = False,
    yes: bool = False,
    provider: object | None = None,
) -> int:
    """Plan (and optionally apply) the Woodpecker CI host. Returns a process code."""
    with log_operation("provision.ci_host", hostname=hostname, env=env, dry_run=dry_run):
        return _provision_ci_host(
            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,
            provider_name=provider_name,
            clouds_yaml=clouds_yaml,
            cloud=cloud,
            dry_run=dry_run,
            yes=yes,
            provider=provider,
        )


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 = 20,
    floating_ip: bool = False,
    key_name: str | None = None,
    bundle_dir: Path | None = None,
    provider_name: str = "infomaniak",
    clouds_yaml: str | None = None,
    cloud: str = "openstack",
    dry_run: bool = False,
    yes: bool = False,
    provider: object | None = None,
) -> int:
    try:
        plan = build_plan(
            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,
        )
    except FileNotFoundError as exc:
        print(f"mgf-fed: {exc}", flush=True)
        return 1

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

    try:
        prov = _resolve_provider(
            provider, provider_name=provider_name, clouds_yaml=clouds_yaml, cloud=cloud
        )
    except CloudExtraMissing as exc:
        print(f"mgf-fed: {exc}", flush=True)
        return 1

    return _apply(prov, plan)


def _apply(provider: object, plan: ProvisionPlan) -> int:
    """Create the security group, the VM, and (optionally) a floating IP."""
    from mgf.cloud.models import SecurityRule, ServerSpec, owned_metadata

    p = provider  # the CloudProvider Protocol (real or fake)
    rules = tuple(
        SecurityRule(direction="ingress", protocol="tcp", port_min=port, port_max=port)
        for port in plan.secgroup_ports
    )
    try:
        p.create_security_group(plan.secgroup_name, "woodpecker CI host", rules)  # type: ignore[attr-defined]
    except ResourceAlreadyExistsError as exc:  # EH-07: ONLY already-exists is fine — reuse it
        # auth / network / quota failures now propagate instead of being masked.
        print(f"  secgroup {plan.secgroup_name}: already exists, reusing ({exc})", flush=True)

    spec = ServerSpec(
        name=plan.server_name,
        flavor=plan.flavor,
        image=plan.image,
        network=plan.network,
        key_name=plan.key_name,
        security_groups=(plan.secgroup_name,),
        user_data=plan.cloud_init,
        volume_size_gb=plan.volume_size_gb,
        assign_floating_ip=plan.floating_ip,
        metadata=owned_metadata(plan.owner),
    )
    server = p.create_server(spec)  # type: ignore[attr-defined]
    address = (
        server.floating_ips[0]
        if server.floating_ips
        else (server.private_ips[0] if server.private_ips else None)
    )
    print(
        f"\n✅ provisioned {server.name} ({server.status}) — {address or 'no address yet'}",
        flush=True,
    )
    print(_next_steps(server.name, address), flush=True)
    return 0
