"""Tests for routers/magic_link.py — magic-link auth (generate / redeem / list / revoke).

Covers:
  * Auth enforcement on admin endpoints
  * Generate happy path (returns plaintext token + URL)
  * Generate validation (bad username, expiry bounds, scoped redirect targets)
  * Redeem happy path (single-use, sets session cookie, 302 to chat)
  * Redeem failure modes (invalid, expired, already-redeemed, revoked) → all
    return the same opaque 404
  * Reusable-token semantics (can redeem twice, still tracked in audit)
  * Rate-limit on repeated failures
  * List + revoke flows
"""

import importlib
from datetime import datetime, timedelta, timezone

import pytest


# ---------------------------------------------------------------------------
# Fixtures — isolate per-test storage + rate-limit state
# ---------------------------------------------------------------------------


@pytest.fixture()
def magic_link_module(tmp_path, monkeypatch):
    """Reload routers.magic_link with an isolated DATA_DIR and clean state."""
    monkeypatch.setenv("ODS_DATA_DIR", str(tmp_path))
    monkeypatch.setenv("ODS_SESSION_SECRET", "test-secret-for-magic-link-tests")
    monkeypatch.delenv("ODS_PUBLIC_URL", raising=False)
    monkeypatch.delenv("WEBUI_URL", raising=False)
    monkeypatch.delenv("ODS_TRUST_FORWARDED", raising=False)
    monkeypatch.delenv("ODS_COOKIE_DOMAIN", raising=False)

    # session_signer reads ODS_SESSION_SECRET at import time, so any
    # already-loaded copy keeps the old value. Force-set it for the test.
    import session_signer
    session_signer._set_secret_for_tests("test-secret-for-magic-link-tests")

    # Reimport so module-level constants pick up the new DATA_DIR.
    from routers import magic_link as ml
    importlib.reload(ml)

    # Reset in-memory rate-limit table between tests.
    ml._RATE_LIMIT_BUCKETS.clear()
    monkeypatch.setattr(ml, "_ods_proxy_lan_ready", lambda: (True, ""))

    # The main app already imported the router at module load — re-include the
    # reloaded one so the TestClient routes to fresh module state.
    from main import app
    # FastAPI's APIRouter is by-reference; reloading the module replaces
    # ml.router with a new instance. Re-mount it.
    app.include_router(ml.router)
    return ml


@pytest.fixture()
def magic_link_client(test_client, magic_link_module):
    """TestClient wired to the freshly-reloaded magic_link router."""
    return test_client


# ---------------------------------------------------------------------------
# Auth enforcement
# ---------------------------------------------------------------------------


def test_generate_requires_auth(magic_link_client):
    resp = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "alice"},
    )
    assert resp.status_code == 401


def test_list_requires_auth(magic_link_client):
    resp = magic_link_client.get("/api/auth/magic-link/list")
    assert resp.status_code == 401


def test_revoke_requires_auth(magic_link_client):
    resp = magic_link_client.delete("/api/auth/magic-link/abcd1234")
    assert resp.status_code == 401


def test_qr_requires_auth(magic_link_client):
    resp = magic_link_client.get("/api/auth/magic-link/qr?url=http://example/")
    assert resp.status_code == 401


def test_owner_card_status_requires_auth(magic_link_client):
    resp = magic_link_client.get("/api/auth/magic-link/owner-card/status")
    assert resp.status_code == 401


def test_redeem_is_public(magic_link_client):
    """Redemption endpoint must be reachable without an API key (it's the
    whole point — the holder of the link is who's getting access)."""
    resp = magic_link_client.get(
        "/auth/magic-link/totally-bogus-token",
        follow_redirects=False,
    )
    # Bogus token → 404 (constant-shape failure), not 401.
    assert resp.status_code == 404


# ---------------------------------------------------------------------------
# Generate happy path
# ---------------------------------------------------------------------------


def test_generate_returns_token_and_url(magic_link_client):
    resp = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "alice", "scope": "chat"},
        headers=magic_link_client.auth_headers,
    )
    assert resp.status_code == 200, resp.text
    data = resp.json()
    assert data["target_username"] == "alice"
    assert data["scope"] == "chat"
    assert data["token_type"] == "guest"
    assert data["url_mode"] == "auto"
    assert data["reusable"] is False
    assert len(data["token"]) >= 32
    # New URL shape: http://auth.<device>.local/magic-link/<token>.
    # The /auth/ path prefix is gone — the auth subdomain implies it.
    assert data["url"].endswith(f"/magic-link/{data['token']}")


def test_generate_with_note_and_reusable(magic_link_client):
    resp = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={
            "target_username": "family",
            "scope": "chat",
            "reusable": True,
            "expires_in": 3600,
            "note": "household share poster",
        },
        headers=magic_link_client.auth_headers,
    )
    assert resp.status_code == 200, resp.text
    data = resp.json()
    assert data["reusable"] is True


def test_generate_respects_public_url_env(magic_link_client, monkeypatch, magic_link_module):
    """ODS_PUBLIC_URL override (for Tailscale tunnels / custom domains)
    keeps the /auth/ prefix because the URL builder assumes that override
    is being fronted by a proxy that routes /auth/* to dashboard-api
    (matching the old shape)."""
    monkeypatch.setenv("ODS_PUBLIC_URL", "http://ods.local:3002")
    resp = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "bob"},
        headers=magic_link_client.auth_headers,
    )
    assert resp.status_code == 200
    assert resp.json()["url"].startswith("http://ods.local:3002/auth/magic-link/")


def test_generate_url_defaults_to_auth_subdomain(magic_link_client):
    """With no ODS_PUBLIC_URL set, the URL builds off the auth subdomain
    (http://auth.<ODS_DEVICE_NAME>.local) — that's where ods-proxy
    routes dashboard-api in the host-based layout. The URL must NOT point
    at localhost:3000 (Open WebUI direct) or :3002 (dashboard-api direct)."""
    resp = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "alice"},
        headers=magic_link_client.auth_headers,
    )
    assert resp.status_code == 200
    url = resp.json()["url"]
    assert url.startswith("http://auth.ods.local/magic-link/"), url
    assert "localhost:3000" not in url
    assert ":3002" not in url


def test_generate_url_uses_configured_device_name(magic_link_client, monkeypatch, magic_link_module):
    """ODS_DEVICE_NAME feeds into the auth subdomain."""
    monkeypatch.setenv("ODS_DEVICE_NAME", "kitchen")
    resp = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "alice"},
        headers=magic_link_client.auth_headers,
    )
    assert resp.status_code == 200
    url = resp.json()["url"]
    assert url.startswith("http://auth.kitchen.local/magic-link/"), url


def test_generate_strips_trailing_slash_from_public_url(magic_link_client, monkeypatch, magic_link_module):
    """An operator who sets ODS_PUBLIC_URL=http://x/ must not produce a
    double-slash in the magic-link URL."""
    monkeypatch.setenv("ODS_PUBLIC_URL", "http://ods.local/")
    resp = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "carol"},
        headers=magic_link_client.auth_headers,
    )
    assert resp.status_code == 200
    url = resp.json()["url"]
    assert "//auth/" not in url, f"double-slash leak in {url}"
    assert url.startswith("http://ods.local/auth/magic-link/")


# ---------------------------------------------------------------------------
# Generate validation
# ---------------------------------------------------------------------------


def test_generate_rejects_empty_username(magic_link_client):
    resp = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": ""},
        headers=magic_link_client.auth_headers,
    )
    assert resp.status_code == 422


def test_generate_rejects_invalid_username_chars(magic_link_client):
    resp = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "alice; drop table users"},
        headers=magic_link_client.auth_headers,
    )
    assert resp.status_code == 422


@pytest.mark.parametrize("scope", ["root", "dashboard", "all"])
def test_generate_rejects_invalid_scope(magic_link_client, scope):
    resp = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "alice", "scope": scope},
        headers=magic_link_client.auth_headers,
    )
    assert resp.status_code == 422


def test_generate_accepts_hermes_scope(magic_link_client):
    resp = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "alice", "scope": "hermes"},
        headers=magic_link_client.auth_headers,
    )
    assert resp.status_code == 200, resp.text
    assert resp.json()["scope"] == "hermes"


def test_generate_rejects_short_expiry(magic_link_client):
    resp = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "alice", "expires_in": 10},
        headers=magic_link_client.auth_headers,
    )
    assert resp.status_code == 422


def test_generate_rejects_long_expiry(magic_link_client):
    resp = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "alice", "expires_in": 999_999},
        headers=magic_link_client.auth_headers,
    )
    assert resp.status_code == 422


def test_generate_owner_token_defaults_to_revoke_only_hermes_lan(magic_link_client, monkeypatch):
    monkeypatch.setenv("ODS_PUBLIC_URL", "https://ods.example")
    resp = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "owner", "token_type": "owner", "note": "factory card"},
        headers=magic_link_client.auth_headers,
    )
    assert resp.status_code == 200, resp.text
    data = resp.json()
    assert data["token_type"] == "owner"
    assert data["scope"] == "hermes"
    assert data["url_mode"] == "lan"
    assert data["reusable"] is True
    assert data["expires_at"] is None
    assert data["url"].startswith("http://auth.ods.local/magic-link/")


def test_generate_owner_lan_requires_ods_proxy(magic_link_client, magic_link_module, monkeypatch):
    monkeypatch.setattr(
        magic_link_module,
        "_ods_proxy_lan_ready",
        lambda: (False, "ODS Talk owner cards require ods-proxy"),
    )

    resp = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "owner", "token_type": "owner"},
        headers=magic_link_client.auth_headers,
    )

    assert resp.status_code == 409
    assert "ods-proxy" in resp.json()["detail"]


def test_generate_owner_rejects_expiry(magic_link_client):
    resp = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "owner", "token_type": "owner", "expires_in": 3600},
        headers=magic_link_client.auth_headers,
    )
    assert resp.status_code == 422


def test_owner_public_url_mode_uses_public_url_when_requested(magic_link_client, monkeypatch):
    monkeypatch.setenv("ODS_PUBLIC_URL", "https://ods.example")
    resp = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "owner", "token_type": "owner", "url_mode": "public"},
        headers=magic_link_client.auth_headers,
    )
    assert resp.status_code == 200, resp.text
    data = resp.json()
    assert data["url_mode"] == "public"
    assert data["url"].startswith("https://ods.example/auth/magic-link/")


def test_owner_public_url_mode_does_not_require_ods_proxy(
    magic_link_client, magic_link_module, monkeypatch,
):
    monkeypatch.setenv("ODS_PUBLIC_URL", "https://ods.example")
    monkeypatch.setattr(
        magic_link_module,
        "_ods_proxy_lan_ready",
        lambda: (False, "ODS Talk owner cards require ods-proxy"),
    )

    resp = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "owner", "token_type": "owner", "url_mode": "public"},
        headers=magic_link_client.auth_headers,
    )

    assert resp.status_code == 200, resp.text
    assert resp.json()["url"].startswith("https://ods.example/auth/magic-link/")


def test_owner_card_status_reports_proxy_state(
    magic_link_client, magic_link_module, monkeypatch,
):
    monkeypatch.setattr(
        magic_link_module,
        "_ods_proxy_lan_ready",
        lambda: (False, "ods-proxy is not enabled"),
    )

    resp = magic_link_client.get(
        "/api/auth/magic-link/owner-card/status",
        headers=magic_link_client.auth_headers,
    )

    assert resp.status_code == 200
    assert resp.json() == {
        "ready": False,
        "requires": "ods-proxy",
        "reason": "ods-proxy is not enabled",
    }


def test_ods_proxy_service_refreshes_stale_manifest_cache(magic_link_module, monkeypatch):
    """Owner-card readiness should see ods-proxy after CLI enable.

    The dashboard API imports SERVICES at startup. When `ods enable
    ods-proxy` renames compose.yaml.disabled to compose.yaml, the running
    process can have a stale SERVICES cache. The owner-card gate should refresh
    that one service instead of requiring a manual dashboard-api restart.
    """
    magic_link_module.SERVICES.clear()

    refreshed = {
        "ods-proxy": {
            "host": "ods-proxy",
            "port": 80,
            "health": "/health",
            "name": "ODS Proxy",
        }
    }

    def fake_load_extension_manifests(_extensions_dir, _gpu_backend):
        return refreshed, [], []

    monkeypatch.setattr(
        magic_link_module,
        "load_extension_manifests",
        fake_load_extension_manifests,
    )

    service = magic_link_module._ods_proxy_service()

    assert service == refreshed["ods-proxy"]
    assert magic_link_module.SERVICES["ods-proxy"] == refreshed["ods-proxy"]


def test_ods_proxy_service_keeps_disabled_state(magic_link_module, monkeypatch):
    magic_link_module.SERVICES.clear()

    def fake_load_extension_manifests(_extensions_dir, _gpu_backend):
        return {}, [], []

    monkeypatch.setattr(
        magic_link_module,
        "load_extension_manifests",
        fake_load_extension_manifests,
    )

    assert magic_link_module._ods_proxy_service() is None
    assert "ods-proxy" not in magic_link_module.SERVICES


def test_public_url_mode_requires_public_url(magic_link_client):
    resp = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "owner", "token_type": "owner", "url_mode": "public"},
        headers=magic_link_client.auth_headers,
    )
    assert resp.status_code == 400
    assert "ODS_PUBLIC_URL" in resp.json()["detail"]


# ---------------------------------------------------------------------------
# Redeem happy path
# ---------------------------------------------------------------------------


def test_redeem_sets_cookie_and_redirects(magic_link_client, magic_link_module):
    gen = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "alice", "scope": "chat"},
        headers=magic_link_client.auth_headers,
    )
    token = gen.json()["token"]

    resp = magic_link_client.get(
        f"/auth/magic-link/{token}",
        follow_redirects=False,
    )
    assert resp.status_code == 302
    # Session cookie must be HttpOnly.
    set_cookies = [h for h in resp.headers.raw if h[0].lower() == b"set-cookie"]
    cookie_blob = b" ".join(c[1] for c in set_cookies).lower()
    assert b"ods-session=" in cookie_blob
    assert b"httponly" in cookie_blob
    assert b"samesite=lax" in cookie_blob
    # Username hint is readable by the chat UI's JS (not HttpOnly).
    assert b"ods-target-user=alice" in cookie_blob


def test_redeem_issues_signed_cookie_that_verifies(magic_link_client, magic_link_module):
    """The ods-session cookie set by redemption must round-trip through
    session_signer.verify() — that's what ods-proxy's forward_auth will
    call on every protected request."""
    import session_signer

    gen = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "alice"},
        headers=magic_link_client.auth_headers,
    )
    token = gen.json()["token"]

    resp = magic_link_client.get(f"/auth/magic-link/{token}", follow_redirects=False)
    assert resp.status_code == 302
    from http.cookies import SimpleCookie

    cookies = SimpleCookie()
    for key, value in resp.headers.raw:
        if key.lower() == b"set-cookie":
            cookies.load(value.decode("latin1"))
    cookie = cookies["ods-session"].value if "ods-session" in cookies else None
    assert cookie, "redemption did not set ods-session cookie"

    ok, reason = session_signer.verify(cookie)
    assert ok is True, f"signed cookie did not verify: {reason}"


def test_redeem_redirects_to_chat_subdomain(magic_link_client, magic_link_module, monkeypatch):
    """Successful redemption 302s to chat.<device>.local — the ods-proxy
    routes Host: chat.<device>.local to Open WebUI. The cookie set just
    above uses Domain=<device>.local so it travels to the chat subdomain."""
    monkeypatch.setenv("ODS_DEVICE_NAME", "kitchen")
    monkeypatch.setenv("ODS_COOKIE_DOMAIN", "kitchen.local")

    gen = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "alice"},
        headers=magic_link_client.auth_headers,
    )
    token = gen.json()["token"]

    resp = magic_link_client.get(f"/magic-link/{token}", follow_redirects=False)
    assert resp.status_code == 302
    assert resp.headers["location"] == "http://chat.kitchen.local"


def test_redeem_hermes_scope_redirects_to_hermes_subdomain(magic_link_client, monkeypatch):
    monkeypatch.setenv("ODS_DEVICE_NAME", "kitchen")
    monkeypatch.setenv("ODS_COOKIE_DOMAIN", "kitchen.local")

    gen = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "alice", "scope": "hermes"},
        headers=magic_link_client.auth_headers,
    )
    token = gen.json()["token"]

    resp = magic_link_client.get(f"/magic-link/{token}", follow_redirects=False)
    assert resp.status_code == 302
    assert resp.headers["location"] == "http://hermes.kitchen.local"


def test_owner_hermes_scope_redirects_to_ods_talk(magic_link_client, monkeypatch):
    monkeypatch.setenv("ODS_DEVICE_NAME", "kitchen")
    monkeypatch.setenv("ODS_COOKIE_DOMAIN", "kitchen.local")

    gen = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "owner", "token_type": "owner", "scope": "hermes"},
        headers=magic_link_client.auth_headers,
    )
    token = gen.json()["token"]

    resp = magic_link_client.get(f"/magic-link/{token}", follow_redirects=False)
    assert resp.status_code == 302
    assert resp.headers["location"] == "http://talk.kitchen.local/talk"


def test_owner_token_can_be_redeemed_repeatedly_and_revoked(
    magic_link_client, magic_link_module, monkeypatch
):
    monkeypatch.setenv("ODS_DEVICE_NAME", "studio")

    gen = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "owner", "token_type": "owner"},
        headers=magic_link_client.auth_headers,
    )
    token = gen.json()["token"]

    first = magic_link_client.get(f"/auth/magic-link/{token}", follow_redirects=False)
    second = magic_link_client.get(f"/auth/magic-link/{token}", follow_redirects=False)
    assert first.status_code == 302
    assert second.status_code == 302
    assert first.headers["location"] == "http://talk.studio.local/talk"

    store = magic_link_module._ensure_store()
    assert store["tokens"][0]["expires_at"] is None
    assert len(store["tokens"][0]["redemptions"]) == 2

    prefix = store["tokens"][0]["token_hash"][:8]
    rev = magic_link_client.delete(
        f"/api/auth/magic-link/{prefix}",
        headers=magic_link_client.auth_headers,
    )
    assert rev.status_code == 200

    after_revoke = magic_link_client.get(f"/auth/magic-link/{token}", follow_redirects=False)
    assert after_revoke.status_code == 404


def test_redeem_sets_cookie_with_configured_domain(magic_link_client, magic_link_module, monkeypatch):
    """When ODS_COOKIE_DOMAIN is set, the cookie carries that Domain
    attribute so the browser shares it across subdomains (SSO).
    Without it, the cookie stays host-only."""
    monkeypatch.setenv("ODS_DEVICE_NAME", "kitchen")
    monkeypatch.setenv("ODS_COOKIE_DOMAIN", "kitchen.local")

    gen = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "alice"},
        headers=magic_link_client.auth_headers,
    )
    token = gen.json()["token"]
    resp = magic_link_client.get(f"/magic-link/{token}", follow_redirects=False)
    assert resp.status_code == 302
    cookie_blob = b" ".join(
        v for k, v in resp.headers.raw if k.lower() == b"set-cookie"
    ).lower()
    assert b"domain=kitchen.local" in cookie_blob


def test_redeem_defaults_cookie_domain_to_device_domain(magic_link_client, magic_link_module, monkeypatch):
    """With ODS_COOKIE_DOMAIN unset in the default host-based layout, derive
    <ODS_DEVICE_NAME>.local so redemption on auth.<device>.local carries to
    chat.<device>.local."""
    monkeypatch.setenv("ODS_DEVICE_NAME", "kitchen")
    monkeypatch.delenv("ODS_COOKIE_DOMAIN", raising=False)
    gen = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "alice"},
        headers=magic_link_client.auth_headers,
    )
    token = gen.json()["token"]
    resp = magic_link_client.get(f"/magic-link/{token}", follow_redirects=False)
    cookie_blob = b" ".join(
        v for k, v in resp.headers.raw if k.lower() == b"set-cookie"
    ).lower()
    assert b"domain=kitchen.local" in cookie_blob, cookie_blob


def test_redeem_omits_cookie_domain_with_public_url_override(magic_link_client, magic_link_module, monkeypatch):
    """ODS_PUBLIC_URL means the operator chose a single custom origin/path
    layout, so omit Domain and let the cookie stay host-only."""
    monkeypatch.setenv("ODS_PUBLIC_URL", "http://ods.example")
    monkeypatch.delenv("ODS_COOKIE_DOMAIN", raising=False)
    gen = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "alice"},
        headers=magic_link_client.auth_headers,
    )
    token = gen.json()["token"]
    resp = magic_link_client.get(f"/auth/magic-link/{token}", follow_redirects=False)
    cookie_blob = b" ".join(
        v for k, v in resp.headers.raw if k.lower() == b"set-cookie"
    ).lower()
    assert b"domain=" not in cookie_blob, cookie_blob


def test_redeem_refuses_when_signing_unconfigured(magic_link_client, magic_link_module):
    """If ODS_SESSION_SECRET isn't configured, redemption returns 503
    BEFORE the single-use token is marked used. Otherwise a misconfigured
    install burns invites on every attempt — the user can't retry and
    has to ask the admin for a new link. The pre-check protects the
    invite."""
    import session_signer
    # Generate an invite while the secret is set (fixture state).
    gen = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "alice"},
        headers=magic_link_client.auth_headers,
    )
    token = gen.json()["token"]

    # Now clear the secret to simulate misconfiguration.
    session_signer._set_secret_for_tests("")

    resp = magic_link_client.get(f"/magic-link/{token}", follow_redirects=False)
    assert resp.status_code == 503
    assert "not configured" in resp.json()["detail"].lower()

    # The invite must NOT be marked used — restore the secret and confirm
    # redemption still works on a retry.
    session_signer._set_secret_for_tests("test-secret-for-magic-link-tests")
    retry = magic_link_client.get(f"/magic-link/{token}", follow_redirects=False)
    assert retry.status_code == 302, retry.text


def test_redeem_back_compat_auth_prefix_route_still_works(magic_link_client, magic_link_module):
    """The /auth/magic-link/<token> path is kept for back-compat with
    ODS_PUBLIC_URL overrides and any in-flight QR codes from before
    the URL shape change. Both routes call the same handler."""
    gen = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "alice"},
        headers=magic_link_client.auth_headers,
    )
    token = gen.json()["token"]

    resp = magic_link_client.get(f"/auth/magic-link/{token}", follow_redirects=False)
    assert resp.status_code == 302


def test_redeem_marks_token_used(magic_link_client, magic_link_module):
    """Second redemption of a single-use token must 404."""
    gen = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "alice"},
        headers=magic_link_client.auth_headers,
    )
    token = gen.json()["token"]

    first = magic_link_client.get(f"/auth/magic-link/{token}", follow_redirects=False)
    assert first.status_code == 302

    second = magic_link_client.get(f"/auth/magic-link/{token}", follow_redirects=False)
    assert second.status_code == 404
    assert second.json()["detail"] == "Invalid or expired magic link"


# ---------------------------------------------------------------------------
# Redeem failure modes — all must return the same opaque 404
# ---------------------------------------------------------------------------


def test_redeem_invalid_token(magic_link_client):
    resp = magic_link_client.get(
        "/auth/magic-link/not-a-real-token",
        follow_redirects=False,
    )
    assert resp.status_code == 404
    assert resp.json()["detail"] == "Invalid or expired magic link"


def test_redeem_expired_token(magic_link_client, magic_link_module):
    """An expired token returns the same 404 as a bogus one."""
    # Generate with the minimum (60s) expiry, then forcibly age the record.
    gen = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "alice", "expires_in": 60},
        headers=magic_link_client.auth_headers,
    )
    token = gen.json()["token"]

    # Reach into storage and rewind expires_at to the past.
    store = magic_link_module._ensure_store()
    assert store["tokens"], "token store must contain the generated record"
    past = (datetime.now(timezone.utc) - timedelta(minutes=5)).isoformat()
    store["tokens"][0]["expires_at"] = past
    magic_link_module._write_store(store)

    resp = magic_link_client.get(f"/auth/magic-link/{token}", follow_redirects=False)
    assert resp.status_code == 404
    assert resp.json()["detail"] == "Invalid or expired magic link"


def test_redeem_revoked_token(magic_link_client, magic_link_module):
    gen = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "alice"},
        headers=magic_link_client.auth_headers,
    )
    token = gen.json()["token"]

    store = magic_link_module._ensure_store()
    prefix = store["tokens"][0]["token_hash"][:8]

    rev = magic_link_client.delete(
        f"/api/auth/magic-link/{prefix}",
        headers=magic_link_client.auth_headers,
    )
    assert rev.status_code == 200
    assert rev.json()["revoked"] is True

    resp = magic_link_client.get(f"/auth/magic-link/{token}", follow_redirects=False)
    assert resp.status_code == 404


# ---------------------------------------------------------------------------
# Reusable-token semantics
# ---------------------------------------------------------------------------


def test_reusable_token_can_be_redeemed_multiple_times(
    magic_link_client, magic_link_module
):
    gen = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "family", "reusable": True},
        headers=magic_link_client.auth_headers,
    )
    token = gen.json()["token"]

    first = magic_link_client.get(f"/auth/magic-link/{token}", follow_redirects=False)
    second = magic_link_client.get(f"/auth/magic-link/{token}", follow_redirects=False)
    third = magic_link_client.get(f"/auth/magic-link/{token}", follow_redirects=False)

    assert first.status_code == 302
    assert second.status_code == 302
    assert third.status_code == 302

    # Audit trail records every redemption.
    store = magic_link_module._ensure_store()
    redemptions = store["tokens"][0]["redemptions"]
    assert len(redemptions) == 3


def test_owner_token_survives_pruning(magic_link_module):
    store = {
        "tokens": [
            {
                "token_hash": "a" * 64,
                "target_username": "owner",
                "scope": "hermes",
                "reusable": True,
                "token_type": "owner",
                "url_mode": "lan",
                "created_at": (datetime.now(timezone.utc) - timedelta(days=365)).isoformat(),
                "expires_at": None,
                "created_by_ip": "127.0.0.1",
                "redemptions": [],
                "revoked_at": None,
                "note": "factory card",
            }
        ]
    }
    pruned = magic_link_module._prune(store)
    assert len(pruned["tokens"]) == 1
    assert pruned["tokens"][0]["token_type"] == "owner"


# ---------------------------------------------------------------------------
# Rate-limit
# ---------------------------------------------------------------------------


def test_rate_limit_kicks_in_after_repeated_failures(
    magic_link_client, magic_link_module
):
    # 5 failures is the configured ceiling; the 6th must return 429.
    for _ in range(magic_link_module._RATE_LIMIT_MAX_FAILURES):
        bad = magic_link_client.get(
            "/auth/magic-link/no-such-token",
            follow_redirects=False,
        )
        assert bad.status_code == 404

    blocked = magic_link_client.get(
        "/auth/magic-link/no-such-token",
        follow_redirects=False,
    )
    assert blocked.status_code == 429


# ---------------------------------------------------------------------------
# List + revoke
# ---------------------------------------------------------------------------


def test_list_includes_generated_token(magic_link_client):
    magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "alice", "note": "for laptop"},
        headers=magic_link_client.auth_headers,
    )
    resp = magic_link_client.get(
        "/api/auth/magic-link/list",
        headers=magic_link_client.auth_headers,
    )
    assert resp.status_code == 200
    tokens = resp.json()["tokens"]
    assert len(tokens) == 1
    assert tokens[0]["target_username"] == "alice"
    assert tokens[0]["token_type"] == "guest"
    assert tokens[0]["url_mode"] == "auto"
    assert tokens[0]["note"] == "for laptop"
    assert len(tokens[0]["token_hash_prefix"]) == 8
    assert tokens[0]["redemption_count"] == 0
    assert tokens[0]["revoked_at"] is None


def test_list_normalizes_legacy_records(magic_link_client, magic_link_module):
    magic_link_module._write_store({
        "tokens": [
            {
                "token_hash": "b" * 64,
                "target_username": "legacy",
                "scope": "chat",
                "reusable": False,
                "created_at": datetime.now(timezone.utc).isoformat(),
                "expires_at": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
                "created_by_ip": "127.0.0.1",
                "redemptions": [],
                "revoked_at": None,
                "note": None,
            }
        ]
    })
    resp = magic_link_client.get(
        "/api/auth/magic-link/list",
        headers=magic_link_client.auth_headers,
    )
    assert resp.status_code == 200
    token = resp.json()["tokens"][0]
    assert token["token_type"] == "guest"
    assert token["url_mode"] == "auto"
    assert token["scope"] == "chat"


def test_list_reflects_redemption_count(magic_link_client):
    gen = magic_link_client.post(
        "/api/auth/magic-link/generate",
        json={"target_username": "alice", "reusable": True},
        headers=magic_link_client.auth_headers,
    )
    token = gen.json()["token"]

    magic_link_client.get(f"/auth/magic-link/{token}", follow_redirects=False)
    magic_link_client.get(f"/auth/magic-link/{token}", follow_redirects=False)

    resp = magic_link_client.get(
        "/api/auth/magic-link/list",
        headers=magic_link_client.auth_headers,
    )
    tokens = resp.json()["tokens"]
    assert tokens[0]["redemption_count"] == 2
    assert tokens[0]["last_redeemed_at"] is not None


def test_revoke_short_prefix_rejected(magic_link_client):
    resp = magic_link_client.delete(
        "/api/auth/magic-link/abc",
        headers=magic_link_client.auth_headers,
    )
    assert resp.status_code == 400


def test_revoke_unknown_prefix_returns_404(magic_link_client):
    resp = magic_link_client.delete(
        "/api/auth/magic-link/deadbeef",
        headers=magic_link_client.auth_headers,
    )
    assert resp.status_code == 404


# ---------------------------------------------------------------------------
# QR endpoint
# ---------------------------------------------------------------------------


def test_qr_endpoint_returns_data_url_when_qrcode_installed(magic_link_client):
    """QR endpoint returns a base64 data URL if the qrcode library is available;
    otherwise 503 with a clear hint. Both shapes are acceptable — the test
    asserts the contract on whichever path is taken."""
    resp = magic_link_client.get(
        "/api/auth/magic-link/qr?url=http://ods.local:3002/auth/magic-link/abc",
        headers=magic_link_client.auth_headers,
    )
    if resp.status_code == 200:
        data = resp.json()
        assert data["data_url"].startswith("data:image/png;base64,")
    else:
        assert resp.status_code == 503
        assert "qrcode" in resp.json()["detail"].lower()


def test_store_falls_back_when_primary_parent_is_unwritable(
    magic_link_module, tmp_path, monkeypatch,
):
    """Docker Desktop can expose /data as non-writable while /data/config works."""
    blocked_parent = tmp_path / "auth-blocked"
    blocked_parent.write_text("not a directory", encoding="utf-8")
    fallback_store = tmp_path / "config" / "auth" / "magic-links.json"
    monkeypatch.setattr(
        magic_link_module,
        "_magic_link_store_candidates",
        lambda: [blocked_parent / "magic-links.json", fallback_store],
    )

    magic_link_module._write_store({"tokens": []})

    assert fallback_store.exists()
    assert magic_link_module._ensure_store() == {"tokens": []}
