"""Cloud-credential migration across the auth on/off boundary (#2530).

``get_stored_token`` reads global ``Settings`` rows when auth is disabled and
``User.cloud_token`` when it's enabled. Toggling auth therefore switches which
store the ``/cloud/*`` routes consult. Without an explicit hand-off the token
is stranded in the store nobody reads: ``build_authenticated_cloud`` returns
``None``, Phase 2 of ``get_filament_info`` is skipped entirely, and the caller
sees a ``200`` full of local-preset fallbacks with no sign the cloud was never
contacted. That silent degradation is what #2530 actually reported.

These tests pin the hand-off in both directions, and — just as importantly —
pin the two cases where Bambuddy must refuse to guess who owns a credential.
"""

import logging

import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from backend.app.api.routes import cloud as cloud_routes
from backend.app.api.routes.cloud import (
    CLOUD_EMAIL_KEY,
    CLOUD_REGION_KEY,
    CLOUD_TOKEN_KEY,
    get_stored_token,
)
from backend.app.core.auth import get_password_hash
from backend.app.models.settings import Settings
from backend.app.models.user import User
from backend.app.services.bambu_cloud import BambuCloudError


async def _seed_global_token(db: AsyncSession, token: str = "tok-global", region: str = "china") -> None:
    db.add(Settings(key=CLOUD_TOKEN_KEY, value=token))
    db.add(Settings(key=CLOUD_EMAIL_KEY, value="owner@example.com"))
    db.add(Settings(key=CLOUD_REGION_KEY, value=region))
    await db.commit()


async def _global_rows(db: AsyncSession) -> dict[str, str]:
    rows = (
        (
            await db.execute(
                select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
            )
        )
        .scalars()
        .all()
    )
    return {r.key: r.value for r in rows}


async def _make_admin(db: AsyncSession, username: str) -> User:
    user = User(
        username=username,
        password_hash=get_password_hash("AdminPass1!"),
        role="admin",
        is_active=True,
    )
    db.add(user)
    await db.commit()
    await db.refresh(user)
    return user


# ---------------------------------------------------------------------------
# auth OFF -> ON
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_setup_migrates_global_token_to_created_admin(async_client: AsyncClient, db_session: AsyncSession):
    """The reporter's exact path: link cloud with auth off, then enable auth."""
    await _seed_global_token(db_session)

    resp = await async_client.post(
        "/api/v1/auth/setup",
        json={"auth_enabled": True, "admin_username": "admin", "admin_password": "AdminPass1!"},
    )
    assert resp.status_code == 200, resp.text

    admin = (await db_session.execute(select(User).where(User.role == "admin"))).scalar_one()
    token, email, region = await get_stored_token(db_session, admin)
    assert token == "tok-global"
    assert email == "owner@example.com"
    assert region == "china", "region must survive the hop, not silently reset to global"

    # Credential must not be left at rest in a table nothing reads any more.
    assert await _global_rows(db_session) == {}


@pytest.mark.asyncio
async def test_setup_migrates_to_sole_pre_existing_admin(async_client: AsyncClient, db_session: AsyncSession):
    """Re-enabling auth when exactly one admin already exists has one obvious owner."""
    admin = await _make_admin(db_session, "solo")
    await _seed_global_token(db_session, token="tok-solo")

    resp = await async_client.post("/api/v1/auth/setup", json={"auth_enabled": True})
    assert resp.status_code == 200, resp.text
    assert resp.json()["admin_created"] is False

    await db_session.refresh(admin)
    token, _, _ = await get_stored_token(db_session, admin)
    assert token == "tok-solo"
    assert await _global_rows(db_session) == {}


@pytest.mark.asyncio
async def test_setup_refuses_to_guess_owner_when_multiple_admins(async_client: AsyncClient, db_session: AsyncSession):
    """Two admins, one credential: handing it to either is a security decision we don't make."""
    a = await _make_admin(db_session, "admin_a")
    b = await _make_admin(db_session, "admin_b")
    await _seed_global_token(db_session, token="tok-ambiguous")

    resp = await async_client.post("/api/v1/auth/setup", json={"auth_enabled": True})
    assert resp.status_code == 200, resp.text

    await db_session.refresh(a)
    await db_session.refresh(b)
    assert a.cloud_token is None
    assert b.cloud_token is None
    # Left intact so the operator can re-link rather than lose it.
    assert (await _global_rows(db_session))[CLOUD_TOKEN_KEY] == "tok-ambiguous"


@pytest.mark.asyncio
async def test_setup_with_auth_disabled_leaves_global_token_untouched(
    async_client: AsyncClient, db_session: AsyncSession
):
    """Completing setup while declining auth must not move anything."""
    await _seed_global_token(db_session, token="tok-stay")

    resp = await async_client.post("/api/v1/auth/setup", json={"auth_enabled": False})
    assert resp.status_code == 200, resp.text

    assert (await _global_rows(db_session))[CLOUD_TOKEN_KEY] == "tok-stay"


# ---------------------------------------------------------------------------
# auth ON -> OFF
# ---------------------------------------------------------------------------


async def _admin_bearer(async_client: AsyncClient, username: str = "admin") -> str:
    await async_client.post(
        "/api/v1/auth/setup",
        json={"auth_enabled": True, "admin_username": username, "admin_password": "AdminPass1!"},
    )
    login = await async_client.post(
        "/api/v1/auth/login",
        json={"username": username, "password": "AdminPass1!"},
    )
    return login.json()["access_token"]


@pytest.mark.asyncio
async def test_disable_auth_migrates_admin_token_to_global(async_client: AsyncClient, db_session: AsyncSession):
    bearer = await _admin_bearer(async_client)
    admin = (await db_session.execute(select(User).where(User.role == "admin"))).scalar_one()
    admin.cloud_token = "tok-user"
    admin.cloud_email = "user@example.com"
    admin.cloud_region = "china"
    await db_session.commit()

    resp = await async_client.post("/api/v1/auth/disable", headers={"Authorization": f"Bearer {bearer}"})
    assert resp.status_code == 200, resp.text

    rows = await _global_rows(db_session)
    assert rows[CLOUD_TOKEN_KEY] == "tok-user"
    assert rows[CLOUD_REGION_KEY] == "china"

    await db_session.refresh(admin)
    assert admin.cloud_token is None, "credential must not be duplicated across both stores"

    # And the no-auth read path now finds it.
    token, _, _ = await get_stored_token(db_session, None)
    assert token == "tok-user"


@pytest.mark.asyncio
async def test_disable_auth_does_not_clobber_existing_global_token(async_client: AsyncClient, db_session: AsyncSession):
    """A stale global row is still somebody's credential — refuse rather than overwrite."""
    bearer = await _admin_bearer(async_client)
    admin = (await db_session.execute(select(User).where(User.role == "admin"))).scalar_one()
    admin.cloud_token = "tok-user"
    await db_session.commit()
    await _seed_global_token(db_session, token="tok-preexisting")

    resp = await async_client.post("/api/v1/auth/disable", headers={"Authorization": f"Bearer {bearer}"})
    assert resp.status_code == 200, resp.text

    assert (await _global_rows(db_session))[CLOUD_TOKEN_KEY] == "tok-preexisting"
    await db_session.refresh(admin)
    assert admin.cloud_token == "tok-user", "admin keeps their token when we decline to migrate"


@pytest.mark.asyncio
async def test_disable_auth_with_no_cloud_token_is_a_noop(async_client: AsyncClient, db_session: AsyncSession):
    bearer = await _admin_bearer(async_client)

    resp = await async_client.post("/api/v1/auth/disable", headers={"Authorization": f"Bearer {bearer}"})
    assert resp.status_code == 200, resp.text
    assert await _global_rows(db_session) == {}


# ---------------------------------------------------------------------------
# Log-level classification for cloud preset misses (#2530)
# ---------------------------------------------------------------------------


def test_bambu_cloud_error_carries_status_code():
    """The 400-vs-fault distinction depends on this attribute existing."""
    from backend.app.services.bambu_cloud import BambuCloudError

    assert BambuCloudError("boom").status_code is None
    assert BambuCloudError("boom", status_code=400).status_code == 400
    # Every pre-existing raise site passes a bare message; must not break.
    assert str(BambuCloudError("Request failed: timeout")) == "Request failed: timeout"


class _StubCloud:
    """Minimal stand-in for BambuCloudService that fails every preset lookup."""

    def __init__(self, error: Exception):
        self._error = error
        self.is_authenticated = True

    async def get_setting_detail(self, setting_id: str) -> dict:
        raise self._error

    async def close(self) -> None:
        return None


@pytest.mark.asyncio
@pytest.mark.parametrize(
    ("error", "expected_level"),
    [
        (BambuCloudError("missing", status_code=400), logging.DEBUG),
        (BambuCloudError("unauthorized", status_code=401), logging.WARNING),
        (BambuCloudError("bad gateway", status_code=502), logging.WARNING),
        (BambuCloudError("Request failed: timeout"), logging.WARNING),
    ],
    ids=["expected-400-miss", "expired-token", "cloud-outage", "transport-failure"],
)
async def test_preset_miss_logs_at_debug_but_faults_stay_at_warning(
    monkeypatch, caplog, db_session: AsyncSession, error: Exception, expected_level: int
):
    """A preset the catalog doesn't carry is routine; an expired token is a fault.

    Drives the real ``get_filament_info`` route so the classification is exercised
    where it lives, not re-derived in the test.
    """
    monkeypatch.setattr(cloud_routes, "build_authenticated_cloud", _stub_builder(error))
    # Phase 1 would otherwise short-circuit the cloud call on a warm cache.
    monkeypatch.setattr(cloud_routes, "_filament_cache", {})

    with caplog.at_level(logging.DEBUG, logger="backend.app.api.routes.cloud"):
        result = await cloud_routes.get_filament_info(setting_ids=["GFL05"], db=db_session, current_user=None)

    records = [r for r in caplog.records if "Failed to get cloud preset" in r.getMessage()]
    assert len(records) == 1, "the miss must be logged exactly once"
    assert records[0].levelno == expected_level
    assert "GFL05" in records[0].getMessage()
    assert "GFSL05" in records[0].getMessage(), "the translated API ID stays in the message"

    # Whatever the level, the endpoint still answers and falls through to Phase 3.
    assert isinstance(result, dict)


def _stub_builder(error: Exception):
    async def _build(db, user):
        return _StubCloud(error)

    return _build
