import sqlite3

import pytest
import httpx

from src import main
from src.state_encryption import PrivateStateEncryptionError
from src.today_store import TodayPlanFull, TodaySessionConflict, TodayStore


def test_task_bearing_today_journey_is_encrypted_at_rest_and_survives_restart(tmp_path):
    path = tmp_path / "today.sqlite3"
    key = b"t" * 32
    store = TodayStore(path, encryption_key=key, clock=lambda: 1234.5)
    issue = "issue:private-roadmap/launch:4242:"
    device = "private-phone-canary"
    recap_session = "private-recap-canary"

    plan = store.apply_batch("Timmy", [
        {"operation_id": "add", "action": "add", "item_id": issue},
        {"operation_id": "configure", "action": "configure", "item_id": "plan",
         "capacity_minutes": 180, "estimates": {issue: 75}},
    ])
    session = store.update_session(
        "timmy", base_revision=0, device_id=device, identity=issue,
        elapsed_ms=90_000, running=True,
    )
    recap = store.save_recap("timmy", recap_session, [
        {"identity": issue, "estimate_minutes": 75, "actual_minutes": 91}
    ])
    assert store.begin_time_log("timmy", recap_session, issue, 91) == "claimed"
    store.finish_time_log("timmy", recap_session, issue, succeeded=True)

    retained = b"".join(
        candidate.read_bytes()
        for candidate in (path, path.with_name(path.name + "-wal"))
        if candidate.exists()
    )
    for canary in (issue, device, recap_session):
        assert canary.encode() not in retained
    assert retained.count(b"v1:") >= 4

    reopened = TodayStore(path, encryption_key=key)
    assert reopened.get("timmy") == {
        key: value for key, value in plan.items()
        if key not in {"accepted_operation_ids", "duplicate_operation_ids", "rejected_operations"}
    }
    assert reopened.get_session("timmy") == session
    assert reopened.list_recaps("timmy") == [recap]
    assert reopened.begin_time_log("timmy", recap_session, issue, 91) == "logged"


def test_legacy_today_rows_migrate_on_use_without_changing_behavior(tmp_path):
    path = tmp_path / "today.sqlite3"
    key = b"m" * 32
    store = TodayStore(path, encryption_key=key)
    issue = "issue:legacy-private/project:7:"
    with sqlite3.connect(path) as connection:
        connection.execute(
            "INSERT INTO today_plans VALUES (?, ?, ?, ?, ?, ?, ?)",
            ("timmy", 4, f'["{issue}"]', 120, f'{{"{issue}":45}}', "2026-08-19", "UTC"),
        )
        connection.execute(
            "INSERT INTO today_sessions VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
            ("timmy", 3, "legacy-phone", issue, 5000, 1, None, 99.0),
        )
        connection.execute(
            "INSERT INTO today_recaps VALUES (?, ?, ?, ?)",
            ("timmy", "legacy-session", 88.0,
             f'[{ {"identity": issue, "estimate_minutes": 45, "actual_minutes": 50} }]'.replace("'", '"')),
        )
        connection.execute(
            "INSERT INTO today_time_logs VALUES (?, ?, ?, ?, ?)",
            ("timmy", "legacy-session", issue, 50, "pending"),
        )

    assert store.get("timmy")["revision"] == 4
    assert store.get_session("timmy")["revision"] == 3
    assert store.list_recaps("timmy")[0]["items"][0]["identity"] == issue
    assert store.begin_time_log("timmy", "legacy-session", issue, 50) == "pending"

    retained = path.read_bytes()
    for canary in (issue, "legacy-phone", "legacy-session"):
        assert canary.encode() not in retained


@pytest.mark.anyio
async def test_tampered_or_account_substituted_today_state_fails_closed_with_retry(monkeypatch, tmp_path):
    path = tmp_path / "today.sqlite3"
    store = TodayStore(path, encryption_key=b"a" * 32)
    store.apply("timmy", "seed", "add", "issue:private/repo:9:")
    with sqlite3.connect(path) as connection:
        payload = connection.execute(
            "SELECT ids FROM today_plans WHERE login = 'timmy'"
        ).fetchone()[0]
        connection.execute(
            "INSERT INTO today_plans(login, revision, ids, estimates) VALUES ('alexander', 1, ?, '{}')",
            (payload,),
        )

    with pytest.raises(PrivateStateEncryptionError):
        store.get("alexander")

    async def user():
        return {"login": "alexander"}

    monkeypatch.setattr(main, "current_user", user)
    monkeypatch.setattr(main, "_today_store", lambda: store)
    with pytest.raises(main.HTTPException) as raised:
        await main.get_today_plan()
    assert raised.value.status_code == 503
    assert raised.value.detail == "Today synchronization is unavailable"
    assert raised.value.headers == {"Retry-After": "1"}


def test_active_session_is_durable_and_account_scoped(tmp_path):
    path = tmp_path / "today.sqlite3"
    store = TodayStore(path, clock=lambda: 1234.5)

    saved = store.update_session(
        "Timmy", base_revision=0, device_id="phone-a",
        identity="issue:stackchain/dashboard:42:", elapsed_ms=90_000, running=True,
    )

    assert saved == {
        "revision": 1,
        "device_id": "phone-a",
        "identity": "issue:stackchain/dashboard:42:",
        "elapsed_ms": 90_000,
        "running": True,
        "break_deadline_at": None,
        "updated_at": 1234.5,
    }
    assert TodayStore(path).get_session("timmy") == saved
    assert store.get_session("alexander") == {
        "revision": 0,
        "device_id": "",
        "identity": "",
        "elapsed_ms": 0,
        "running": False,
        "break_deadline_at": None,
        "updated_at": None,
    }


def test_timed_break_deadline_is_durable_in_the_account_session(tmp_path):
    path = tmp_path / "today.sqlite3"
    store = TodayStore(path, clock=lambda: 1234.5)

    saved = store.update_session(
        "timmy", base_revision=0, device_id="phone-a",
        identity="issue:r:42:", elapsed_ms=90_000, running=False,
        break_deadline_at=1_800_000,
    )

    assert saved["break_deadline_at"] == 1_800_000
    assert saved["running"] is False
    assert TodayStore(path).get_session("timmy") == saved


def test_active_session_claim_rejects_a_stale_revision(tmp_path):
    store = TodayStore(tmp_path / "today.sqlite3")
    first = store.update_session(
        "timmy", base_revision=0, device_id="phone-a", identity="issue:r:1:",
        elapsed_ms=1_000, running=True,
    )
    claimed = store.update_session(
        "timmy", base_revision=first["revision"], device_id="phone-b",
        identity=first["identity"], elapsed_ms=first["elapsed_ms"], running=True,
    )

    with pytest.raises(TodaySessionConflict) as raised:
        store.update_session(
            "timmy", base_revision=first["revision"], device_id="phone-a",
            identity=first["identity"], elapsed_ms=5_000, running=True,
        )

    assert raised.value.session == claimed
    assert store.get_session("timmy") == claimed


@pytest.mark.anyio
async def test_active_session_api_persists_a_bounded_break_deadline(monkeypatch, tmp_path):
    async def user():
        return {"login": "timmy"}

    store = TodayStore(tmp_path / "today.sqlite3")
    monkeypatch.setattr(main, "current_user", user)
    monkeypatch.setattr(main, "_today_store", lambda: store)
    payload = main.TodaySessionUpdate(
        base_revision=0, device_id="phone-a", identity="issue:r:1:",
        elapsed_ms=5_000, running=False, break_deadline_at=1_800_000,
    )

    saved = await main.update_today_session(payload)

    assert saved["break_deadline_at"] == 1_800_000
    assert (await main.get_today_session()) == saved


def test_active_session_api_rejects_a_running_break():
    with pytest.raises(ValueError, match="break must remain paused"):
        main.TodaySessionUpdate(
            base_revision=0, device_id="phone-a", identity="issue:r:1:",
            elapsed_ms=5_000, running=True, break_deadline_at=1_800_000,
        )


@pytest.mark.anyio
async def test_active_session_api_returns_current_state_on_claim_conflict(monkeypatch, tmp_path):
    async def user():
        return {"login": "timmy"}

    store = TodayStore(tmp_path / "today.sqlite3")
    current = store.update_session(
        "timmy", base_revision=0, device_id="phone-a", identity="issue:r:1:",
        elapsed_ms=5_000, running=True,
    )
    monkeypatch.setattr(main, "current_user", user)
    monkeypatch.setattr(main, "_today_store", lambda: store)

    payload = main.TodaySessionUpdate(
        base_revision=0, device_id="phone-b", identity="issue:r:1:",
        elapsed_ms=5_000, running=True,
    )
    with pytest.raises(main.HTTPException) as raised:
        await main.update_today_session(payload)

    assert raised.value.status_code == 409
    assert raised.value.detail == {"code": "session_changed", "session": current}


@pytest.mark.anyio
async def test_transient_today_store_failure_tells_clients_when_to_retry(monkeypatch):
    async def user():
        return {"login": "timmy"}

    class BusyStore:
        def apply(self, *_args, **_kwargs):
            raise sqlite3.OperationalError("database is locked")

    monkeypatch.setattr(main, "current_user", user)
    monkeypatch.setattr(main, "_today_store", lambda: BusyStore())
    payload = main.TodayOperation(
        operation_id="retry-me", action="add", item_id="issue:r:1:"
    )

    with pytest.raises(main.HTTPException) as raised:
        await main.update_today_plan(payload)

    assert raised.value.status_code == 503
    assert raised.value.headers == {"Retry-After": "1"}


def test_operations_are_durable_ordered_idempotent_and_account_scoped(tmp_path):
    path = tmp_path / "today.sqlite3"
    store = TodayStore(path, limit=3)

    assert store.apply("timmy", "op-1", "add", "issue:stackchain/dashboard:1:") == {
        "revision": 1,
        "ids": ["issue:stackchain/dashboard:1:"],
        "capacity_minutes": None,
        "estimates": {},
    }
    store.apply("timmy", "op-2", "add", "issue:stackchain/dashboard:2:")
    store.apply("timmy", "op-3", "add", "issue:stackchain/dashboard:3:")
    moved = store.apply(
        "timmy", "op-4", "move", "issue:stackchain/dashboard:3:", direction="up"
    )
    duplicate = store.apply(
        "timmy", "op-4", "move", "issue:stackchain/dashboard:3:", direction="up"
    )

    assert moved == duplicate == {
        "revision": 4,
        "ids": [
            "issue:stackchain/dashboard:1:",
            "issue:stackchain/dashboard:3:",
            "issue:stackchain/dashboard:2:",
        ],
        "capacity_minutes": None,
        "estimates": {},
    }
    assert TodayStore(path, limit=3).get("timmy") == moved
    assert store.get("alexander") == {
        "revision": 0,
        "ids": [],
        "capacity_minutes": None,
        "estimates": {},
    }


def test_initialized_today_reads_remain_available_during_a_planning_write(tmp_path):
    path = tmp_path / "today.sqlite3"
    store = TodayStore(path, timeout=0.05)
    expected = store.apply("timmy", "seed", "add", "issue:r:1:")

    writer = sqlite3.connect(path)
    writer.execute("BEGIN IMMEDIATE")
    try:
        assert TodayStore(path, timeout=0.05).get("timmy") == expected
    finally:
        writer.rollback()
        writer.close()


def test_first_task_completion_is_monotonic_durable_and_account_scoped(tmp_path):
    path = tmp_path / "today.sqlite3"
    key = b"f" * 32
    store = TodayStore(path, encryption_key=key)
    store.apply("timmy", "plan-change", "add", "issue:r:1:")

    completed = store.apply_batch("Timmy", [{
        "operation_id": "first-task-complete",
        "action": "activate",
        "item_id": "first-task",
        "activation_state": "complete",
        "base_revision": 0,
    }])
    replayed_coaching = store.apply_batch("timmy", [{
        "operation_id": "late-coaching",
        "action": "activate",
        "item_id": "first-task",
        "activation_state": "coaching",
        "base_revision": 0,
    }])

    assert completed["first_task_state"] == "complete"
    assert replayed_coaching["first_task_state"] == "complete"
    assert TodayStore(path, encryption_key=key).get("timmy")["first_task_state"] == "complete"
    assert "first_task_state" not in store.get("alexander")
    retained = path.read_bytes()
    assert b'first_task_state' not in retained
    assert b'complete' not in retained


def test_capacity_and_estimates_are_durable_account_scoped_and_follow_item_identity(tmp_path):
    path = tmp_path / "today.sqlite3"
    store = TodayStore(path, limit=3)
    store.apply("timmy", "add-1", "add", "issue:r:1:")
    store.apply("timmy", "add-2", "add", "issue:r:2:")

    configured = store.apply_batch("timmy", [{
        "operation_id": "capacity-1",
        "action": "configure",
        "item_id": "plan",
        "capacity_minutes": 180,
        "estimates": {"issue:r:1:": 60, "issue:r:2:": 45, "issue:r:99:": 30},
        "base_revision": 2,
    }])
    store.apply("timmy", "move-2", "move", "issue:r:2:", direction="up")
    store.apply("timmy", "remove-1", "remove", "issue:r:1:")

    assert configured["capacity_minutes"] == 180
    assert configured["estimates"] == {"issue:r:1:": 60, "issue:r:2:": 45}
    assert TodayStore(path, limit=3).get("timmy") == {
        "revision": 5,
        "ids": ["issue:r:2:"],
        "capacity_minutes": 180,
        "estimates": {"issue:r:2:": 45},
    }
    assert store.get("alexander") == {
        "revision": 0,
        "ids": [],
        "capacity_minutes": None,
        "estimates": {},
    }


def test_limit_is_atomic_and_remove_frees_capacity(tmp_path):
    store = TodayStore(tmp_path / "today.sqlite3", limit=2)
    store.apply("timmy", "one", "add", "issue:r:1:")
    store.apply("timmy", "two", "add", "issue:r:2:")

    with pytest.raises(TodayPlanFull):
        store.apply("timmy", "three", "add", "issue:r:3:")

    assert store.get("timmy") == {
        "revision": 2,
        "ids": ["issue:r:1:", "issue:r:2:"],
        "capacity_minutes": None,
        "estimates": {},
    }
    store.apply("timmy", "remove", "remove", "issue:r:1:")
    assert store.apply("timmy", "retry-three", "add", "issue:r:3:")["ids"] == [
        "issue:r:2:",
        "issue:r:3:",
    ]


def test_batch_applies_ordered_operations_once_and_rejects_only_capacity_conflicts(tmp_path):
    store = TodayStore(tmp_path / "today.sqlite3", limit=2)

    result = store.apply_batch(
        "timmy",
        [
            {"operation_id": "one", "action": "add", "item_id": "issue:r:1:"},
            {"operation_id": "two", "action": "add", "item_id": "issue:r:2:"},
            {"operation_id": "full", "action": "add", "item_id": "issue:r:3:"},
            {"operation_id": "remove", "action": "remove", "item_id": "issue:r:1:"},
            {"operation_id": "three", "action": "add", "item_id": "issue:r:3:"},
        ],
    )

    assert result == {
        "revision": 4,
        "ids": ["issue:r:2:", "issue:r:3:"],
        "capacity_minutes": None,
        "estimates": {},
        "accepted_operation_ids": ["one", "two", "remove", "three"],
        "duplicate_operation_ids": [],
        "rejected_operations": [{"operation_id": "full", "reason": "today_full"}],
    }
    replay = store.apply_batch("timmy", [
        {"operation_id": "two", "action": "add", "item_id": "issue:r:2:"},
        {"operation_id": "three", "action": "add", "item_id": "issue:r:3:"},
    ])
    assert replay["revision"] == 4
    assert replay["accepted_operation_ids"] == []
    assert replay["duplicate_operation_ids"] == ["two", "three"]


def test_operation_receipts_are_bounded_per_account_and_existing_schema_migrates(tmp_path):
    path = tmp_path / "today.sqlite3"
    with sqlite3.connect(path) as connection:
        connection.execute(
            "CREATE TABLE today_operations (login TEXT NOT NULL, operation_id TEXT NOT NULL, "
            "PRIMARY KEY (login, operation_id))"
        )
        connection.execute(
            "INSERT INTO today_operations(login, operation_id) VALUES ('timmy', 'legacy')"
        )

    store = TodayStore(path, limit=20, operation_limit=3, clock=lambda: 1_000)
    for index in range(5):
        store.apply("timmy", f"op-{index}", "add", f"issue:r:{index}:")
    store.apply("alexander", "other", "add", "issue:r:99:")

    with sqlite3.connect(path) as connection:
        columns = {row[1] for row in connection.execute("PRAGMA table_info(today_operations)")}
        timmy = connection.execute(
            "SELECT operation_id FROM today_operations WHERE login = 'timmy' ORDER BY rowid"
        ).fetchall()
        alexander = connection.execute(
            "SELECT operation_id FROM today_operations WHERE login = 'alexander'"
        ).fetchall()

    assert "created_at" in columns
    assert timmy == [("op-2",), ("op-3",), ("op-4",)]
    assert alexander == [("other",)]


def test_pruned_stale_today_operation_cannot_reorder_a_newer_plan(tmp_path):
    store = TodayStore(tmp_path / "today.sqlite3", limit=5, operation_limit=1)
    store.apply_batch("timmy", [
        {"operation_id": "seed-1", "action": "add", "item_id": "issue:r:1:", "base_revision": 0},
        {"operation_id": "seed-2", "action": "add", "item_id": "issue:r:2:", "base_revision": 0},
    ])
    store.apply_batch("timmy", [{
        "operation_id": "offline-move", "action": "move", "item_id": "issue:r:2:",
        "direction": "up", "base_revision": 2,
    }])
    newer = store.apply_batch("timmy", [{
        "operation_id": "newer-move", "action": "move", "item_id": "issue:r:1:",
        "direction": "up", "base_revision": 3,
    }])

    replay = store.apply_batch("timmy", [{
        "operation_id": "offline-move", "action": "move", "item_id": "issue:r:2:",
        "direction": "up", "base_revision": 2,
    }])

    assert replay["ids"] == newer["ids"] == ["issue:r:1:", "issue:r:2:"]
    assert replay["revision"] == 4
    assert replay["accepted_operation_ids"] == []
    assert replay["rejected_operations"] == [
        {"operation_id": "offline-move", "reason": "stale_intent"}
    ]


def test_today_receipt_age_pruning_is_account_scoped(tmp_path):
    clock = [1_000.0]
    path = tmp_path / "today.sqlite3"
    store = TodayStore(
        path, limit=5, operation_retention_seconds=60, clock=lambda: clock[0]
    )
    store.apply("timmy", "old", "add", "issue:r:1:")
    store.apply("alexander", "other-old", "add", "issue:r:2:")
    clock[0] += 61
    store.apply("timmy", "fresh", "remove", "issue:r:1:")

    with sqlite3.connect(path) as connection:
        timmy = connection.execute(
            "SELECT operation_id FROM today_operations WHERE login = 'timmy'"
        ).fetchall()
        alexander = connection.execute(
            "SELECT operation_id FROM today_operations WHERE login = 'alexander'"
        ).fetchall()

    assert timmy == [("fresh",)]
    assert alexander == [("other-old",)]


def test_today_api_model_preserves_client_base_revision():
    operation = main.TodayOperation(
        operation_id="offline", action="move", item_id="issue:r:2:",
        direction="up", base_revision=7,
    )

    assert operation.model_dump()["base_revision"] == 7


def test_today_api_model_accepts_bounded_capacity_configuration():
    operation = main.TodayOperation(
        operation_id="capacity", action="configure", item_id="plan",
        capacity_minutes=180,
        estimates={"issue:r:1:": 60, "issue:r:2:": 45},
        base_revision=2,
    )

    assert operation.model_dump() == {
        "operation_id": "capacity",
        "action": "configure",
        "item_id": "plan",
        "direction": None,
        "base_revision": 2,
        "capacity_minutes": 180,
        "estimates": {"issue:r:1:": 60, "issue:r:2:": 45},
    }


def test_today_api_model_accepts_only_bounded_first_task_activation():
    operation = main.TodayOperation(
        operation_id="activation", action="activate", item_id="first-task",
        activation_state="complete", base_revision=7,
    )

    assert operation.model_dump() == {
        "operation_id": "activation", "action": "activate", "item_id": "first-task",
        "direction": None, "base_revision": 7, "capacity_minutes": None,
        "estimates": {}, "activation_state": "complete",
    }
    with pytest.raises(ValueError):
        main.TodayOperation(
            operation_id="bad", action="activate", item_id="another-flow",
            activation_state="complete",
        )


@pytest.mark.anyio
async def test_single_capacity_configuration_uses_atomic_batch_path(monkeypatch):
    async def user():
        return {"login": "timmy"}

    calls = []
    class Store:
        def apply_batch(self, login, operations):
            calls.append((login, operations))
            return {"revision": 1, "ids": [], "capacity_minutes": 180, "estimates": {}}

    monkeypatch.setattr(main, "current_user", user)
    monkeypatch.setattr(main, "_today_store", lambda: Store())
    payload = main.TodayOperation(
        operation_id="capacity", action="configure", item_id="plan", capacity_minutes=180,
    )

    result = await main.update_today_plan(payload)

    assert result["capacity_minutes"] == 180
    assert calls == [("timmy", [payload.model_dump()])]


@pytest.mark.anyio
async def test_authenticated_today_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path):
    monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
    monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
    monkeypatch.setenv(
        "STACKCHAIN_DASHBOARD_SESSION_SECRET",
        "a-separate-session-signing-secret-with-enough-entropy",
    )
    monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
    monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
    monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))

    async def user():
        return {"id": 1, "login": "Timmy"}

    monkeypatch.setattr(main, "current_user", user)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
        await client.post(
            "/api/v1/session", json={"access_token": "correct horse battery staple"}
        )
        forbidden = await client.patch(
            "/api/v1/today",
            json={
                "operation_id": "mobile-1",
                "action": "add",
                "item_id": "issue:stackchain/dashboard:357:",
            },
        )
        changed = await client.patch(
            "/api/v1/today",
            json={
                "operations": [
                    {"operation_id": "mobile-1", "action": "add", "item_id": "issue:stackchain/dashboard:357:"},
                    {"operation_id": "mobile-2", "action": "add", "item_id": "issue:stackchain/dashboard:359:"},
                ],
            },
            headers={
                "Origin": "https://test",
                "X-CSRF-Token": client.cookies["stackchain_csrf"],
            },
        )
        fetched = await client.get("/api/v1/today")
        changed_session = await client.patch(
            "/api/v1/today/session",
            json={
                "base_revision": 0,
                "device_id": "phone-a",
                "identity": "issue:stackchain/dashboard:357:",
                "elapsed_ms": 12_000,
                "running": True,
            },
            headers={
                "Origin": "https://test",
                "X-CSRF-Token": client.cookies["stackchain_csrf"],
            },
        )
        fetched_session = await client.get("/api/v1/today/session")

    assert forbidden.status_code == 403
    assert changed.status_code == 200
    assert changed.json() == {
        "revision": 2,
        "ids": ["issue:stackchain/dashboard:357:", "issue:stackchain/dashboard:359:"],
        "capacity_minutes": None,
        "estimates": {},
        "accepted_operation_ids": ["mobile-1", "mobile-2"],
        "duplicate_operation_ids": [],
        "rejected_operations": [],
    }
    assert fetched.json() == {
        "revision": 2,
        "ids": ["issue:stackchain/dashboard:357:", "issue:stackchain/dashboard:359:"],
        "capacity_minutes": None,
        "estimates": {},
    }
    assert fetched.headers["cache-control"] == "no-store"
    assert changed_session.status_code == 200
    assert fetched_session.json() == changed_session.json()
    assert fetched_session.headers["cache-control"] == "no-store"
