"""The Infomaniak Public Cloud adapter (openstacksdk) — the ``[infomaniak]`` extra.

Implements the :class:`~mgf.cloud.providers.base.CloudProvider` Protocol against a
real OpenStack cloud via ``openstacksdk``: compute, block storage, object storage
(Swift), and networking. It speaks only :mod:`mgf.cloud.models` types across the
Protocol — no openstacksdk object leaks out.

Two invariants:

* **SDK confinement.** ``openstacksdk`` is imported only here (and ``aws.py``).
  Importing this module needs the ``[infomaniak]`` extra; the factory
  (:func:`mgf.cloud.providers.get_provider`) turns a missing extra into a clear
  :class:`~mgf.cloud.exceptions.ConfigError`.
* **Exception translation (EH-02).** Every openstacksdk call runs inside
  :meth:`_boundary`, which maps ``openstack.exceptions`` onto the federation
  vocabulary (:class:`ResourceNotFoundError` / :class:`ResourceAlreadyExistsError`
  / :class:`ProviderError`) so no third-party exception escapes the adapter.

The OpenStack ``Connection`` authenticates lazily on first proxy use, so unit
tests inject a fake connection (``InfomaniakProvider(creds, connection=fake)``)
and never touch the network; the opt-in live integration test exercises the real
cloud.
"""

from __future__ import annotations

import base64
import time
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, cast

import openstack
from keystoneauth1 import exceptions as ka_exc
from mgf.common.observability import get_logger
from openstack import exceptions as os_exc

from mgf.cloud import defaults
from mgf.cloud.exceptions import (
    CredentialsError,
    ProviderError,
    ResourceAlreadyExistsError,
    ResourceNotFoundError,
)
from mgf.cloud.models import (
    OWNER_TAG,
    Flavor,
    FloatingIP,
    Image,
    Keypair,
    Network,
    ObjectContainer,
    ObjectInfo,
    SecurityGroup,
    SecurityRule,
    Server,
    ServerSpec,
    Volume,
    VolumeSnapshot,
    VolumeSpec,
)

if TYPE_CHECKING:
    from collections.abc import Iterator

    from mgf.cloud.config import Credentials

__all__ = ["InfomaniakProvider"]

LOG = get_logger(__name__)

# How long create_server waits for the instance to reach ACTIVE, in seconds.
_BOOT_TIMEOUT = 300

# Finite per-call REST timeout (SC-07): without it an openstacksdk proxy read can
# block indefinitely. RequestTimeout maps cleanly via _boundary to ProviderError.
_API_TIMEOUT = 30


class InfomaniakProvider:
    """:class:`CloudProvider` backed by Infomaniak Public Cloud (OpenStack)."""

    name = "infomaniak"

    def __init__(
        self,
        credentials: Credentials,
        *,
        connection: Any | None = None,
        boot_timeout: int = _BOOT_TIMEOUT,
        api_timeout: int = _API_TIMEOUT,
    ) -> None:
        self._creds = credentials.require_complete()
        self._conn = connection
        self._boot_timeout = boot_timeout
        self._api_timeout = api_timeout

    # --- connection ----------------------------------------------------------
    @property
    def conn(self) -> Any:
        """The openstacksdk :class:`Connection` (built + authenticated lazily)."""
        if self._conn is None:
            self._conn = self._build_connection()
        return self._conn

    def _build_connection(self) -> Any:
        c = self._creds
        if c.has_app_credential:
            secret = c.application_credential_secret
            auth = {
                "auth_url": c.auth_url,
                "application_credential_id": c.application_credential_id,
                "application_credential_secret": secret.get_secret_value() if secret else None,
            }
        else:
            auth = {
                "auth_url": c.auth_url,
                "username": c.username,
                "password": c.password.get_secret_value() if c.password else None,
                "project_id": c.project_id,
                "project_name": c.project_name,
                "user_domain_name": c.user_domain_name,
                "project_domain_name": c.project_domain_name,
            }
        auth = {k: v for k, v in auth.items() if v is not None}
        conn = openstack.connection.Connection(
            region_name=c.region_name,
            interface=c.interface,
            identity_api_version=str(c.identity_api_version),
            auth_type=c.auth_type,
            auth=auth,
            # SC-07: bound every REST read so a hung endpoint can't block forever.
            api_timeout=self._api_timeout,
        )
        # SC-17 audit seam: a credential-bearing session was assembled. Never logs
        # the secret — only the auth_url + non-secret identity it will present.
        LOG.info(
            "auth.ok",
            extra={
                "audit": True,
                "operation": "auth.ok",
                "auth_url": c.auth_url,
                "user": c.application_credential_id or c.username,
                "auth_type": c.auth_type,
            },
        )
        return conn

    @contextmanager
    def _boundary(self, what: str) -> Iterator[None]:
        """Translate openstacksdk exceptions into the federation vocabulary (EH-02).

        Also the central observability seam (LG-11): emits an INFO start/end pair
        with a STABLE ``operation`` field (the verb only — ids stay in ``detail``,
        never embedded in the operation name) plus ``duration_ms``.
        """
        # The verb is the first token (e.g. "create_server"); the rest (resource
        # ids) is detail, kept OUT of the stable operation field.
        verb, _, detail = what.partition(" ")
        operation = f"infomaniak.{verb}"
        started = time.perf_counter()
        LOG.info(operation, extra={"operation": operation, "phase": "start", "detail": detail})
        try:
            yield
        except (ResourceNotFoundError, ResourceAlreadyExistsError, ProviderError, CredentialsError):
            raise
        except os_exc.ResourceNotFound as exc:
            raise ResourceNotFoundError(what) from exc
        except os_exc.ConflictException as exc:
            raise ResourceAlreadyExistsError(what) from exc
        except ka_exc.Unauthorized as exc:
            # Keystone rejected the credentials (401). keystoneauth1 exceptions are
            # NOT openstack.exceptions, so they must be caught explicitly.
            LOG.warning(
                "auth.fail",
                extra={
                    "audit": True,
                    "operation": "auth.fail",
                    "auth_url": self._creds.auth_url,
                    "user": self._creds.application_credential_id or self._creds.username,
                },
            )
            raise CredentialsError(
                f"infomaniak: authentication failed during {what} — check the "
                f"clouds.yaml user/password/domain ({exc})"
            ) from exc
        except (os_exc.SDKException, ka_exc.ClientException) as exc:
            raise ProviderError(f"infomaniak: {what} failed: {exc}") from exc
        finally:
            duration_ms = round((time.perf_counter() - started) * 1000, 2)
            LOG.info(
                operation,
                extra={"operation": operation, "phase": "end", "duration_ms": duration_ms},
            )

    # =====================================================================
    # mapping — openstacksdk resource -> immutable mgf.cloud model
    # =====================================================================
    @staticmethod
    def _flavor_name(server: Any) -> str:
        fl = server.flavor
        if isinstance(fl, dict):
            return str(fl.get("original_name") or fl.get("id") or "")
        if fl is not None:
            return str(getattr(fl, "original_name", None) or getattr(fl, "id", "") or "")
        return ""

    @staticmethod
    def _image_id(server: Any) -> str:
        im = server.image
        if isinstance(im, dict):
            return str(im.get("id", ""))
        if im is not None:
            return str(getattr(im, "id", "") or "")
        return ""

    @classmethod
    def _map_server(cls, s: Any) -> Server:
        private: list[str] = []
        floating: list[str] = []
        for entries in (s.addresses or {}).values():
            for entry in entries:
                addr = entry.get("addr")
                if not addr:
                    continue
                if entry.get("OS-EXT-IPS:type") == "floating":
                    floating.append(addr)
                else:
                    private.append(addr)
        groups = tuple(g.get("name") for g in (s.security_groups or []) if g.get("name"))
        return Server(
            id=s.id,
            name=s.name or "",
            status=s.status or "",
            flavor=cls._flavor_name(s),
            image=cls._image_id(s),
            key_name=s.key_name,
            private_ips=tuple(private),
            floating_ips=tuple(floating),
            security_groups=groups,
            metadata=dict(s.metadata or {}),
            created_at=getattr(s, "created_at", None),
        )

    @staticmethod
    def _map_flavor(f: Any) -> Flavor:
        return Flavor(
            id=f.id,
            name=f.name or "",
            vcpus=int(f.vcpus or 0),
            ram_mb=int(f.ram or 0),
            disk_gb=int(f.disk or 0),
        )

    @staticmethod
    def _map_image(i: Any) -> Image:
        return Image(id=i.id, name=i.name or "")

    @staticmethod
    def _map_keypair(k: Any) -> Keypair:
        return Keypair(
            name=k.name or "",
            fingerprint=getattr(k, "fingerprint", "") or "",
            public_key=getattr(k, "public_key", "") or "",
        )

    @staticmethod
    def _map_volume(v: Any) -> Volume:
        attached: str | None = None
        for att in getattr(v, "attachments", None) or []:
            sid = att.get("server_id") if isinstance(att, dict) else None
            if sid:
                attached = sid
                break
        return Volume(
            id=v.id,
            name=v.name or "",
            size_gb=int(v.size or 0),
            status=v.status or "available",
            bootable=bool(getattr(v, "is_bootable", False)),
            attached_server_id=attached,
        )

    @staticmethod
    def _map_snapshot(s: Any) -> VolumeSnapshot:
        return VolumeSnapshot(
            id=s.id,
            name=s.name or "",
            volume_id=s.volume_id,
            size_gb=int(s.size or 0),
            status=s.status or "available",
        )

    @staticmethod
    def _map_floating_ip(f: Any) -> FloatingIP:
        return FloatingIP(
            id=f.id,
            address=f.floating_ip_address,
            attached_server_id=None,  # not carried on the FIP; resolve via the server
        )

    @staticmethod
    def _map_rule(r: Any) -> SecurityRule:
        get = r.get if isinstance(r, dict) else (lambda k, d=None: getattr(r, k, d))
        return SecurityRule(
            direction=get("direction", "ingress") or "ingress",
            protocol=get("protocol", None),
            port_min=get("port_range_min", None),
            port_max=get("port_range_max", None),
            remote_ip_prefix=get("remote_ip_prefix", None) or "0.0.0.0/0",
        )

    @classmethod
    def _map_security_group(cls, g: Any) -> SecurityGroup:
        rules = tuple(cls._map_rule(r) for r in (getattr(g, "security_group_rules", None) or []))
        return SecurityGroup(
            id=g.id,
            name=g.name or "",
            description=g.description or "",
            rules=rules,
        )

    @staticmethod
    def _map_network(n: Any) -> Network:
        return Network(id=n.id, name=n.name or "")

    @staticmethod
    def _map_container(c: Any) -> ObjectContainer:
        return ObjectContainer(
            name=c.name or "",
            object_count=int(getattr(c, "count", 0) or 0),
            bytes=int(getattr(c, "bytes", 0) or 0),
        )

    @staticmethod
    def _map_object(o: Any) -> ObjectInfo:
        size = getattr(o, "bytes", None)
        if size is None:
            size = getattr(o, "content_length", 0)
        last = getattr(o, "last_modified", None) or getattr(o, "last_modified_at", None)
        return ObjectInfo(
            name=o.name or "",
            bytes=int(size or 0),
            content_type=getattr(o, "content_type", None) or "application/octet-stream",
            last_modified=last,
            etag=getattr(o, "etag", None) or getattr(o, "hash", "") or "",
        )

    # =====================================================================
    # compute
    # =====================================================================
    def list_servers(self, owner: str | None = None) -> list[Server]:
        with self._boundary("list_servers"):
            servers = [self._map_server(s) for s in self.conn.compute.servers(details=True)]
        if owner is not None:
            servers = [s for s in servers if s.metadata.get(OWNER_TAG) == owner]
        return servers

    def _require_server(self, name_or_id: str) -> Any:
        srv = self.conn.compute.find_server(name_or_id, ignore_missing=True)
        if srv is None:
            raise ResourceNotFoundError(name_or_id)
        return srv

    def get_server(self, name_or_id: str) -> Server:
        with self._boundary(f"get_server {name_or_id}"):
            return self._map_server(self._require_server(name_or_id))

    def create_server(self, spec: ServerSpec) -> Server:
        with self._boundary(f"create_server {spec.name}"):
            if self.conn.compute.find_server(spec.name, ignore_missing=True) is not None:
                raise ResourceAlreadyExistsError(spec.name)
            flavor = self.conn.compute.find_flavor(spec.flavor, ignore_missing=True)
            if flavor is None:
                raise ResourceNotFoundError(f"flavor {spec.flavor}")
            image = self.conn.image.find_image(spec.image, ignore_missing=True)
            if image is None:
                raise ResourceNotFoundError(f"image {spec.image}")
            attrs: dict[str, Any] = {
                "name": spec.name,
                "flavor_id": flavor.id,
                "metadata": dict(spec.metadata),
            }
            if spec.key_name:
                attrs["key_name"] = spec.key_name
            if spec.user_data:
                attrs["user_data"] = base64.b64encode(spec.user_data.encode("utf-8")).decode(
                    "ascii"
                )
            if spec.security_groups:
                attrs["security_groups"] = [{"name": g} for g in spec.security_groups]
            if spec.network:
                network = self.conn.network.find_network(spec.network, ignore_missing=True)
                if network is None:
                    raise ResourceNotFoundError(f"network {spec.network}")
                attrs["networks"] = [{"uuid": network.id}]
            if spec.volume_size_gb is not None:
                # Boot from a new persistent volume; never delete it on terminate
                # (DESIGN §6: Delete-on-Terminate = No).
                attrs["block_device_mapping_v2"] = [
                    {
                        "boot_index": 0,
                        "uuid": image.id,
                        "source_type": "image",
                        "destination_type": "volume",
                        "volume_size": spec.volume_size_gb,
                        "delete_on_termination": False,
                    }
                ]
            else:
                attrs["image_id"] = image.id
            srv = self.conn.compute.create_server(**attrs)
            srv = self.conn.compute.wait_for_server(srv, status="ACTIVE", wait=self._boot_timeout)
            if spec.assign_floating_ip:
                # Floating IPs come from the EXTERNAL pool, not the instance network
                # (allocating from ext-net1 is a 400 "not a valid external network").
                fip = self.create_floating_ip()
                try:
                    self.attach_floating_ip(fip.address, srv.id)
                except Exception:
                    # Never leak the IP we just allocated if the attach fails.
                    self.release_floating_ip(fip.address)
                    raise
                srv = self.conn.compute.get_server(srv)
            return self._map_server(srv)

    def delete_server(self, name_or_id: str) -> None:
        with self._boundary(f"delete_server {name_or_id}"):
            srv = self._require_server(name_or_id)
            # Nova disassociates floating IPs (they stay allocated, not released) and
            # detaches Delete-on-Terminate=No volumes as part of the delete.
            self.conn.compute.delete_server(srv, ignore_missing=False)

    def reboot_server(self, name_or_id: str, *, hard: bool = False) -> None:
        with self._boundary(f"reboot_server {name_or_id}"):
            srv = self._require_server(name_or_id)
            self.conn.compute.reboot_server(srv, "HARD" if hard else "SOFT")

    def resize_server(self, name_or_id: str, flavor: str) -> None:
        with self._boundary(f"resize_server {name_or_id}"):
            srv = self._require_server(name_or_id)
            target = self.conn.compute.find_flavor(flavor, ignore_missing=True)
            if target is None:
                raise ResourceNotFoundError(f"flavor {flavor}")
            self.conn.compute.resize_server(srv, target.id)

    def snapshot_server(self, name_or_id: str, snapshot_name: str) -> Image:
        with self._boundary(f"snapshot_server {name_or_id}"):
            srv = self._require_server(name_or_id)
            result = self.conn.compute.create_server_image(srv, snapshot_name, wait=False)
            if result is not None and getattr(result, "id", None):
                return Image(id=result.id, name=getattr(result, "name", "") or snapshot_name)
            found = self.conn.image.find_image(snapshot_name, ignore_missing=True)
            if found is None:
                raise ProviderError(
                    f"infomaniak: snapshot {snapshot_name!r} not found after create"
                )
            return self._map_image(found)

    def list_flavors(self) -> list[Flavor]:
        with self._boundary("list_flavors"):
            return [self._map_flavor(f) for f in self.conn.compute.flavors(details=True)]

    def list_images(self) -> list[Image]:
        with self._boundary("list_images"):
            return [self._map_image(i) for i in self.conn.image.images()]

    # =====================================================================
    # keypairs
    # =====================================================================
    def list_keypairs(self) -> list[Keypair]:
        with self._boundary("list_keypairs"):
            return [self._map_keypair(k) for k in self.conn.compute.keypairs()]

    def create_keypair(self, name: str, public_key: str) -> Keypair:
        with self._boundary(f"create_keypair {name}"):
            return self._map_keypair(
                self.conn.compute.create_keypair(name=name, public_key=public_key)
            )

    def delete_keypair(self, name: str) -> None:
        with self._boundary(f"delete_keypair {name}"):
            self.conn.compute.delete_keypair(name, ignore_missing=False)

    # =====================================================================
    # block storage
    # =====================================================================
    def list_volumes(self) -> list[Volume]:
        with self._boundary("list_volumes"):
            return [self._map_volume(v) for v in self.conn.block_storage.volumes(details=True)]

    def _require_volume(self, name_or_id: str) -> Any:
        vol = self.conn.block_storage.find_volume(name_or_id, ignore_missing=True)
        if vol is None:
            raise ResourceNotFoundError(name_or_id)
        return vol

    def get_volume(self, name_or_id: str) -> Volume:
        with self._boundary(f"get_volume {name_or_id}"):
            return self._map_volume(self._require_volume(name_or_id))

    def create_volume(self, spec: VolumeSpec) -> Volume:
        with self._boundary(f"create_volume {spec.name}"):
            # EH-12 idempotency: a retry must not silently create a SECOND billable
            # volume with the same name (Cinder allows duplicate names by id).
            if self.conn.block_storage.find_volume(spec.name, ignore_missing=True) is not None:
                raise ResourceAlreadyExistsError(spec.name)
            return self._map_volume(
                self.conn.block_storage.create_volume(name=spec.name, size=spec.size_gb)
            )

    def attach_volume(self, volume: str, server: str) -> None:
        with self._boundary(f"attach_volume {volume} -> {server}"):
            vol = self._require_volume(volume)
            srv = self._require_server(server)
            self.conn.compute.create_volume_attachment(srv, volume_id=vol.id)

    def detach_volume(self, volume: str, server: str) -> None:
        with self._boundary(f"detach_volume {volume} <- {server}"):
            vol = self._require_volume(volume)
            srv = self._require_server(server)
            self.conn.compute.delete_volume_attachment(srv, vol.id, ignore_missing=False)

    def delete_volume(self, volume: str) -> None:
        with self._boundary(f"delete_volume {volume}"):
            vol = self._require_volume(volume)
            self.conn.block_storage.delete_volume(vol, ignore_missing=False)

    def snapshot_volume(self, volume: str, snapshot_name: str) -> VolumeSnapshot:
        with self._boundary(f"snapshot_volume {volume}"):
            vol = self._require_volume(volume)
            return self._map_snapshot(
                self.conn.block_storage.create_snapshot(volume_id=vol.id, name=snapshot_name)
            )

    # =====================================================================
    # network
    # =====================================================================
    def list_networks(self) -> list[Network]:
        with self._boundary("list_networks"):
            return [self._map_network(n) for n in self.conn.network.networks()]

    def list_floating_ips(self) -> list[FloatingIP]:
        with self._boundary("list_floating_ips"):
            return [self._map_floating_ip(f) for f in self.conn.network.ips()]

    def create_floating_ip(self, network: str | None = None) -> FloatingIP:
        pool = network or defaults.DEFAULT_FLOATING_NETWORK
        with self._boundary(f"create_floating_ip ({pool})"):
            ext = self.conn.network.find_network(pool, ignore_missing=True)
            if ext is None:
                raise ResourceNotFoundError(f"network {pool}")
            return self._map_floating_ip(self.conn.network.create_ip(floating_network_id=ext.id))

    def _require_floating_ip(self, address: str) -> Any:
        fip = self.conn.network.find_ip(address, ignore_missing=True)
        if fip is None:
            raise ResourceNotFoundError(address)
        return fip

    def _server_port_id(self, server_id: str) -> str:
        """The id of the server's first neutron port (for floating-IP association)."""
        port = next(iter(self.conn.network.ports(device_id=server_id)), None)
        if port is None:
            raise ProviderError(f"infomaniak: server {server_id} has no port for a floating IP")
        return str(port.id)

    def attach_floating_ip(self, address: str, server: str) -> None:
        with self._boundary(f"attach_floating_ip {address} -> {server}"):
            fip = self._require_floating_ip(address)
            srv = self._require_server(server)
            # Associate via Neutron (update the FIP's port_id). The legacy Nova
            # add_floating_ip_to_server proxy 404s on Infomaniak.
            self.conn.network.update_ip(fip, port_id=self._server_port_id(srv.id))

    def detach_floating_ip(self, address: str) -> None:
        with self._boundary(f"detach_floating_ip {address}"):
            fip = self._require_floating_ip(address)
            self.conn.network.update_ip(fip, port_id=None)

    def release_floating_ip(self, address: str) -> None:
        with self._boundary(f"release_floating_ip {address}"):
            fip = self._require_floating_ip(address)
            self.conn.network.delete_ip(fip, ignore_missing=False)

    def list_security_groups(self) -> list[SecurityGroup]:
        with self._boundary("list_security_groups"):
            return [self._map_security_group(g) for g in self.conn.network.security_groups()]

    def create_security_group(
        self,
        name: str,
        description: str = "",
        rules: tuple[SecurityRule, ...] = (),
    ) -> SecurityGroup:
        with self._boundary(f"create_security_group {name}"):
            if self.conn.network.find_security_group(name, ignore_missing=True) is not None:
                raise ResourceAlreadyExistsError(name)
            group = self.conn.network.create_security_group(name=name, description=description)
            for rule in rules:
                self.conn.network.create_security_group_rule(
                    security_group_id=group.id,
                    direction=rule.direction,
                    protocol=rule.protocol,
                    port_range_min=rule.port_min,
                    port_range_max=rule.port_max,
                    remote_ip_prefix=rule.remote_ip_prefix,
                    ether_type="IPv4",
                )
            # Re-read so the returned group carries the rules we just added.
            refreshed = self.conn.network.find_security_group(name, ignore_missing=True)
            return self._map_security_group(refreshed if refreshed is not None else group)

    def delete_security_group(self, name_or_id: str) -> None:
        with self._boundary(f"delete_security_group {name_or_id}"):
            group = self.conn.network.find_security_group(name_or_id, ignore_missing=True)
            if group is None:
                raise ResourceNotFoundError(name_or_id)
            self.conn.network.delete_security_group(group, ignore_missing=False)

    # =====================================================================
    # object storage (Swift)
    # =====================================================================
    def list_containers(self) -> list[ObjectContainer]:
        with self._boundary("list_containers"):
            return [self._map_container(c) for c in self.conn.object_store.containers()]

    def create_container(self, name: str) -> ObjectContainer:
        with self._boundary(f"create_container {name}"):
            return self._map_container(self.conn.object_store.create_container(name=name))

    def delete_container(self, name: str) -> None:
        with self._boundary(f"delete_container {name}"):
            self.conn.object_store.delete_container(name, ignore_missing=False)

    def list_objects(self, container: str) -> list[ObjectInfo]:
        with self._boundary(f"list_objects {container}"):
            return [self._map_object(o) for o in self.conn.object_store.objects(container)]

    def put_object(
        self,
        container: str,
        name: str,
        data: bytes,
        content_type: str = "application/octet-stream",
    ) -> ObjectInfo:
        with self._boundary(f"put_object {container}/{name}"):
            self.conn.object_store.upload_object(
                container=container, name=name, data=data, content_type=content_type
            )
            return ObjectInfo(name=name, bytes=len(data), content_type=content_type)

    def get_object(self, container: str, name: str) -> bytes:
        with self._boundary(f"get_object {container}/{name}"):
            return cast("bytes", self.conn.object_store.download_object(name, container=container))

    def delete_object(self, container: str, name: str) -> None:
        with self._boundary(f"delete_object {container}/{name}"):
            self.conn.object_store.delete_object(name, container=container, ignore_missing=False)
