"""ProviderService coverage — list/get/create/update/delete/decrypt.

Drives a real `db_session` via the project's Postgres-backed conftest.
Provider rows are encrypted at rest with Fernet; tests round-trip
plaintext → ciphertext → plaintext through `get_decrypted_token` and
exercise the tri-state semantics of ``ProviderUpdate.auth_token``.
"""

from __future__ import annotations

import uuid
from typing import TYPE_CHECKING, cast
from uuid import uuid4

import pytest
import pytest_asyncio
from roboco.db.tables import ModelAssignmentTable
from roboco.models.base import ModelProvider
from roboco.services import provider as provider_module
from roboco.services.base import ConflictError, NotFoundError
from roboco.services.provider import (
    ProviderCreate,
    ProviderService,
    ProviderUpdate,
    get_provider_service,
)
from roboco.utils.crypto import EncryptionError

if TYPE_CHECKING:
    from collections.abc import AsyncIterator

    from sqlalchemy.ext.asyncio import AsyncSession


@pytest_asyncio.fixture
async def provider_svc(db_session: AsyncSession) -> AsyncIterator[ProviderService]:
    yield ProviderService(db_session)


@pytest.mark.asyncio
async def test_create_provider_with_token_encrypts(
    provider_svc: ProviderService,
) -> None:
    row = await provider_svc.create_provider(
        ProviderCreate(
            name=f"anthropic-{uuid4().hex[:6]}",
            type=ModelProvider.ANTHROPIC,
            auth_token="sk-test-secret",
        )
    )
    assert row.auth_token_encrypted is not None
    assert row.auth_token_encrypted != "sk-test-secret"


@pytest.mark.asyncio
async def test_create_provider_without_token_leaves_null(
    provider_svc: ProviderService,
) -> None:
    row = await provider_svc.create_provider(
        ProviderCreate(name=f"p-{uuid4().hex[:6]}", type=ModelProvider.LOCAL)
    )
    assert row.auth_token_encrypted is None


@pytest.mark.asyncio
async def test_create_provider_duplicate_name_raises(
    provider_svc: ProviderService,
) -> None:
    name = f"dup-{uuid4().hex[:6]}"
    await provider_svc.create_provider(
        ProviderCreate(name=name, type=ModelProvider.ANTHROPIC)
    )
    with pytest.raises(ConflictError):
        await provider_svc.create_provider(
            ProviderCreate(name=name, type=ModelProvider.OPENAI)
        )


@pytest.mark.asyncio
async def test_get_provider_returns_row(provider_svc: ProviderService) -> None:
    row = await provider_svc.create_provider(
        ProviderCreate(name=f"g-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
    )
    fetched = await provider_svc.get_provider(cast("uuid.UUID", row.id))
    assert fetched is not None
    assert fetched.id == row.id


@pytest.mark.asyncio
async def test_get_provider_returns_none_when_missing(
    provider_svc: ProviderService,
) -> None:
    assert await provider_svc.get_provider(uuid4()) is None


@pytest.mark.asyncio
async def test_get_provider_or_raise_raises(provider_svc: ProviderService) -> None:
    with pytest.raises(NotFoundError):
        await provider_svc.get_provider_or_raise(uuid4())


@pytest.mark.asyncio
async def test_get_by_name(provider_svc: ProviderService) -> None:
    name = f"by-name-{uuid4().hex[:6]}"
    row = await provider_svc.create_provider(
        ProviderCreate(name=name, type=ModelProvider.ANTHROPIC)
    )
    found = await provider_svc.get_by_name(name)
    assert found is not None
    assert found.id == row.id


@pytest.mark.asyncio
async def test_list_providers_excludes_disabled_by_default(
    provider_svc: ProviderService,
) -> None:
    enabled = await provider_svc.create_provider(
        ProviderCreate(
            name=f"on-{uuid4().hex[:6]}", type=ModelProvider.ANTHROPIC, enabled=True
        )
    )
    disabled = await provider_svc.create_provider(
        ProviderCreate(
            name=f"off-{uuid4().hex[:6]}", type=ModelProvider.LOCAL, enabled=False
        )
    )
    visible = await provider_svc.list_providers()
    visible_ids = {p.id for p in visible}
    assert enabled.id in visible_ids
    assert disabled.id not in visible_ids


@pytest.mark.asyncio
async def test_list_providers_include_disabled(provider_svc: ProviderService) -> None:
    disabled = await provider_svc.create_provider(
        ProviderCreate(
            name=f"x-{uuid4().hex[:6]}", type=ModelProvider.LOCAL, enabled=False
        )
    )
    every = await provider_svc.list_providers(include_disabled=True)
    assert disabled.id in {p.id for p in every}


@pytest.mark.asyncio
async def test_update_provider_changes_name(provider_svc: ProviderService) -> None:
    row = await provider_svc.create_provider(
        ProviderCreate(name=f"old-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
    )
    new_name = f"new-{uuid4().hex[:6]}"
    updated = await provider_svc.update_provider(
        cast("uuid.UUID", row.id), ProviderUpdate(name=new_name)
    )
    assert updated is not None
    assert updated.name == new_name


@pytest.mark.asyncio
async def test_update_provider_duplicate_name_raises(
    provider_svc: ProviderService,
) -> None:
    a = await provider_svc.create_provider(
        ProviderCreate(name=f"a-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
    )
    b = await provider_svc.create_provider(
        ProviderCreate(name=f"b-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
    )
    with pytest.raises(ConflictError):
        await provider_svc.update_provider(
            cast("uuid.UUID", b.id), ProviderUpdate(name=a.name)
        )


@pytest.mark.asyncio
async def test_update_provider_clears_base_url_with_empty_string(
    provider_svc: ProviderService,
) -> None:
    row = await provider_svc.create_provider(
        ProviderCreate(
            name=f"url-{uuid4().hex[:6]}",
            type=ModelProvider.OLLAMA_CLOUD,
            base_url="https://example.com",
        )
    )
    updated = await provider_svc.update_provider(
        cast("uuid.UUID", row.id), ProviderUpdate(base_url="")
    )
    assert updated is not None
    assert updated.base_url is None


@pytest.mark.asyncio
async def test_update_provider_token_tristate_clear(
    provider_svc: ProviderService,
) -> None:
    row = await provider_svc.create_provider(
        ProviderCreate(
            name=f"t-{uuid4().hex[:6]}",
            type=ModelProvider.OLLAMA_CLOUD,
            auth_token="initial",
        )
    )
    updated = await provider_svc.update_provider(
        cast("uuid.UUID", row.id), ProviderUpdate(clear_auth_token=True)
    )
    assert updated is not None
    assert updated.auth_token_encrypted is None


@pytest.mark.asyncio
async def test_update_provider_token_tristate_set(
    provider_svc: ProviderService,
) -> None:
    row = await provider_svc.create_provider(
        ProviderCreate(name=f"s-{uuid4().hex[:6]}", type=ModelProvider.ANTHROPIC)
    )
    updated = await provider_svc.update_provider(
        cast("uuid.UUID", row.id), ProviderUpdate(auth_token="new-secret")
    )
    assert updated is not None
    assert updated.auth_token_encrypted is not None


@pytest.mark.asyncio
async def test_update_provider_token_tristate_unchanged(
    provider_svc: ProviderService,
) -> None:
    row = await provider_svc.create_provider(
        ProviderCreate(
            name=f"u-{uuid4().hex[:6]}",
            type=ModelProvider.ANTHROPIC,
            auth_token="initial",
        )
    )
    original_token = row.auth_token_encrypted
    updated = await provider_svc.update_provider(
        cast("uuid.UUID", row.id),
        ProviderUpdate(enabled=False),  # no auth_token field
    )
    assert updated is not None
    assert updated.auth_token_encrypted == original_token  # unchanged
    assert updated.enabled is False


@pytest.mark.asyncio
async def test_update_provider_returns_none_for_missing(
    provider_svc: ProviderService,
) -> None:
    assert (
        await provider_svc.update_provider(uuid4(), ProviderUpdate(enabled=False))
        is None
    )


@pytest.mark.asyncio
async def test_delete_provider(provider_svc: ProviderService) -> None:
    row = await provider_svc.create_provider(
        ProviderCreate(name=f"d-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
    )
    await provider_svc.delete_provider(cast("uuid.UUID", row.id))
    assert await provider_svc.get_provider(cast("uuid.UUID", row.id)) is None


@pytest.mark.asyncio
async def test_delete_provider_raises_when_referenced(
    db_session: AsyncSession, provider_svc: ProviderService
) -> None:
    row = await provider_svc.create_provider(
        ProviderCreate(name=f"r-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
    )
    # Insert a model assignment that references this provider.
    assignment = ModelAssignmentTable(
        id=uuid4(),
        scope="role",
        scope_value="developer",
        provider_config_id=row.id,
        model_name="claude-haiku-4-5",
    )
    db_session.add(assignment)
    await db_session.flush()

    with pytest.raises(ConflictError):
        await provider_svc.delete_provider(cast("uuid.UUID", row.id))


@pytest.mark.asyncio
async def test_get_decrypted_token_round_trip(provider_svc: ProviderService) -> None:
    plaintext = "sk-roundtrip-secret"
    row = await provider_svc.create_provider(
        ProviderCreate(
            name=f"rt-{uuid4().hex[:6]}",
            type=ModelProvider.ANTHROPIC,
            auth_token=plaintext,
        )
    )
    decrypted = await provider_svc.get_decrypted_token(cast("uuid.UUID", row.id))
    assert decrypted == plaintext


@pytest.mark.asyncio
async def test_get_decrypted_token_returns_none_when_unset(
    provider_svc: ProviderService,
) -> None:
    row = await provider_svc.create_provider(
        ProviderCreate(name=f"nt-{uuid4().hex[:6]}", type=ModelProvider.LOCAL)
    )
    assert await provider_svc.get_decrypted_token(cast("uuid.UUID", row.id)) is None


@pytest.mark.asyncio
async def test_get_decrypted_token_returns_none_for_missing_provider(
    provider_svc: ProviderService,
) -> None:
    assert await provider_svc.get_decrypted_token(uuid4()) is None


# ---------------------------------------------------------------------------
# Encryption error paths (lines 115-117, 162-164)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_create_provider_encrypt_failure_propagates(
    provider_svc: ProviderService,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """If encrypt_token fails, error is logged + raised."""

    def _boom(_token: str) -> str:
        raise EncryptionError("bad key")

    monkeypatch.setattr(provider_module, "encrypt_token", _boom)
    with pytest.raises(EncryptionError):
        await provider_svc.create_provider(
            ProviderCreate(
                name=f"e-{uuid4().hex[:6]}",
                type=ModelProvider.ANTHROPIC,
                auth_token="some-token",
            )
        )


@pytest.mark.asyncio
async def test_update_provider_encrypt_failure_propagates(
    provider_svc: ProviderService,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """If encrypt_token fails on update, error propagates."""

    row = await provider_svc.create_provider(
        ProviderCreate(name=f"eu-{uuid4().hex[:6]}", type=ModelProvider.ANTHROPIC)
    )

    def _boom(_token: str) -> str:
        raise EncryptionError("bad key")

    monkeypatch.setattr(provider_module, "encrypt_token", _boom)
    with pytest.raises(EncryptionError):
        await provider_svc.update_provider(
            cast("uuid.UUID", row.id), ProviderUpdate(auth_token="new-secret")
        )


# ---------------------------------------------------------------------------
# Decryption error path (lines 218-224)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_get_decrypted_token_decrypt_failure_propagates(
    provider_svc: ProviderService,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """If decrypt_token raises, error propagates."""

    row = await provider_svc.create_provider(
        ProviderCreate(
            name=f"dec-{uuid4().hex[:6]}",
            type=ModelProvider.ANTHROPIC,
            auth_token="initial",
        )
    )

    def _boom(_blob: str) -> str:
        raise EncryptionError("decrypt failed")

    monkeypatch.setattr(provider_module, "decrypt_token", _boom)
    with pytest.raises(EncryptionError):
        await provider_svc.get_decrypted_token(cast("uuid.UUID", row.id))


# ---------------------------------------------------------------------------
# _apply_name_change identity branch (line 141)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_update_provider_same_name_is_noop(
    provider_svc: ProviderService,
) -> None:
    """Updating to the same name short-circuits in _apply_name_change (line 141)."""
    name = f"same-{uuid4().hex[:6]}"
    row = await provider_svc.create_provider(
        ProviderCreate(name=name, type=ModelProvider.ANTHROPIC)
    )
    updated = await provider_svc.update_provider(
        cast("uuid.UUID", row.id), ProviderUpdate(name=name)
    )
    assert updated is not None
    assert updated.name == name


# ---------------------------------------------------------------------------
# Factory (line 229)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_get_provider_service_factory(db_session: AsyncSession) -> None:
    """Factory returns a configured ProviderService."""

    svc = get_provider_service(db_session)
    assert isinstance(svc, ProviderService)
