"""Tests for the Spotify integration's boundary and its fail-closed behaviour.

What matters here is not that Spotify works — that needs real credentials and a
real account — but that the parts which protect a user's third-party
credentials behave correctly when things go wrong: the routes are gated, tokens
are never stored in the clear, and a missing key fails loudly.
"""

from __future__ import annotations

import os

import pytest
from fastapi.testclient import TestClient

from app.main import app
from app.routers.spotify import STATE_COOKIE
from app.services.spotify.oauth import SCOPE_STRING, SCOPES, authorize_url


@pytest.fixture
def client() -> TestClient:
    return TestClient(app, follow_redirects=False)


# --- the routes are behind the gate ----------------------------------------


def test_spotify_api_routes_require_a_session(client: TestClient) -> None:
    assert client.get("/api/spotify/status").status_code == 401
    assert client.post("/api/spotify/disconnect").status_code == 401


@pytest.mark.parametrize("path", ["/spotify/connect", "/spotify/callback"])
def test_spotify_browser_routes_redirect_when_signed_out(
    client: TestClient, path: str
) -> None:
    """A browser navigation must not be answered with raw JSON — an anonymous
    visitor is sent to the app, which shows the welcome page."""
    resp = client.get(path)
    assert resp.status_code == 302
    assert resp.headers["location"] == "/"


def test_callback_cannot_be_reached_anonymously(client: TestClient) -> None:
    """The callback attaches a Spotify account to a user. That user comes from
    the app session, so an unauthenticated callback must never run — otherwise
    there is no user to attach to (or, worse, a guessable one)."""
    resp = client.get("/spotify/callback?code=stolen&state=whatever")
    assert resp.status_code == 302
    assert resp.headers["location"] == "/"
    assert STATE_COOKIE not in resp.cookies


# --- the authorize URL ------------------------------------------------------


def test_authorize_url_is_well_formed(monkeypatch: pytest.MonkeyPatch) -> None:
    from app.config import get_settings

    get_settings.cache_clear()
    monkeypatch.setenv("SPOTIFY_CLIENT_ID", "test-client")
    monkeypatch.setenv("SPOTIFY_CLIENT_SECRET", "test-secret")
    monkeypatch.setenv("BW_APP_DOMAIN", "https://dailysplice.test")
    try:
        url = authorize_url("state-value")
        assert url.startswith("https://accounts.spotify.com/authorize?")
        assert "client_id=test-client" in url
        assert "response_type=code" in url
        assert "state=state-value" in url
        # The redirect URI is derived from one place and must match what is
        # registered with Spotify byte for byte.
        assert (
            "redirect_uri=https%3A%2F%2Fdailysplice.test%2Fspotify%2Fcallback" in url
        )
        # Always re-prompt, so a user can pick a different Spotify account.
        assert "show_dialog=true" in url
    finally:
        get_settings.cache_clear()


def test_every_requested_scope_has_a_stated_reason() -> None:
    """A scope with no justification is one the user is right to refuse. If a
    scope is added without a reason, this fails."""
    for scope, reason in SCOPES:
        assert scope and reason, f"scope {scope!r} has no stated reason"
    assert "playlist-modify-private" in SCOPE_STRING


def test_no_playback_or_library_write_scopes_requested() -> None:
    """Guard against scope creep. Playback control needs Premium and the app
    does not play audio; library writes are not something a playlist builder
    should be able to do. Adding either is a deliberate decision, not a drift."""
    for forbidden in (
        "user-modify-playback-state",
        "streaming",
        "user-library-modify",
        "user-follow-modify",
    ):
        assert forbidden not in SCOPE_STRING


# --- encryption fails closed ------------------------------------------------


def test_encryption_refuses_to_store_plaintext_without_a_key(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """The single most important property in this module: with no key, encrypt()
    RAISES. It must never fall back to storing a refresh token in the clear."""
    from app.config import get_settings
    from app.services import crypto

    get_settings.cache_clear()
    crypto._fernet.cache_clear()
    monkeypatch.delenv("TOKEN_ENCRYPTION_KEY", raising=False)
    try:
        assert crypto.encryption_available() is False
        with pytest.raises(crypto.TokenCryptoError):
            crypto.encrypt("a-refresh-token")
    finally:
        get_settings.cache_clear()
        crypto._fernet.cache_clear()


def test_placeholder_key_reads_as_absent(monkeypatch: pytest.MonkeyPatch) -> None:
    """A staged REPLACE_WITH_ value is a non-empty string. It must still count
    as unconfigured, or the app reports itself ready and fails on first use."""
    from app.config import get_settings
    from app.services import crypto

    get_settings.cache_clear()
    crypto._fernet.cache_clear()
    monkeypatch.setenv("TOKEN_ENCRYPTION_KEY", "REPLACE_WITH_KEY")
    try:
        assert crypto.encryption_available() is False
    finally:
        get_settings.cache_clear()
        crypto._fernet.cache_clear()


def test_round_trip_and_tamper_detection(monkeypatch: pytest.MonkeyPatch) -> None:
    from cryptography.fernet import Fernet

    from app.config import get_settings
    from app.services import crypto

    get_settings.cache_clear()
    crypto._fernet.cache_clear()
    monkeypatch.setenv("TOKEN_ENCRYPTION_KEY", Fernet.generate_key().decode())
    try:
        secret = "AQC-refresh-token-value"
        blob = crypto.encrypt(secret)
        assert secret not in blob  # the plaintext must not survive in the output
        assert crypto.decrypt(blob) == secret
        # Fernet is authenticated: a modified ciphertext is rejected rather than
        # decrypting to garbage that would be sent to Spotify as a token.
        tampered = blob[:-4] + ("AAAA" if not blob.endswith("AAAA") else "BBBB")
        with pytest.raises(crypto.TokenCryptoError):
            crypto.decrypt(tampered)
    finally:
        get_settings.cache_clear()
        crypto._fernet.cache_clear()


def test_key_change_makes_old_values_undecryptable(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Documents the rotation consequence stated in crypto.py and the plan:
    changing the key does not corrupt anything, it just means reconnecting."""
    from cryptography.fernet import Fernet

    from app.config import get_settings
    from app.services import crypto

    get_settings.cache_clear()
    crypto._fernet.cache_clear()
    monkeypatch.setenv("TOKEN_ENCRYPTION_KEY", Fernet.generate_key().decode())
    blob = crypto.encrypt("token")
    get_settings.cache_clear()
    crypto._fernet.cache_clear()
    monkeypatch.setenv("TOKEN_ENCRYPTION_KEY", Fernet.generate_key().decode())
    try:
        with pytest.raises(crypto.TokenCryptoError):
            crypto.decrypt(blob)
    finally:
        get_settings.cache_clear()
        crypto._fernet.cache_clear()


def test_spotify_reports_unconfigured_without_credentials() -> None:
    """With placeholders (or nothing) set, the deployment must say so rather
    than offer a connect button that cannot work."""
    from app.config import get_settings

    get_settings.cache_clear()
    saved = {
        k: os.environ.pop(k, None)
        for k in ("SPOTIFY_CLIENT_ID", "SPOTIFY_CLIENT_SECRET")
    }
    try:
        assert get_settings().spotify_configured is False
    finally:
        for key, value in saved.items():
            if value is not None:
                os.environ[key] = value
        get_settings.cache_clear()
