"""The converge engine — ``plan`` / ``apply`` / ``destroy`` over a CloudProvider.

Provider-agnostic: it takes any :class:`mgf.cloud.providers.base.CloudProvider`
(the live Infomaniak adapter in production, the in-memory ``FakeProvider`` in
tests), so the whole surface is exercisable with no cloud. The post-boot service
install is carried as cloud-init ``user_data`` on the created server — an SSH
converge step (install/restart) is a Phase-1b follow-up, not in this skeleton.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from mgf.cloud.models import ServerSpec, owned_metadata

from mgf.cloud_provision.exceptions import ProvisionError
from mgf.cloud_provision.models import Action, ApplyResult, ServicePlan, ServiceSpec

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

__all__ = ["apply", "destroy", "plan", "to_server_spec"]


def to_server_spec(spec: ServiceSpec) -> ServerSpec:
    """Project a :class:`ServiceSpec` onto mgf-cloud's :class:`ServerSpec`."""
    return ServerSpec(
        name=spec.name,
        flavor=spec.flavor,
        image=spec.image,
        network=spec.network,
        key_name=spec.key_name,
        security_groups=spec.security_groups,
        user_data=spec.cloud_init,
        volume_size_gb=spec.volume_size_gb,
        assign_floating_ip=spec.assign_floating_ip,
        metadata=owned_metadata(spec.owner),
    )


def _find(provider: CloudProvider, name: str) -> Server | None:
    """The server named ``name`` (provisioned by us), or None."""
    for server in provider.list_servers():
        if server.name == name:
            return server
    return None


def _drift(existing: Server, spec: ServiceSpec) -> list[str]:
    """Human-readable drift reasons between a live server and a spec."""
    reasons: list[str] = []
    if existing.flavor and existing.flavor != spec.flavor:
        reasons.append(f"flavor {existing.flavor!r}->{spec.flavor!r}")
    if existing.image and existing.image != spec.image:
        reasons.append(f"image {existing.image!r}->{spec.image!r}")
    return reasons


def plan(spec: ServiceSpec, provider: CloudProvider) -> ServicePlan:
    """Diff ``spec`` against the live cloud. Read-only — mutates nothing."""
    existing = _find(provider, spec.name)
    if existing is None:
        return ServicePlan(spec=spec, action=Action.CREATE, reason=f"no server named {spec.name!r}")
    reasons = _drift(existing, spec)
    if reasons:
        return ServicePlan(
            spec=spec, action=Action.REPLACE, reason="; ".join(reasons), current_id=existing.id
        )
    return ServicePlan(
        spec=spec, action=Action.NOOP, reason="server exists and matches", current_id=existing.id
    )


def _ips(server: Server) -> tuple[str, ...]:
    return (*server.private_ips, *server.floating_ips)


def apply(
    spec: ServiceSpec, provider: CloudProvider, *, allow_replace: bool = False
) -> ApplyResult:
    """Converge the cloud to ``spec`` and return what happened.

    NOOP is a no-op. REPLACE requires ``allow_replace=True`` (recreate is
    destructive — a fresh VM, new IP) and otherwise raises :class:`ProvisionError`.
    """
    decision = plan(spec, provider)

    if decision.action is Action.NOOP:
        existing = _find(provider, spec.name)
        return ApplyResult(
            name=spec.name,
            action=Action.NOOP,
            server_id=decision.current_id,
            ip_addresses=_ips(existing) if existing is not None else (),
        )

    if decision.action is Action.REPLACE:
        if not allow_replace:
            raise ProvisionError(
                f"{spec.name} drifts ({decision.reason}); pass allow_replace=True to recreate it"
            )
        provider.delete_server(spec.name)

    server = provider.create_server(to_server_spec(spec))
    return ApplyResult(
        name=spec.name,
        action=decision.action,
        server_id=server.id,
        ip_addresses=_ips(server),
    )


def destroy(name: str, provider: CloudProvider) -> bool:
    """Delete the service's server. Returns False if nothing was there."""
    if _find(provider, name) is None:
        return False
    provider.delete_server(name)
    return True
