import threading
import os
import sqlite3

import pytest

from src import live_snapshot_store
from src.live_snapshot_store import LiveSnapshotStore, RefreshLeaseLost
from src.state_encryption import PrivateStateEncryptionError


PRIVATE_KEY = b"l" * 32


def test_published_live_snapshot_is_encrypted_at_rest_and_survives_restart(tmp_path):
    path = tmp_path / "live.sqlite3"
    store = LiveSnapshotStore(path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY)
    lease = store.try_acquire_refresh({"context"}, lease_seconds=5)
    canary = "private-live-title-canary"

    published = store.publish_refresh(
        lease,
        value={"context": {"title": canary}, "events": [], "notifications": []},
        created_at={section: 100.0 for section in live_snapshot_store.SECTIONS},
        failure_count={section: 0 for section in live_snapshot_store.SECTIONS},
        retry_at={section: None for section in live_snapshot_store.SECTIONS},
        changed_sections={"context"},
    )

    with sqlite3.connect(path) as connection:
        payload = connection.execute(
            "SELECT value_json FROM live_snapshot WHERE singleton = 1"
        ).fetchone()[0]
    assert payload.startswith("v1:")
    assert canary not in payload
    assert LiveSnapshotStore(path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY).load().value == published.value


def test_live_snapshot_lazily_migrates_plaintext_without_changing_metadata(tmp_path):
    path = tmp_path / "live.sqlite3"
    store = LiveSnapshotStore(path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY)
    legacy = {"context": {"title": "legacy"}, "events": [], "notifications": []}
    with sqlite3.connect(path) as connection:
        connection.execute(
            "UPDATE live_snapshot SET value_json = ?, revisions_json = ? WHERE singleton = 1",
            ('{"context":{"title":"legacy"},"events":[],"notifications":[]}',
             '{"context":4,"events":2,"notifications":1}'),
        )

    state = store.load()

    with sqlite3.connect(path) as connection:
        migrated = connection.execute(
            "SELECT value_json FROM live_snapshot WHERE singleton = 1"
        ).fetchone()[0]
    assert state.value == legacy
    assert state.revisions == {"context": 4, "events": 2, "notifications": 1}
    assert migrated.startswith("v1:")


def test_live_snapshot_authentication_failure_returns_no_private_content(tmp_path):
    path = tmp_path / "live.sqlite3"
    store = LiveSnapshotStore(path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY)
    lease = store.try_acquire_refresh({"context"}, lease_seconds=5)
    metadata = {section: None for section in live_snapshot_store.SECTIONS}
    store.publish_refresh(
        lease,
        value={"context": {"title": "secret"}},
        created_at=metadata,
        failure_count={section: 0 for section in metadata},
        retry_at=metadata,
        changed_sections={"context"},
    )

    with pytest.raises(PrivateStateEncryptionError, match="private state could not be decrypted"):
        LiveSnapshotStore(path, encryption_key=b"x" * 32).load()


def test_metadata_load_does_not_retrieve_or_decode_snapshot_value(tmp_path):
    store = LiveSnapshotStore(tmp_path / "live.sqlite3", clock=lambda: 100.0)
    with sqlite3.connect(store.path) as connection:
        connection.execute(
            "UPDATE live_snapshot SET value_json = ? WHERE singleton = 1",
            ("not-json",),
        )

    metadata = store.load_metadata()

    assert metadata.created_at == {
        section: None for section in ("context", "events", "notifications")
    }
    assert metadata.revisions == {
        section: 0 for section in ("context", "events", "notifications")
    }
    with pytest.raises(PrivateStateEncryptionError):
        store.load()


def test_independent_stores_racing_for_refresh_have_one_lease_winner(tmp_path):
    path = tmp_path / "live.sqlite3"
    first = LiveSnapshotStore(path, clock=lambda: 100.0)
    second = LiveSnapshotStore(path, clock=lambda: 100.0)
    barrier = threading.Barrier(2)
    results = []

    def acquire(store):
        barrier.wait()
        results.append(store.try_acquire_refresh({"context", "events"}, lease_seconds=5))

    threads = [threading.Thread(target=acquire, args=(store,)) for store in (first, second)]
    for thread in threads:
        thread.start()
    for thread in threads:
        thread.join()

    assert sum(result is not None for result in results) == 1


def test_snapshot_and_shared_revisions_publish_atomically(tmp_path):
    path = tmp_path / "live.sqlite3"
    writer = LiveSnapshotStore(path, clock=lambda: 100.0)
    reader = LiveSnapshotStore(path, clock=lambda: 100.0)
    lease = writer.try_acquire_refresh(set(("context", "events", "notifications")), lease_seconds=5)
    assert lease is not None

    state = writer.publish_refresh(
        lease,
        value={
            "context": {"generation": 1},
            "events": [],
            "notifications": [],
            "sections": {section: "fresh" for section in ("context", "events", "notifications")},
        },
        created_at={section: 100.0 for section in ("context", "events", "notifications")},
        failure_count={section: 0 for section in ("context", "events", "notifications")},
        retry_at={section: None for section in ("context", "events", "notifications")},
        changed_sections=set(("context", "events", "notifications")),
    )

    observed = reader.load()
    assert observed.value == state.value
    assert observed.revisions == {"context": 1, "events": 1, "notifications": 1}
    assert observed.refreshing_sections == set()
    with pytest.raises(RefreshLeaseLost):
        writer.publish_refresh(
            lease,
            value={"context": {"generation": 2}},
            created_at=observed.created_at,
            failure_count=observed.failure_count,
            retry_at=observed.retry_at,
            changed_sections={"context"},
        )


def test_expired_refresh_lease_can_be_recovered(tmp_path):
    now = 100.0
    path = tmp_path / "live.sqlite3"
    abandoned = LiveSnapshotStore(path, clock=lambda: now)
    recovery = LiveSnapshotStore(path, clock=lambda: now)

    first = abandoned.try_acquire_refresh({"context"}, lease_seconds=5)
    assert first is not None
    assert recovery.try_acquire_refresh({"context"}, lease_seconds=5) is None
    now = 106.0
    second = recovery.try_acquire_refresh({"context"}, lease_seconds=5)

    assert second is not None
    assert second != first


def test_default_store_clock_uses_reboot_stable_wall_time(tmp_path, monkeypatch):
    epoch = 1_700_000_000.0
    monkeypatch.setattr(live_snapshot_store.time, "time", lambda: epoch)
    store = LiveSnapshotStore(tmp_path / "live.sqlite3")

    lease = store.try_acquire_refresh({"context"}, lease_seconds=5)

    assert lease is not None
    assert store.load().lease_expires_at == epoch + 5


def test_store_is_private_and_does_not_persist_upstream_token(tmp_path, monkeypatch):
    token = "gitea-super-secret-token-material"
    monkeypatch.setenv("GITEA_TOKEN", token)
    path = tmp_path / "private-state" / "live.sqlite3"

    LiveSnapshotStore(path)

    assert os.stat(path).st_mode & 0o777 == 0o600
    assert os.stat(path.parent).st_mode & 0o777 == 0o700
    assert token.encode() not in path.read_bytes()


def test_remove_notifications_filters_batch_with_one_revision_advance(tmp_path):
    store = LiveSnapshotStore(tmp_path / "live.sqlite3", clock=lambda: 100.0)
    lease = store.try_acquire_refresh({"notifications"}, lease_seconds=5)
    assert lease is not None
    metadata = {section: None for section in ("context", "events", "notifications")}
    published = store.publish_refresh(
        lease,
        value={
            "notifications": [{"id": 7}, {"id": 8}, {"id": 9}],
            "sections": {"notifications": "fresh"},
        },
        created_at=metadata,
        failure_count={section: 0 for section in metadata},
        retry_at=metadata,
        changed_sections={"notifications"},
    )

    removed = store.remove_notifications([7, 9, 7])

    assert removed.value["notifications"] == [{"id": 8}]
    assert removed.revisions["notifications"] == published.revisions["notifications"] + 1


def test_read_notification_filter_is_shared_with_future_publications(tmp_path):
    path = tmp_path / "live.sqlite3"
    first = LiveSnapshotStore(path, clock=lambda: 100.0)
    second = LiveSnapshotStore(path, clock=lambda: 100.0)
    lease = first.try_acquire_refresh({"notifications"}, lease_seconds=5)
    assert lease is not None
    metadata = {section: None for section in ("context", "events", "notifications")}
    first.publish_refresh(
        lease,
        value={"notifications": [{"id": 7}, {"id": 8}], "sections": {"notifications": "fresh"}},
        created_at=metadata,
        failure_count={section: 0 for section in metadata},
        retry_at=metadata,
        changed_sections={"notifications"},
    )

    removed = second.remove_notification(7)
    lease = first.try_acquire_refresh({"notifications"}, lease_seconds=5)
    assert lease is not None
    republished = first.publish_refresh(
        lease,
        value={"notifications": [{"id": 7}, {"id": 8}], "sections": {"notifications": "fresh"}},
        created_at=metadata,
        failure_count={section: 0 for section in metadata},
        retry_at=metadata,
        changed_sections={"notifications"},
    )

    assert removed.value["notifications"] == [{"id": 8}]
    assert republished.value["notifications"] == [{"id": 8}]
