"""Unit tests for the Infomaniak adapter (Pass 3) against a fake openstacksdk.

These need the ``[infomaniak]`` extra (the adapter imports ``openstack`` at module
top), so the whole module is skipped where openstacksdk is absent — the core-only
CI env. No network is touched: a stateful fake ``Connection`` is injected, so the
tests exercise mapping, dispatch, attr-building, and EH-02 exception translation
without authenticating against a live cloud (the live path is the opt-in
``tests/integration`` test).
"""

from __future__ import annotations

import base64
from typing import Any

import pytest

pytest.importorskip("openstack")

from keystoneauth1 import exceptions as ka_exc
from openstack import exceptions as os_exc

from mgf.cloud import available_providers, get_provider
from mgf.cloud.config import Credentials
from mgf.cloud.exceptions import (
    ProviderError,
    ResourceAlreadyExistsError,
    ResourceNotFoundError,
)
from mgf.cloud.models import (
    OWNER_TAG,
    SecurityRule,
    ServerSpec,
    VolumeSpec,
    owned_metadata,
)
from mgf.cloud.providers.base import CloudProvider
from mgf.cloud.providers.infomaniak import InfomaniakProvider


# --------------------------------------------------------------------------
# a small openstacksdk-shaped fake Connection
# --------------------------------------------------------------------------
class _Res:
    """Attribute-bag standing in for an openstacksdk resource."""

    def __init__(self, **kw: Any) -> None:
        self.__dict__.update(kw)


class _Proxy:
    """A namespace whose methods can be swapped per-test (for error injection)."""

    def __init__(self, **methods: Any) -> None:
        self.__dict__.update(methods)


class _FakeConn:
    """Stateful in-memory stand-in for an openstacksdk ``Connection``."""

    def __init__(self) -> None:
        self._seq = 0
        self.servers: dict[str, _Res] = {}
        self.volumes: dict[str, _Res] = {}
        self.fips: dict[str, _Res] = {}  # keyed by address
        self.sgs: dict[str, _Res] = {}  # keyed by id
        self.keypairs: dict[str, _Res] = {}
        self.containers: dict[str, dict[str, tuple[bytes, str]]] = {}
        self.created_server_attrs: dict[str, Any] = {}
        self.ports_by_server: dict[str, _Res] = {}
        self._flavors = {
            "a2-ram2-disk20": _Res(id="fl-1", name="a2-ram2-disk20", vcpus=1, ram=2048, disk=20),
            "a2-ram4-disk40": _Res(id="fl-2", name="a2-ram4-disk40", vcpus=2, ram=4096, disk=40),
        }
        self._images = {"Ubuntu 24.04 LTS": _Res(id="img-1", name="Ubuntu 24.04 LTS")}
        self._networks = {
            "ext-net1": _Res(id="net-1", name="ext-net1"),
            "ext-floating1": _Res(id="net-fip", name="ext-floating1"),  # the floating-IP pool
        }

        self.compute = _Proxy(
            servers=self._servers,
            find_server=self._find_server,
            get_server=self._get_server,
            create_server=self._create_server,
            wait_for_server=self._wait_for_server,
            delete_server=self._delete_server,
            reboot_server=self._reboot_server,
            resize_server=self._resize_server,
            create_server_image=self._create_server_image,
            flavors=self._list_flavors,
            find_flavor=self._find_flavor,
            keypairs=self._keypairs,
            create_keypair=self._create_keypair,
            delete_keypair=self._delete_keypair,
            create_volume_attachment=self._attach_vol,
            delete_volume_attachment=self._detach_vol,
            add_floating_ip_to_server=self._add_fip,
        )
        self.block_storage = _Proxy(
            volumes=self._list_volumes,
            find_volume=self._find_volume,
            create_volume=self._create_volume,
            delete_volume=self._delete_volume,
            create_snapshot=self._create_snapshot,
        )
        self.network = _Proxy(
            networks=self._list_networks,
            ips=self._list_ips,
            create_ip=self._create_ip,
            find_ip=self._find_ip,
            update_ip=self._update_ip,
            delete_ip=self._delete_ip,
            ports=self._ports,
            security_groups=self._list_sgs,
            find_security_group=self._find_sg,
            create_security_group=self._create_sg,
            create_security_group_rule=self._create_sg_rule,
            delete_security_group=self._delete_sg,
            find_network=self._find_network,
        )
        self.image = _Proxy(images=self._list_images, find_image=self._find_image)
        self.object_store = _Proxy(
            containers=self._list_containers,
            create_container=self._create_container,
            delete_container=self._delete_container,
            objects=self._objects,
            upload_object=self._upload,
            download_object=self._download,
            delete_object=self._del_obj,
        )

    def _n(self, kind: str) -> str:
        self._seq += 1
        return f"{kind}-{self._seq}"

    @staticmethod
    def _id_of(ref: Any) -> str:
        return ref.id if isinstance(ref, _Res) else str(ref)

    # compute --------------------------------------------------------------
    def _servers(self, details: bool = True) -> list[_Res]:
        return list(self.servers.values())

    def _find_server(self, name_or_id: str, ignore_missing: bool = True, **_: Any) -> _Res | None:
        if name_or_id in self.servers:
            return self.servers[name_or_id]
        for s in self.servers.values():
            if s.name == name_or_id:
                return s
        return None

    def _get_server(self, ref: Any) -> _Res:
        return self.servers[self._id_of(ref)]

    def _create_server(self, **attrs: Any) -> _Res:
        self.created_server_attrs = attrs
        sid = self._n("server")
        srv = _Res(
            id=sid,
            name=attrs["name"],
            status="ACTIVE",
            flavor=_Res(original_name="a2-ram2-disk20", id=attrs.get("flavor_id")),
            image=_Res(id=attrs.get("image_id", "img-1")),
            key_name=attrs.get("key_name"),
            addresses={"ext-net1": [{"addr": f"10.0.0.{self._seq}", "OS-EXT-IPS:type": "fixed"}]},
            security_groups=[{"name": g["name"]} for g in attrs.get("security_groups", [])],
            metadata=dict(attrs.get("metadata", {})),
            created_at="2026-01-01T00:00:00Z",
        )
        self.servers[sid] = srv
        self.ports_by_server[sid] = _Res(id=f"port-{sid}", device_id=sid)
        return srv

    def _wait_for_server(self, srv: _Res, status: str = "ACTIVE", wait: int | None = None) -> _Res:
        return self.servers[srv.id]

    def _delete_server(self, ref: Any, ignore_missing: bool = True, force: bool = False) -> None:
        del self.servers[self._id_of(ref)]

    def _reboot_server(self, ref: Any, reboot_type: str) -> None:
        self.last_reboot = (self._id_of(ref), reboot_type)

    def _resize_server(self, ref: Any, flavor: Any) -> None:
        sid = self._id_of(ref)
        self.servers[sid].flavor = _Res(original_name="a2-ram4-disk40", id=self._id_of(flavor))

    def _create_server_image(self, ref: Any, name: str, wait: bool = False, **_: Any) -> _Res:
        return _Res(id=self._n("img"), name=name)

    def _list_flavors(self, details: bool = True, **_: Any) -> list[_Res]:
        return list(self._flavors.values())

    def _find_flavor(self, name_or_id: str, ignore_missing: bool = True, **_: Any) -> _Res | None:
        return self._flavors.get(name_or_id) or next(
            (f for f in self._flavors.values() if f.id == name_or_id), None
        )

    def _keypairs(self, **_: Any) -> list[_Res]:
        return list(self.keypairs.values())

    def _create_keypair(self, name: str, public_key: str) -> _Res:
        kp = _Res(name=name, public_key=public_key, fingerprint=f"fp:{len(public_key)}")
        self.keypairs[name] = kp
        return kp

    def _delete_keypair(self, name_or_id: Any, ignore_missing: bool = True, **_: Any) -> None:
        key = self._id_of(name_or_id)
        if key not in self.keypairs:
            raise os_exc.ResourceNotFound(f"keypair {key}")
        del self.keypairs[key]

    def _attach_vol(self, srv: Any, volume_id: str | None = None, **_: Any) -> _Res:
        self.volumes[volume_id].attachments = [{"server_id": self._id_of(srv)}]
        return _Res(id=self._n("att"))

    def _detach_vol(self, srv: Any, volume: Any, ignore_missing: bool = True) -> None:
        self.volumes[self._id_of(volume)].attachments = []

    def _add_fip(self, srv: Any, address: str, fixed_address: str | None = None) -> None:
        s = self.servers[self._id_of(srv)]
        s.addresses["ext-net1"].append({"addr": address, "OS-EXT-IPS:type": "floating"})

    # block storage --------------------------------------------------------
    def _list_volumes(self, details: bool = True, **_: Any) -> list[_Res]:
        return list(self.volumes.values())

    def _find_volume(self, name_or_id: str, ignore_missing: bool = True, **_: Any) -> _Res | None:
        if name_or_id in self.volumes:
            return self.volumes[name_or_id]
        return next((v for v in self.volumes.values() if v.name == name_or_id), None)

    def _create_volume(self, name: str, size: int, **_: Any) -> _Res:
        vid = self._n("volume")
        vol = _Res(
            id=vid, name=name, size=size, status="available", is_bootable=False, attachments=[]
        )
        self.volumes[vid] = vol
        return vol

    def _delete_volume(self, ref: Any, ignore_missing: bool = True, **_: Any) -> None:
        del self.volumes[self._id_of(ref)]

    def _create_snapshot(self, volume_id: str, name: str, **_: Any) -> _Res:
        return _Res(
            id=self._n("snap"),
            name=name,
            volume_id=volume_id,
            size=self.volumes[volume_id].size,
            status="available",
        )

    # network --------------------------------------------------------------
    def _list_networks(self, **_: Any) -> list[_Res]:
        return list(self._networks.values())

    def _find_network(self, name_or_id: str, ignore_missing: bool = True, **_: Any) -> _Res | None:
        return self._networks.get(name_or_id) or next(
            (n for n in self._networks.values() if n.id == name_or_id), None
        )

    # image ----------------------------------------------------------------
    def _list_images(self, **_: Any) -> list[_Res]:
        return list(self._images.values())

    def _find_image(self, name_or_id: str, ignore_missing: bool = True, **_: Any) -> _Res | None:
        return self._images.get(name_or_id) or next(
            (i for i in self._images.values() if i.id == name_or_id), None
        )

    def _list_ips(self, **_: Any) -> list[_Res]:
        return list(self.fips.values())

    def _create_ip(self, floating_network_id: str, **_: Any) -> _Res:
        self.last_fip_network = floating_network_id
        addr = f"203.0.113.{len(self.fips) + 1}"
        fip = _Res(id=self._n("fip"), floating_ip_address=addr, port_id=None)
        self.fips[addr] = fip
        return fip

    def _find_ip(self, name_or_id: str, ignore_missing: bool = True, **_: Any) -> _Res | None:
        return self.fips.get(name_or_id)

    def _ports(self, device_id: str | None = None, **_: Any) -> list[_Res]:
        if device_id is not None:
            port = self.ports_by_server.get(device_id)
            return [port] if port is not None else []
        return list(self.ports_by_server.values())

    def _update_ip(self, fip: Any, **attrs: Any) -> _Res:
        addr = fip.floating_ip_address
        port_id = attrs.get("port_id")
        self.fips[addr].port_id = port_id
        if port_id:  # associate — reflect on the server's addresses
            sid = next((s for s, p in self.ports_by_server.items() if p.id == port_id), None)
            if sid is not None:
                self.servers[sid].addresses.setdefault("ext-net1", []).append(
                    {"addr": addr, "OS-EXT-IPS:type": "floating"}
                )
        else:  # dissociate — drop it from whichever server holds it
            for s in self.servers.values():
                for entries in s.addresses.values():
                    entries[:] = [e for e in entries if e.get("addr") != addr]
        return fip

    def _delete_ip(self, fip: Any, ignore_missing: bool = True, **_: Any) -> None:
        del self.fips[fip.floating_ip_address]

    def _list_sgs(self, **_: Any) -> list[_Res]:
        return list(self.sgs.values())

    def _find_sg(self, name_or_id: str, ignore_missing: bool = True, **_: Any) -> _Res | None:
        if name_or_id in self.sgs:
            return self.sgs[name_or_id]
        return next((g for g in self.sgs.values() if g.name == name_or_id), None)

    def _create_sg(self, name: str, description: str = "", **_: Any) -> _Res:
        gid = self._n("sg")
        group = _Res(id=gid, name=name, description=description, security_group_rules=[])
        self.sgs[gid] = group
        return group

    def _create_sg_rule(self, security_group_id: str, **attrs: Any) -> _Res:
        self.sgs[security_group_id].security_group_rules.append(attrs)
        return _Res(id=self._n("rule"))

    def _delete_sg(self, ref: Any, ignore_missing: bool = True, **_: Any) -> None:
        del self.sgs[self._id_of(ref)]

    # object store ---------------------------------------------------------
    def _list_containers(self, **_: Any) -> list[_Res]:
        return [
            _Res(name=name, count=len(objs), bytes=sum(len(d) for d, _ in objs.values()))
            for name, objs in self.containers.items()
        ]

    def _create_container(self, name: str, **_: Any) -> _Res:
        self.containers.setdefault(name, {})
        return _Res(name=name, count=0, bytes=0)

    def _delete_container(self, name: Any, ignore_missing: bool = True) -> None:
        if name not in self.containers:
            raise os_exc.ResourceNotFound(f"container {name}")
        del self.containers[name]

    def _objects(self, container: str, **_: Any) -> list[_Res]:
        if container not in self.containers:
            raise os_exc.ResourceNotFound(f"container {container}")
        return [
            _Res(name=n, bytes=len(d), content_type=ct, last_modified="2026", etag=f"e{len(d)}")
            for n, (d, ct) in self.containers[container].items()
        ]

    def _upload(
        self,
        container: str,
        name: str,
        data: bytes,
        content_type: str = "application/octet-stream",
        **_: Any,
    ) -> _Res:
        if container not in self.containers:
            raise os_exc.ResourceNotFound(f"container {container}")
        self.containers[container][name] = (data, content_type)
        return _Res(name=name)

    def _download(self, name: Any, container: str | None = None, **_: Any) -> bytes:
        objs = self.containers.get(container or "", {})
        if name not in objs:
            raise os_exc.ResourceNotFound(f"object {name}")
        return objs[name][0]

    def _del_obj(
        self, name: Any, container: str | None = None, ignore_missing: bool = True
    ) -> None:
        objs = self.containers.get(container or "", {})
        if name not in objs:
            raise os_exc.ResourceNotFound(f"object {name}")
        del objs[name]


def _creds() -> Credentials:
    return Credentials(
        auth_url="https://api.pub1.infomaniak.cloud/identity/v3",
        region_name="dc3-a",
        username="PCU-X",
        password="pw",
        project_name="PCP-X",
        user_domain_name="default",
        project_domain_name="default",
    )


def _provider() -> tuple[InfomaniakProvider, _FakeConn]:
    conn = _FakeConn()
    return InfomaniakProvider(_creds(), connection=conn, boot_timeout=1), conn


# --------------------------------------------------------------------------
# wiring + structural conformance
# --------------------------------------------------------------------------
def test_infomaniak_is_a_cloudprovider() -> None:
    provider, _ = _provider()
    assert provider.name == "infomaniak"
    assert "infomaniak" in available_providers()
    assert isinstance(provider, CloudProvider)  # structural Protocol match


def test_factory_builds_infomaniak_when_extra_present() -> None:
    # openstacksdk is importable here, so the lazy factory import succeeds.
    provider = get_provider("infomaniak", _creds())
    assert isinstance(provider, InfomaniakProvider)


def test_incomplete_credentials_rejected_at_construction() -> None:
    from mgf.cloud.exceptions import CredentialsError

    with pytest.raises(CredentialsError):
        InfomaniakProvider(Credentials(auth_url="https://x/identity/v3"))


# --------------------------------------------------------------------------
# compute lifecycle
# --------------------------------------------------------------------------
def test_create_server_builds_correct_attrs_and_maps_result() -> None:
    provider, conn = _provider()
    spec = ServerSpec(
        name="woodpecker",
        flavor="a2-ram2-disk20",
        image="Ubuntu 24.04 LTS",
        network="ext-net1",
        key_name="k1",
        security_groups=("ci",),
        user_data="#cloud-config\n",
        assign_floating_ip=True,
        metadata=owned_metadata("federation"),
    )
    server = provider.create_server(spec)

    attrs = conn.created_server_attrs
    assert attrs["flavor_id"] == "fl-1"  # resolved by name
    assert attrs["image_id"] == "img-1"  # boots from image (no volume)
    assert attrs["networks"] == [{"uuid": "net-1"}]
    assert attrs["security_groups"] == [{"name": "ci"}]
    # user_data is base64-encoded for Nova
    assert base64.b64decode(attrs["user_data"]).decode() == "#cloud-config\n"

    assert server.status == "ACTIVE"
    assert server.flavor == "a2-ram2-disk20"
    assert server.metadata[OWNER_TAG] == "federation"
    assert server.private_ips and server.floating_ips  # fixed + the attached floating IP
    # regression: the FIP is allocated from the EXTERNAL pool (ext-floating1),
    # NOT the instance network ext-net1 (live 400 "not a valid external network").
    assert conn.last_fip_network == "net-fip"  # ext-floating1's id, not ext-net1's


def test_create_server_with_boot_volume_uses_persistent_bdm() -> None:
    provider, conn = _provider()
    provider.create_server(
        ServerSpec(name="vm", flavor="a2-ram2-disk20", image="Ubuntu 24.04 LTS", volume_size_gb=20)
    )
    bdm = conn.created_server_attrs["block_device_mapping_v2"][0]
    assert bdm["source_type"] == "image" and bdm["destination_type"] == "volume"
    assert bdm["volume_size"] == 20
    assert bdm["delete_on_termination"] is False  # DESIGN §6: keep the disk
    assert "image_id" not in conn.created_server_attrs  # booted from the volume, not directly


def test_create_server_duplicate_name_conflicts() -> None:
    provider, _ = _provider()
    spec = ServerSpec(name="vm", flavor="a2-ram2-disk20", image="Ubuntu 24.04 LTS")
    provider.create_server(spec)
    with pytest.raises(ResourceAlreadyExistsError):
        provider.create_server(spec)


def test_create_server_unknown_flavor_or_image_not_found() -> None:
    provider, _ = _provider()
    with pytest.raises(ResourceNotFoundError):
        provider.create_server(ServerSpec(name="v", flavor="nope", image="Ubuntu 24.04 LTS"))
    with pytest.raises(ResourceNotFoundError):
        provider.create_server(ServerSpec(name="v", flavor="a2-ram2-disk20", image="nope"))


def test_server_get_list_filter_reboot_resize_snapshot_delete() -> None:
    provider, _ = _provider()
    provider.create_server(
        ServerSpec(
            name="vm",
            flavor="a2-ram2-disk20",
            image="Ubuntu 24.04 LTS",
            metadata=owned_metadata("federation"),
        )
    )
    assert provider.get_server("vm").name == "vm"
    with pytest.raises(ResourceNotFoundError):
        provider.get_server("ghost")

    assert provider.list_servers(owner="federation")
    assert provider.list_servers(owner="other") == []

    provider.reboot_server("vm", hard=True)
    provider.resize_server("vm", "a2-ram4-disk40")
    assert provider.get_server("vm").flavor == "a2-ram4-disk40"
    image = provider.snapshot_server("vm", "vm-snap")
    assert image.name == "vm-snap"

    provider.delete_server("vm")
    assert provider.list_servers() == []
    with pytest.raises(ResourceNotFoundError):
        provider.delete_server("vm")


def test_flavors_and_images_map() -> None:
    provider, _ = _provider()
    flavor = next(f for f in provider.list_flavors() if f.name == "a2-ram2-disk20")
    assert (flavor.vcpus, flavor.ram_mb, flavor.disk_gb) == (1, 2048, 20)
    assert any(i.name == "Ubuntu 24.04 LTS" for i in provider.list_images())


# --------------------------------------------------------------------------
# keypairs / volumes / floating IPs / security groups / objects
# --------------------------------------------------------------------------
def test_keypairs() -> None:
    provider, _ = _provider()
    kp = provider.create_keypair("k1", "ssh-ed25519 AAAA")
    assert kp.name == "k1" and kp.public_key == "ssh-ed25519 AAAA"
    assert provider.list_keypairs()
    provider.delete_keypair("k1")
    with pytest.raises(ResourceNotFoundError):
        provider.delete_keypair("k1")


def test_volume_lifecycle() -> None:
    provider, _ = _provider()
    provider.create_server(ServerSpec(name="vm", flavor="a2-ram2-disk20", image="Ubuntu 24.04 LTS"))
    vol = provider.create_volume(VolumeSpec(name="data", size_gb=50))
    assert vol.size_gb == 50 and vol.status == "available"
    provider.attach_volume("data", "vm")
    assert provider.get_volume("data").attached_server_id is not None
    provider.detach_volume("data", "vm")
    assert provider.get_volume("data").attached_server_id is None
    snap = provider.snapshot_volume("data", "data-snap")
    assert snap.size_gb == 50 and snap.volume_id == vol.id
    provider.delete_volume("data")
    with pytest.raises(ResourceNotFoundError):
        provider.get_volume("data")


def test_floating_ip_lifecycle() -> None:
    provider, _ = _provider()
    provider.create_server(ServerSpec(name="vm", flavor="a2-ram2-disk20", image="Ubuntu 24.04 LTS"))
    fip = provider.create_floating_ip("ext-net1")
    assert fip.address.startswith("203.0.113.")
    provider.attach_floating_ip(fip.address, "vm")
    assert any(fip.address in s.floating_ips for s in [provider.get_server("vm")])
    provider.detach_floating_ip(fip.address)
    assert provider.list_floating_ips()  # still allocated
    provider.release_floating_ip(fip.address)
    assert provider.list_floating_ips() == []
    with pytest.raises(ResourceNotFoundError):
        provider.attach_floating_ip("203.0.113.250", "vm")


def test_create_floating_ip_unknown_network() -> None:
    provider, _ = _provider()
    with pytest.raises(ResourceNotFoundError):
        provider.create_floating_ip("no-such-net")


def test_create_server_releases_floating_ip_if_attach_fails() -> None:
    """If the FIP attach fails after allocation, the IP must not leak."""
    provider, conn = _provider()

    def no_ports(device_id: str | None = None, **_: Any) -> list[Any]:
        return []  # -> _server_port_id raises -> attach fails

    conn.network.ports = no_ports
    with pytest.raises(ProviderError):
        provider.create_server(
            ServerSpec(
                name="vm",
                flavor="a2-ram2-disk20",
                image="Ubuntu 24.04 LTS",
                assign_floating_ip=True,
            )
        )
    assert conn.fips == {}  # the allocated floating IP was released, not leaked


def test_security_groups_with_rules() -> None:
    provider, _ = _provider()
    rule = SecurityRule(protocol="tcp", port_min=22, port_max=22)
    group = provider.create_security_group("ci", "ci hosts", (rule,))
    assert group.name == "ci"
    assert group.rules and group.rules[0].port_min == 22
    with pytest.raises(ResourceAlreadyExistsError):
        provider.create_security_group("ci")
    assert provider.list_security_groups()
    provider.delete_security_group("ci")
    with pytest.raises(ResourceNotFoundError):
        provider.delete_security_group("ci")


def test_object_storage() -> None:
    provider, _ = _provider()
    provider.create_container("backups")
    info = provider.put_object("backups", "a.txt", b"hello", content_type="text/plain")
    assert info.bytes == 5 and info.content_type == "text/plain"
    assert provider.get_object("backups", "a.txt") == b"hello"
    assert [o.name for o in provider.list_objects("backups")] == ["a.txt"]
    assert {c.name: c.object_count for c in provider.list_containers()}["backups"] == 1
    provider.delete_object("backups", "a.txt")
    with pytest.raises(ResourceNotFoundError):
        provider.get_object("backups", "a.txt")
    provider.delete_container("backups")
    with pytest.raises(ResourceNotFoundError):
        provider.list_objects("backups")


# --------------------------------------------------------------------------
# EH-02: every openstacksdk exception is translated at the boundary
# --------------------------------------------------------------------------
def test_sdk_exception_becomes_provider_error() -> None:
    provider, conn = _provider()

    def boom(**_: Any) -> Any:
        raise os_exc.SDKException("upstream is on fire")

    conn.compute.servers = boom
    with pytest.raises(ProviderError) as ei:
        provider.list_servers()
    assert "list_servers" in str(ei.value)


def test_sdk_not_found_becomes_resource_not_found() -> None:
    provider, conn = _provider()

    def missing(*_: Any, **__: Any) -> Any:
        raise os_exc.ResourceNotFound("gone")

    conn.compute.flavors = missing
    with pytest.raises(ResourceNotFoundError):
        provider.list_flavors()


def test_sdk_conflict_becomes_already_exists() -> None:
    provider, conn = _provider()

    def conflict(**_: Any) -> Any:
        raise os_exc.ConflictException("dup")

    conn.object_store.create_container = conflict
    with pytest.raises(ResourceAlreadyExistsError):
        provider.create_container("dup")


def test_keystone_unauthorized_becomes_credentials_error() -> None:
    """A Keystone 401 (a keystoneauth1 exception, NOT openstack.exceptions) is
    translated to CredentialsError so a bad clouds.yaml gives an actionable error."""
    from mgf.cloud.exceptions import CredentialsError

    provider, conn = _provider()

    def unauthorized(**_: Any) -> Any:
        raise ka_exc.http.Unauthorized("401")

    conn.compute.servers = unauthorized
    with pytest.raises(CredentialsError) as ei:
        provider.list_servers()
    assert "authentication failed" in str(ei.value)


def test_keystone_client_exception_becomes_provider_error() -> None:
    provider, conn = _provider()

    def transport_error(**_: Any) -> Any:
        raise ka_exc.ClientException("connection reset")

    conn.compute.servers = transport_error
    with pytest.raises(ProviderError):
        provider.list_servers()


def test_no_raw_openstack_exception_escapes() -> None:
    """A non-SDK error inside a call is *not* swallowed (only os_exc is translated)."""
    provider, conn = _provider()

    def kaboom(**_: Any) -> Any:
        raise ValueError("a bug, not an SDK error")

    conn.compute.servers = kaboom
    with pytest.raises(ValueError):
        provider.list_servers()


def test_create_volume_idempotent_guard() -> None:
    """EH-12: a same-name retry raises instead of creating a duplicate volume."""
    provider, _ = _provider()
    provider.create_volume(VolumeSpec(name="data", size_gb=50))
    with pytest.raises(ResourceAlreadyExistsError):
        provider.create_volume(VolumeSpec(name="data", size_gb=50))
    assert len(provider.list_volumes()) == 1


def test_build_connection_sets_finite_api_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
    """SC-07: every REST read is bounded by a finite api_timeout."""
    captured: dict[str, Any] = {}

    def fake_connection(**kwargs: Any) -> object:
        captured.update(kwargs)
        return object()

    monkeypatch.setattr("openstack.connection.Connection", fake_connection)
    provider = InfomaniakProvider(_creds(), api_timeout=17)
    _ = provider.conn  # triggers _build_connection
    assert captured["api_timeout"] == 17


def test_build_connection_emits_auth_ok_audit(
    monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
    """SC-17: building a credential-bearing session emits an audit=true auth.ok
    record carrying the auth_url but never the secret."""
    monkeypatch.setattr("openstack.connection.Connection", lambda **_: object())
    provider = InfomaniakProvider(_creds())
    with caplog.at_level("INFO", logger="mgf.cloud.providers.infomaniak"):
        _ = provider.conn
    rec = next(r for r in caplog.records if getattr(r, "operation", None) == "auth.ok")
    assert rec.audit is True
    assert rec.auth_url == "https://api.pub1.infomaniak.cloud/identity/v3"
    assert "pw" not in caplog.text


def test_auth_fail_emits_audit_record(caplog: pytest.LogCaptureFixture) -> None:
    """SC-17: a Keystone 401 emits an audit=true auth.fail record."""
    from mgf.cloud.exceptions import CredentialsError

    provider, conn = _provider()

    def unauthorized(**_: Any) -> Any:
        raise ka_exc.http.Unauthorized("401")

    conn.compute.servers = unauthorized
    with (
        caplog.at_level("INFO", logger="mgf.cloud.providers.infomaniak"),
        pytest.raises(CredentialsError),
    ):
        provider.list_servers()
    rec = next(r for r in caplog.records if getattr(r, "operation", None) == "auth.fail")
    assert rec.audit is True
