"""End-to-end integration tests for the v0.10.0 user-set passcode
vertical (§6.2, roadmap item #8).

After a successful OTC sign-in the user can set a passcode and use
email + passcode for subsequent sign-ins. OTC remains the structural
fallback — these tests prove:

  * `/auth/passcode/set` requires an active session.
  * `/auth/passcode/check` returns `has_passcode` without leaking the
    hash, the set-at stamp, or the lockout state.
  * Happy path: OTC sign-in → set passcode → sign out → email +
    passcode signs in (no OTC roundtrip).
  * Wrong passcode increments the failure counter without locking.
  * Five consecutive failures lock the passcode path (HTTP 423) and
    persist `passcode_locked_until` on the user row.
  * The lockout expires after `passcode_locked_until`; a verify
    attempt past the window succeeds again and clears the counter.
  * The OTC path is unaffected by the passcode lockout — a user
    whose passcode is locked can still request and verify a fresh
    OTC to sign in.
  * Clearing the passcode wipes the hash; subsequent verify refuses
    with the no-passcode failure shape.
  * Setting a new passcode replaces the prior one (and resets the
    failure counter / lockout state).
  * `passcode_set_at` updates on every set call.
  * The validation denylist refuses obvious patterns (e.g. `0000`,
    `1234`).
  * Passcode length is enforced (4-20).

The fakes from `test_propose_vertical` give us a working app harness.
The OTC envelope buffer from `test_otc_vertical` is reused for the
OTC roundtrips this suite needs.
"""
from __future__ import annotations

import pytest

from test_propose_vertical import (  # noqa: F401
    FakeGitea,
    app_with_fake_gitea,
    provision_user_row,
    tmp_env,
)


# ---------------------------------------------------------------------------
# Helpers — mirror the OTC suite's outbound-buffer helpers.
# ---------------------------------------------------------------------------


def _reset_outbound():
    from app import email as email_mod
    email_mod.reset_sent_envelopes()


def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
    from app import email as email_mod
    out = []
    for env in email_mod.sent_envelopes():
        if env.get("kind") != "otc":
            continue
        if to_address is not None and env["to"] != to_address:
            continue
        for line in env["body"].splitlines():
            tok = line.strip()
            if tok.isdigit() and len(tok) == 6:
                out.append(tok)
                break
    return out


def _sign_in_via_otc(client, email: str) -> None:
    """Run an OTC request+verify so the client carries an authenticated
    session. The cooldown is irrelevant on a fresh email; we don't
    need to drop it."""
    r = client.post("/auth/otc/request", json={"email": email})
    assert r.status_code == 200, r.text
    code = _outbound_otc_codes(email)[-1]
    r = client.post("/auth/otc/verify", json={"email": email, "code": code})
    assert r.status_code == 200, r.text


# ---------------------------------------------------------------------------
# Set passcode — auth-required, happy path
# ---------------------------------------------------------------------------


def test_set_passcode_requires_session(app_with_fake_gitea):
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        r = client.post("/auth/passcode/set", json={"passcode": "secret123"})
        assert r.status_code == 401


def test_set_passcode_after_otc_landing_persists_hash(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    from app import db

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        _reset_outbound()
        _sign_in_via_otc(client, "alice@example.com")

        r = client.post("/auth/passcode/set", json={"passcode": "secret123"})
        assert r.status_code == 200, r.text

        row = db.conn().execute(
            "SELECT passcode_hash, passcode_set_at FROM users WHERE email = ? COLLATE NOCASE",
            ("alice@example.com",),
        ).fetchone()
        assert row is not None
        assert row["passcode_hash"] is not None
        # Not the plaintext.
        assert row["passcode_hash"] != "secret123"
        assert row["passcode_set_at"] is not None


# ---------------------------------------------------------------------------
# Check endpoint — leak-free shape
# ---------------------------------------------------------------------------


def test_check_endpoint_returns_false_for_unknown_email(app_with_fake_gitea):
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        r = client.get("/auth/passcode/check", params={"email": "nobody@example.com"})
        assert r.status_code == 200
        assert r.json() == {"has_passcode": False}


def test_check_endpoint_returns_false_for_user_without_passcode(app_with_fake_gitea):
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        _reset_outbound()
        _sign_in_via_otc(client, "bob@example.com")

        r = client.get("/auth/passcode/check", params={"email": "bob@example.com"})
        assert r.status_code == 200
        assert r.json() == {"has_passcode": False}


def test_check_endpoint_returns_true_after_set(app_with_fake_gitea):
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        _reset_outbound()
        _sign_in_via_otc(client, "carol@example.com")
        client.post("/auth/passcode/set", json={"passcode": "letmein9"})

        # Drop the session so the check is read in the anonymous shape.
        client.cookies.clear()
        r = client.get("/auth/passcode/check", params={"email": "carol@example.com"})
        assert r.status_code == 200
        assert r.json() == {"has_passcode": True}
        # The response carries ONLY the boolean — no hash, no stamp.
        assert set(r.json().keys()) == {"has_passcode"}


# ---------------------------------------------------------------------------
# Verify path — happy path
# ---------------------------------------------------------------------------


def test_verify_passcode_signs_in_user(app_with_fake_gitea):
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        _reset_outbound()
        _sign_in_via_otc(client, "dave@example.com")
        client.post("/auth/passcode/set", json={"passcode": "secret123"})
        client.cookies.clear()

        r = client.post(
            "/auth/passcode/verify",
            json={"email": "dave@example.com", "passcode": "secret123"},
        )
        assert r.status_code == 200, r.text
        me = client.get("/api/auth/me").json()
        assert me["authenticated"] is True
        assert me["user"]["email"] == "dave@example.com"
        assert me["user"]["has_passcode"] is True


# ---------------------------------------------------------------------------
# Verify path — failure modes
# ---------------------------------------------------------------------------


def test_verify_passcode_wrong_increments_counter_without_locking(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    from app import db

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        _reset_outbound()
        _sign_in_via_otc(client, "erin@example.com")
        client.post("/auth/passcode/set", json={"passcode": "secret123"})
        client.cookies.clear()

        # Three bad attempts — under the lockout threshold.
        for _ in range(3):
            r = client.post(
                "/auth/passcode/verify",
                json={"email": "erin@example.com", "passcode": "wrongwrong"},
            )
            assert r.status_code == 400

        row = db.conn().execute(
            "SELECT passcode_failed_attempts, passcode_locked_until FROM users WHERE email = ?",
            ("erin@example.com",),
        ).fetchone()
        assert row["passcode_failed_attempts"] == 3
        assert row["passcode_locked_until"] is None


def test_verify_passcode_locks_after_five_failures(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    from app import db

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        _reset_outbound()
        _sign_in_via_otc(client, "frank@example.com")
        client.post("/auth/passcode/set", json={"passcode": "secret123"})
        client.cookies.clear()

        # Five bad attempts — the last crosses the threshold and the
        # response shape flips to 423.
        statuses = []
        for _ in range(5):
            r = client.post(
                "/auth/passcode/verify",
                json={"email": "frank@example.com", "passcode": "wrongwrong"},
            )
            statuses.append(r.status_code)
        # First four are 400, the fifth (threshold-crossing) is 423.
        assert statuses == [400, 400, 400, 400, 423]

        row = db.conn().execute(
            "SELECT passcode_failed_attempts, passcode_locked_until FROM users WHERE email = ?",
            ("frank@example.com",),
        ).fetchone()
        assert row["passcode_failed_attempts"] >= 5
        assert row["passcode_locked_until"] is not None

        # Sixth attempt — still locked, still 423, even with the correct
        # passcode (lockout overrides the verify).
        r = client.post(
            "/auth/passcode/verify",
            json={"email": "frank@example.com", "passcode": "secret123"},
        )
        assert r.status_code == 423


def test_verify_passcode_lockout_expires(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    from app import db

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        _reset_outbound()
        _sign_in_via_otc(client, "gina@example.com")
        client.post("/auth/passcode/set", json={"passcode": "secret123"})
        client.cookies.clear()

        for _ in range(5):
            client.post(
                "/auth/passcode/verify",
                json={"email": "gina@example.com", "passcode": "wrongwrong"},
            )

        # Backdate the lockout to the past so the next attempt clears it.
        db.conn().execute(
            """
            UPDATE users
               SET passcode_locked_until = datetime('now', '-1 minute')
             WHERE email = ?
            """,
            ("gina@example.com",),
        )

        r = client.post(
            "/auth/passcode/verify",
            json={"email": "gina@example.com", "passcode": "secret123"},
        )
        assert r.status_code == 200, r.text

        # Lockout cleared, counter reset.
        row = db.conn().execute(
            "SELECT passcode_failed_attempts, passcode_locked_until FROM users WHERE email = ?",
            ("gina@example.com",),
        ).fetchone()
        assert row["passcode_failed_attempts"] == 0
        assert row["passcode_locked_until"] is None


def test_otc_path_unaffected_by_passcode_lockout(app_with_fake_gitea, monkeypatch):
    from fastapi.testclient import TestClient

    # Drop the OTC cooldown so the second request lands without a 429.
    # The cooldown is re-read from env on every `request_code` call so
    # this takes effect mid-process.
    monkeypatch.setenv("OTC_REQUEST_COOLDOWN_SECONDS", "0")

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        _reset_outbound()
        _sign_in_via_otc(client, "harvey@example.com")
        client.post("/auth/passcode/set", json={"passcode": "secret123"})
        client.cookies.clear()

        # Lock the passcode path.
        for _ in range(5):
            client.post(
                "/auth/passcode/verify",
                json={"email": "harvey@example.com", "passcode": "wrongwrong"},
            )

        # The OTC path is unaffected by the passcode lockout: the user
        # can still request and verify a fresh code to sign in.
        r = client.post("/auth/otc/request", json={"email": "harvey@example.com"})
        assert r.status_code == 200
        code = _outbound_otc_codes("harvey@example.com")[-1]
        r = client.post("/auth/otc/verify", json={"email": "harvey@example.com", "code": code})
        assert r.status_code == 200

        # The user is now signed in via OTC even though the passcode
        # path is locked. The /api/auth/me payload reflects this.
        me = client.get("/api/auth/me").json()
        assert me["authenticated"] is True
        assert me["user"]["email"] == "harvey@example.com"


# ---------------------------------------------------------------------------
# Clear + replace
# ---------------------------------------------------------------------------


def test_clear_passcode_wipes_the_hash(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    from app import db

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        _reset_outbound()
        _sign_in_via_otc(client, "ivy@example.com")
        client.post("/auth/passcode/set", json={"passcode": "secret123"})

        r = client.delete("/auth/passcode")
        assert r.status_code == 200

        row = db.conn().execute(
            "SELECT passcode_hash, passcode_set_at FROM users WHERE email = ?",
            ("ivy@example.com",),
        ).fetchone()
        assert row["passcode_hash"] is None
        assert row["passcode_set_at"] is None

        # Verify against the cleared passcode refuses (no-passcode shape
        # collapses to a generic 400).
        client.cookies.clear()
        r = client.post(
            "/auth/passcode/verify",
            json={"email": "ivy@example.com", "passcode": "secret123"},
        )
        assert r.status_code == 400


def test_setting_new_passcode_replaces_old(app_with_fake_gitea):
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        _reset_outbound()
        _sign_in_via_otc(client, "jane@example.com")
        client.post("/auth/passcode/set", json={"passcode": "secret123"})
        # Replace.
        r = client.post("/auth/passcode/set", json={"passcode": "newsecret9"})
        assert r.status_code == 200

        client.cookies.clear()

        # Old passcode refuses.
        r = client.post(
            "/auth/passcode/verify",
            json={"email": "jane@example.com", "passcode": "secret123"},
        )
        assert r.status_code == 400

        # New passcode signs in.
        r = client.post(
            "/auth/passcode/verify",
            json={"email": "jane@example.com", "passcode": "newsecret9"},
        )
        assert r.status_code == 200


def test_setting_new_passcode_resets_lockout(app_with_fake_gitea, monkeypatch):
    from fastapi.testclient import TestClient
    from app import db

    monkeypatch.setenv("OTC_REQUEST_COOLDOWN_SECONDS", "0")

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        _reset_outbound()
        _sign_in_via_otc(client, "kate@example.com")
        client.post("/auth/passcode/set", json={"passcode": "secret123"})

        # Lock the passcode path with bad attempts (drop session first).
        client.cookies.clear()
        for _ in range(5):
            client.post(
                "/auth/passcode/verify",
                json={"email": "kate@example.com", "passcode": "wrongwrong"},
            )

        row = db.conn().execute(
            "SELECT passcode_locked_until FROM users WHERE email = ?",
            ("kate@example.com",),
        ).fetchone()
        assert row["passcode_locked_until"] is not None

        # Sign back in via OTC and reset the passcode.
        r = client.post("/auth/otc/request", json={"email": "kate@example.com"})
        assert r.status_code == 200
        code = _outbound_otc_codes("kate@example.com")[-1]
        r = client.post("/auth/otc/verify", json={"email": "kate@example.com", "code": code})
        assert r.status_code == 200

        r = client.post("/auth/passcode/set", json={"passcode": "freshcode9"})
        assert r.status_code == 200

        # Lockout cleared on set.
        row = db.conn().execute(
            "SELECT passcode_locked_until, passcode_failed_attempts FROM users WHERE email = ?",
            ("kate@example.com",),
        ).fetchone()
        assert row["passcode_locked_until"] is None
        assert row["passcode_failed_attempts"] == 0


def test_passcode_set_at_updates_on_each_set(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    from app import db
    import time

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        _reset_outbound()
        _sign_in_via_otc(client, "luke@example.com")

        client.post("/auth/passcode/set", json={"passcode": "secret123"})
        first_stamp = db.conn().execute(
            "SELECT passcode_set_at FROM users WHERE email = ?",
            ("luke@example.com",),
        ).fetchone()["passcode_set_at"]
        assert first_stamp is not None

        # SQLite's datetime('now') has second precision; sleep so the
        # stamp visibly advances on the next set.
        time.sleep(1.1)

        client.post("/auth/passcode/set", json={"passcode": "newcode99"})
        second_stamp = db.conn().execute(
            "SELECT passcode_set_at FROM users WHERE email = ?",
            ("luke@example.com",),
        ).fetchone()["passcode_set_at"]
        assert second_stamp is not None
        assert second_stamp >= first_stamp
        # Lexicographic compare on ISO-8601 datetime strings works for
        # the SQLite shape.
        assert second_stamp > first_stamp


# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------


def test_set_passcode_refuses_too_short(app_with_fake_gitea):
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        _reset_outbound()
        _sign_in_via_otc(client, "mia@example.com")
        r = client.post("/auth/passcode/set", json={"passcode": "abc"})
        assert r.status_code == 422


def test_set_passcode_refuses_denylist_pattern(app_with_fake_gitea):
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        _reset_outbound()
        _sign_in_via_otc(client, "nick@example.com")
        for bad in ["0000", "1234", "aaaa", "qwerty", "password"]:
            r = client.post("/auth/passcode/set", json={"passcode": bad})
            assert r.status_code == 422, f"expected 422 for {bad!r}, got {r.status_code}"


# ---------------------------------------------------------------------------
# Auth me payload
# ---------------------------------------------------------------------------


def test_auth_me_carries_has_passcode_flag(app_with_fake_gitea):
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        _reset_outbound()
        _sign_in_via_otc(client, "olga@example.com")

        me = client.get("/api/auth/me").json()
        assert me["user"]["has_passcode"] is False
        assert me["user"]["passcode_set_at"] is None

        client.post("/auth/passcode/set", json={"passcode": "secret123"})
        me = client.get("/api/auth/me").json()
        assert me["user"]["has_passcode"] is True
        assert me["user"]["passcode_set_at"] is not None
