import json
import sqlite3

import httpx
import pytest

from src import main
from src.later_store import LaterStore
from src.state_encryption import PrivateStateEncryptionError


PRIVATE_KEY = b"l" * 32


@pytest.mark.anyio
async def test_transient_later_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 busy")

    monkeypatch.setattr(main, "current_user", user)
    monkeypatch.setattr(main, "_later_store", lambda: BusyStore())
    payload = main.LaterOperation(
        operation_id="retry-me",
        action="defer",
        item_id="issue:r:1:",
        wake_at="2026-08-10T09:00:00.000Z",
    )

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

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


def test_deferrals_are_durable_revisioned_idempotent_and_account_scoped(tmp_path):
    path = tmp_path / "later.sqlite3"
    store = LaterStore(path)

    deferred = store.apply(
        "Timmy",
        "op-1",
        "defer",
        "issue:stackchain/dashboard:363:",
        wake_at="2026-08-10T09:00:00.000Z",
    )
    duplicate = store.apply(
        "timmy",
        "op-1",
        "defer",
        "issue:stackchain/dashboard:363:",
        wake_at="2026-08-11T09:00:00.000Z",
    )

    assert duplicate == deferred == {
        "revision": 1,
        "records": {
            "issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z"
        },
    }
    assert LaterStore(path).get("timmy") == deferred
    assert store.get("alexander") == {"revision": 0, "records": {}}

    assert store.apply(
        "timmy", "op-2", "restore", "issue:stackchain/dashboard:363:"
    ) == {"revision": 2, "records": {}}


def test_later_plans_encrypt_private_content_and_authenticate_the_account(tmp_path):
    path = tmp_path / "later.sqlite3"
    item_id = "issue:private/repository:363:"
    wake_at = "2026-08-10T09:17:00.000Z"
    store = LaterStore(path, encryption_key=PRIVATE_KEY)

    expected = store.apply(
        "timmy", "op-private", "defer", item_id,
        wake_at=wake_at, handoff="today",
    )

    with sqlite3.connect(path) as connection:
        records = connection.execute(
            "SELECT records FROM later_plans WHERE login = 'timmy'"
        ).fetchone()[0]
        stored_item_id = connection.execute(
            "SELECT item_id FROM later_item_revisions WHERE login = 'timmy'"
        ).fetchone()[0]
    assert records.startswith("v1:")
    assert stored_item_id.startswith("v1:")
    assert item_id not in records + stored_item_id
    assert wake_at not in records
    assert "today" not in records
    assert LaterStore(path, encryption_key=PRIVATE_KEY).get("timmy") == expected
    with sqlite3.connect(path) as connection:
        connection.execute(
            "INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)",
            ("alexander", 1, records),
        )
    with pytest.raises(PrivateStateEncryptionError, match="private state could not be decrypted"):
        store.get("alexander")
    with pytest.raises(PrivateStateEncryptionError, match="private state could not be decrypted"):
        LaterStore(path, encryption_key=b"x" * 32).get("timmy")


def test_later_read_atomically_migrates_legacy_private_content_without_new_revision(tmp_path):
    path = tmp_path / "later.sqlite3"
    item_id = "issue:legacy/private:17:"
    records = {item_id: {"wake_at": "2026-08-20T08:00:00.000Z", "handoff": "today"}}
    store = LaterStore(path, encryption_key=PRIVATE_KEY)
    with sqlite3.connect(path) as connection:
        connection.execute(
            "INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)",
            ("timmy", 7, json.dumps(records)),
        )
        connection.execute(
            "INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)",
            ("timmy", item_id, 7),
        )

    assert store.get("timmy") == {"revision": 7, "records": records}
    with sqlite3.connect(path) as connection:
        migrated_plan = connection.execute(
            "SELECT revision, records FROM later_plans WHERE login = 'timmy'"
        ).fetchone()
        migrated_revision = connection.execute(
            "SELECT revision, item_id FROM later_item_revisions WHERE login = 'timmy'"
        ).fetchone()
    assert migrated_plan[0] == migrated_revision[0] == 7
    assert migrated_plan[1].startswith("v1:")
    assert migrated_revision[1].startswith("v1:")
    assert item_id not in migrated_plan[1] + migrated_revision[1]


def test_duplicate_write_migrates_legacy_later_content_without_advancing_revision(tmp_path):
    path = tmp_path / "later.sqlite3"
    item_id = "issue:legacy/private:18:"
    records = {item_id: "2026-08-21T08:00:00.000Z"}
    store = LaterStore(path, encryption_key=PRIVATE_KEY)
    with sqlite3.connect(path) as connection:
        connection.execute(
            "INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)",
            ("timmy", 4, json.dumps(records)),
        )
        connection.execute(
            "INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)",
            ("timmy", item_id, 4),
        )
        connection.execute(
            "INSERT INTO later_operations(login, operation_id, created_at) VALUES (?, ?, ?)",
            ("timmy", "already-confirmed", 1.0),
        )

    result = store.apply(
        "timmy", "already-confirmed", "restore", item_id, base_revision=4,
    )

    assert result == {"revision": 4, "records": records}
    with sqlite3.connect(path) as connection:
        stored = connection.execute(
            "SELECT records FROM later_plans WHERE login = 'timmy'"
        ).fetchone()[0]
        stored_item_id = connection.execute(
            "SELECT item_id FROM later_item_revisions WHERE login = 'timmy'"
        ).fetchone()[0]
    assert stored.startswith("v1:")
    assert stored_item_id.startswith("v1:")


def test_next_day_today_handoff_is_preserved_without_changing_legacy_deferrals(tmp_path):
    store = LaterStore(tmp_path / "later.sqlite3")

    legacy = store.apply(
        "timmy", "legacy", "defer", "issue:r:1:",
        wake_at="2026-08-17T09:00:00.000Z",
    )
    handoff = store.apply(
        "timmy", "wrap-up", "defer", "issue:r:2:",
        wake_at="2026-08-17T09:00:00.000Z",
        handoff="today",
    )

    assert legacy["records"]["issue:r:1:"] == "2026-08-17T09:00:00.000Z"
    assert handoff["records"] == {
        "issue:r:1:": "2026-08-17T09:00:00.000Z",
        "issue:r:2:": {
            "wake_at": "2026-08-17T09:00:00.000Z",
            "handoff": "today",
        },
    }
    assert LaterStore(store.path).get("timmy") == handoff


def test_initialized_later_reads_remain_available_during_a_planning_write(tmp_path):
    path = tmp_path / "later.sqlite3"
    store = LaterStore(path, timeout=0.05)
    expected = store.apply(
        "timmy", "seed", "defer", "issue:r:1:",
        wake_at="2026-08-10T09:00:00.000Z",
    )

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


def test_batch_applies_in_one_ordered_idempotent_unit(tmp_path):
    store = LaterStore(tmp_path / "later.sqlite3")
    operations = [
        {"operation_id": "first", "action": "defer", "item_id": "issue:r:1:", "wake_at": "2026-08-10T09:00:00.000Z"},
        {"operation_id": "second", "action": "restore", "item_id": "issue:r:1:"},
    ]

    result = store.apply_batch("timmy", operations)
    assert result == {
        "revision": 2,
        "records": {},
        "accepted_operation_ids": ["first", "second"],
        "duplicate_operation_ids": [],
        "rejected_operations": [],
    }
    replay = store.apply_batch("timmy", operations)
    assert replay["revision"] == 2
    assert replay["accepted_operation_ids"] == []
    assert replay["duplicate_operation_ids"] == ["first", "second"]


def test_batch_rejects_stale_same_item_intent_without_blocking_other_items(tmp_path):
    store = LaterStore(tmp_path / "later.sqlite3")
    first = store.apply_batch("timmy", [{
        "operation_id": "newer-device",
        "action": "defer",
        "item_id": "issue:r:1:",
        "wake_at": "2026-08-12T09:00:00.000Z",
        "base_revision": 0,
    }])

    replay = store.apply_batch("timmy", [
        {
            "operation_id": "stale-device",
            "action": "defer",
            "item_id": "issue:r:1:",
            "wake_at": "2026-08-10T09:00:00.000Z",
            "base_revision": 0,
        },
        {
            "operation_id": "unrelated-item",
            "action": "defer",
            "item_id": "issue:r:2:",
            "wake_at": "2026-08-11T09:00:00.000Z",
            "base_revision": 0,
        },
    ])

    assert first["revision"] == 1
    assert replay == {
        "revision": 2,
        "records": {
            "issue:r:1:": "2026-08-12T09:00:00.000Z",
            "issue:r:2:": "2026-08-11T09:00:00.000Z",
        },
        "accepted_operation_ids": ["unrelated-item"],
        "duplicate_operation_ids": [],
        "rejected_operations": [
            {"operation_id": "stale-device", "reason": "stale_intent"}
        ],
    }
    duplicate = store.apply_batch("timmy", [{
        "operation_id": "stale-device",
        "action": "restore",
        "item_id": "issue:r:2:",
        "base_revision": 2,
    }])
    assert duplicate["duplicate_operation_ids"] == ["stale-device"]
    assert duplicate["revision"] == 2
    assert duplicate["records"] == replay["records"]


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

    store = LaterStore(path, operation_limit=2, clock=lambda: 1_000)
    for index in range(4):
        store.apply(
            "timmy", f"op-{index}", "defer", f"issue:r:{index}:",
            wake_at="2026-08-10T09:00:00.000Z",
        )
    store.apply(
        "alexander", "other", "defer", "issue:r:99:",
        wake_at="2026-08-10T09:00:00.000Z",
    )

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

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


def test_later_receipt_age_pruning_is_account_scoped(tmp_path):
    clock = [1_000.0]
    path = tmp_path / "later.sqlite3"
    store = LaterStore(path, operation_retention_seconds=60, clock=lambda: clock[0])
    store.apply("timmy", "old", "restore", "issue:r:1:")
    store.apply("alexander", "other-old", "restore", "issue:r:2:")
    clock[0] += 61
    store.apply("timmy", "fresh", "restore", "issue:r:3:")

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

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


@pytest.mark.anyio
async def test_authenticated_later_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_LATER_DB", str(tmp_path / "later.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/later",
            json={
                "operation_id": "mobile-1",
                "action": "defer",
                "item_id": "issue:stackchain/dashboard:363:",
                "wake_at": "2026-08-10T09:00:00.000Z",
            },
        )
        changed = await client.patch(
            "/api/v1/later",
            json={
                "operations": [
                    {"operation_id": "mobile-1", "action": "defer", "item_id": "issue:stackchain/dashboard:363:", "wake_at": "2026-08-10T09:00:00.000Z"},
                    {"operation_id": "mobile-2", "action": "defer", "item_id": "issue:stackchain/dashboard:365:", "wake_at": "2026-08-11T09:00:00.000Z"},
                ],
            },
            headers={
                "Origin": "https://test",
                "X-CSRF-Token": client.cookies["stackchain_csrf"],
            },
        )
        stale = await client.patch(
            "/api/v1/later",
            json={
                "operations": [{
                    "operation_id": "offline-stale",
                    "action": "restore",
                    "item_id": "issue:stackchain/dashboard:363:",
                    "base_revision": 0,
                }],
            },
            headers={
                "Origin": "https://test",
                "X-CSRF-Token": client.cookies["stackchain_csrf"],
            },
        )
        fetched = await client.get("/api/v1/later")

    assert forbidden.status_code == 403
    assert changed.status_code == 200
    assert changed.json() == {
        "revision": 2,
        "records": {
            "issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z",
            "issue:stackchain/dashboard:365:": "2026-08-11T09:00:00.000Z",
        },
        "accepted_operation_ids": ["mobile-1", "mobile-2"],
        "duplicate_operation_ids": [],
        "rejected_operations": [],
    }
    assert stale.json()["rejected_operations"] == [
        {"operation_id": "offline-stale", "reason": "stale_intent"}
    ]
    assert stale.json()["revision"] == 2
    assert fetched.json() == {"revision": 2, "records": {
        "issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z",
        "issue:stackchain/dashboard:365:": "2026-08-11T09:00:00.000Z",
    }}
    assert fetched.headers["cache-control"] == "no-store"
