"""The JWT session contract and the password primitives (design D12, D13, D17).

Pure unit tests — no database. What they pin is the set of properties that make a two-day
token safe to issue: it carries no authority of its own, it cannot be edited, and it can be
invalidated before it expires.
"""

import time
import uuid

import jwt
import pytest
from fastapi import Request, Response

from app.config import get_settings
from app.services import auth, passwords


def _request_with(token: str | None) -> Request:
    headers = []
    if token is not None:
        headers.append((b"cookie", f"{auth.SESSION_COOKIE}={token}".encode()))
    return Request({"type": "http", "headers": headers, "method": "GET", "path": "/"})


class _FakeAdmin:
    """Only what `issue_session` and `revoke_sessions` read."""

    def __init__(self) -> None:
        self.id = uuid.uuid4()
        self.session_epoch = 0


def _issue() -> tuple[str, _FakeAdmin]:
    admin = _FakeAdmin()
    response = Response()
    token = auth.issue_session(response, admin)  # type: ignore[arg-type]
    return token, admin


# --------------------------------------------------------------------------- the token


def test_the_token_identifies_the_account_and_nothing_more() -> None:
    """Role is deliberately absent (design D12).

    It is re-read from the database on every request, which is the whole reason a two-day
    lifetime is safe: a demotion applies to the next request rather than at expiry. A role
    claim here would silently undo that.
    """
    token, admin = _issue()
    claims = jwt.decode(token, get_settings().jwt_secret, algorithms=[get_settings().jwt_algorithm])
    assert claims["sub"] == str(admin.id)
    assert set(claims) == {"sub", "iat", "exp", "jti", "sv"}
    assert "role" not in claims
    assert "permissions" not in claims


def test_the_token_lasts_two_days() -> None:
    token, _ = _issue()
    claims = jwt.decode(token, get_settings().jwt_secret, algorithms=[get_settings().jwt_algorithm])
    assert claims["exp"] - claims["iat"] == 2 * 24 * 60 * 60


def test_the_cookie_is_httponly_and_lax() -> None:
    """In `localStorage` the same token would be readable by any XSS on an admin screen."""
    response = Response()
    auth.issue_session(response, _FakeAdmin())  # type: ignore[arg-type]
    header = response.headers["set-cookie"]
    assert "HttpOnly" in header
    assert "SameSite=lax" in header.replace("samesite", "SameSite")


def test_a_valid_token_decodes() -> None:
    token, admin = _issue()
    claims = auth.decode_session(_request_with(token))
    assert claims is not None and claims["sub"] == str(admin.id)


def test_a_tampered_token_is_refused() -> None:
    token, _ = _issue()
    header, payload, signature = token.split(".")
    forged = jwt.encode(
        {"sub": str(uuid.uuid4()), "iat": 0, "exp": 9999999999},
        "not-the-real-secret",
        algorithm="HS256",
    )
    assert auth.decode_session(_request_with(forged)) is None
    # Swapping the payload while keeping the original signature must also fail.
    assert auth.decode_session(_request_with(f"{header}.{payload[:-4]}zzzz.{signature}")) is None


def test_an_expired_token_is_refused() -> None:
    settings = get_settings()
    stale = jwt.encode(
        {
            "sub": str(uuid.uuid4()),
            "iat": int(time.time()) - 10_000,
            "exp": int(time.time()) - 5_000,
            "jti": uuid.uuid4().hex,
        },
        settings.jwt_secret,
        algorithm=settings.jwt_algorithm,
    )
    assert auth.decode_session(_request_with(stale)) is None


def test_a_token_signed_with_none_algorithm_is_refused() -> None:
    """The classic JWT hole: an unsigned token claiming `alg: none`."""
    unsigned = jwt.encode(
        {"sub": str(uuid.uuid4()), "iat": 0, "exp": 9999999999}, key="", algorithm="none"
    )
    assert auth.decode_session(_request_with(unsigned)) is None


def test_no_cookie_is_not_an_error() -> None:
    assert auth.decode_session(_request_with(None)) is None


def test_the_session_epoch_is_what_revokes_early() -> None:
    """The comparison `get_current_admin` makes (design D17), asserted directly.

    A token issued before a password change must fail even though it is unexpired and the
    account is still active — the one revocation a row lookup alone cannot express.
    """
    admin = _FakeAdmin()
    old_token = auth.issue_session(Response(), admin)  # type: ignore[arg-type]
    old_claims = auth.decode_session(_request_with(old_token))
    assert old_claims is not None and old_claims["sv"] == 0

    auth.revoke_sessions(admin)  # type: ignore[arg-type]
    new_token = auth.issue_session(Response(), admin)  # type: ignore[arg-type]
    new_claims = auth.decode_session(_request_with(new_token))
    assert new_claims is not None and new_claims["sv"] == 1

    # Both tokens are structurally valid and unexpired; only the epoch tells them apart, and
    # `get_current_admin` compares it against the row.
    assert old_claims["sv"] != admin.session_epoch
    assert new_claims["sv"] == admin.session_epoch


def test_revocation_needs_no_clock() -> None:
    """Two revocations inside the same second are still two distinct epochs.

    The reason this is a counter and not a timestamp: at whole-second `iat` resolution, a
    time-based comparison either signs out the token issued alongside the change or leaves a
    one-second window where a revoked token still works.
    """
    admin = _FakeAdmin()
    auth.revoke_sessions(admin)  # type: ignore[arg-type]
    auth.revoke_sessions(admin)  # type: ignore[arg-type]
    assert admin.session_epoch == 2


def test_a_token_without_an_epoch_claim_is_refused() -> None:
    """An old-format token from before this change carries no `sv` and must not be honoured."""
    settings = get_settings()
    legacy = jwt.encode(
        {
            "sub": str(uuid.uuid4()),
            "iat": int(time.time()),
            "exp": int(time.time()) + 3600,
        },
        settings.jwt_secret,
        algorithm=settings.jwt_algorithm,
    )
    assert auth.decode_session(_request_with(legacy)) is None


# ------------------------------------------------------------------------- passwords


def test_a_password_round_trips() -> None:
    stored = passwords.hash_password("correct horse battery staple")
    assert passwords.verify(stored, "correct horse battery staple")
    assert not passwords.verify(stored, "Correct horse battery staple")


def test_the_hash_does_not_contain_the_password() -> None:
    stored = passwords.hash_password("hunter2-hunter2-hunter2")
    assert "hunter2" not in stored
    assert stored.startswith("$argon2id$")


def test_verifying_against_no_hash_is_false_not_an_error() -> None:
    """A Google account has no password to be right, and must not answer differently."""
    assert passwords.verify(None, "anything") is False
    assert passwords.verify("", "anything") is False


def test_a_corrupt_hash_refuses_rather_than_crashes() -> None:
    assert passwords.verify("not-a-real-hash", "anything") is False


def test_unicode_normalisation_so_a_phone_keyboard_matches_a_laptop() -> None:
    composed = "café-password-1234"
    decomposed = "café-password-1234"
    assert composed != decomposed
    assert passwords.verify(passwords.hash_password(composed), decomposed)


@pytest.mark.parametrize(
    "candidate",
    [
        "short",
        "aaaaaaaaaaaaaaaa",
        "password12345678",
        "  leading-space-here  ",
        "Passw0rd-Passw0rd",
    ],
)
def test_weak_passwords_are_refused(candidate: str) -> None:
    with pytest.raises(passwords.PasswordRejectedError):
        passwords.validate_quality(candidate, min_length=12)


def test_a_reasonable_password_is_accepted() -> None:
    assert passwords.validate_quality("mango-tree-42-sunset", min_length=12)


def test_generated_temporary_passwords_are_unguessable_and_readable() -> None:
    """Read off one screen and typed into another, often by a third person over a phone."""
    generated = {passwords.generate_temporary() for _ in range(200)}
    assert len(generated) == 200
    for value in generated:
        assert len(value) == 16
        assert not (set(value) & set("0O1lI"))


def test_a_generated_temporary_password_passes_the_quality_rules() -> None:
    """It is set through the same validation an admin's own choice goes through."""
    for _ in range(50):
        passwords.validate_quality(passwords.generate_temporary(), min_length=12)
