"""
Tests for scripts/state_store.py

Covers tasks 3.1–3.8 of the add-deterministic-core change.

sys.path is patched here (no shared conftest) so the test file is
self-contained, as required by the file-ownership constraints.
"""

from __future__ import annotations

import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import patch

import pytest

# ---------------------------------------------------------------------------
# Path bootstrap — inject scripts/ so we can import state_store directly.
# ---------------------------------------------------------------------------
_SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
if str(_SCRIPTS_DIR) not in sys.path:
    sys.path.insert(0, str(_SCRIPTS_DIR))

from state_store import (  # noqa: E402
    StateStore,
    resolve_project_root,
)


# ---------------------------------------------------------------------------
# Fake filesystem for root-resolution tests (no real disk needed)
# ---------------------------------------------------------------------------

class FakeFS:
    """
    Simulates a directory tree for resolve_project_root tests.

    *git_roots* is a set of Path objects that "contain" a .git directory.
    """

    def __init__(self, git_roots: set[Path]):
        self._git_roots = git_roots

    def is_dir(self, path: Path) -> bool:
        # path is something like /project/.git or /project/sub/.git
        if path.name == ".git":
            return path.parent in self._git_roots
        return True  # every other dir "exists"

    def parent(self, path: Path) -> Path:
        return path.parent


# ===========================================================================
# Task 3.1 — resolve_project_root
# ===========================================================================

class TestResolveProjectRoot:

    def test_git_root_found_at_start_dir(self):
        """start_dir itself contains .git → return start_dir."""
        root = Path("/project")
        fs = FakeFS(git_roots={root})
        assert resolve_project_root(root, fs) == root

    def test_git_root_found_in_ancestor(self):
        """start_dir is nested; ancestor contains .git → return ancestor."""
        root = Path("/project")
        nested = root / "src" / "module"
        fs = FakeFS(git_roots={root})
        assert resolve_project_root(nested, fs) == root

    def test_fallback_to_cwd_when_no_git(self):
        """No ancestor has .git → return start_dir (cwd semantics)."""
        start = Path("/no/git/here")
        fs = FakeFS(git_roots=set())
        result = resolve_project_root(start, fs)
        assert result == start

    def test_pure_no_os_getcwd_called(self, monkeypatch):
        """The pure function must never call os.getcwd() internally."""
        calls = []
        monkeypatch.setattr(os, "getcwd", lambda: calls.append(1) or "/spy")
        root = Path("/project")
        fs = FakeFS(git_roots={root})
        resolve_project_root(root / "deep", fs)
        assert calls == [], "resolve_project_root must not call os.getcwd()"

    def test_git_root_not_intermediate_sibling(self):
        """Only ancestor .git directories count, not siblings."""
        # /project has .git but we start at /other — should fall back to /other
        root = Path("/project")
        start = Path("/other/sub")
        fs = FakeFS(git_roots={root})
        assert resolve_project_root(start, fs) == start


# ===========================================================================
# Task 3.2 — StateStore confines writes; no global index; no .gitignore edits
# ===========================================================================

class TestWriteConfinement:

    def test_all_writes_under_dochygiene(self, tmp_path):
        """Every path written by the store is under .cc-os/dochygiene/."""
        (tmp_path / ".git").mkdir()
        store = StateStore(project_root=tmp_path)

        written_paths: list[Path] = []

        original_replace = os.replace

        def recording_replace(src, dst):
            written_paths.append(Path(dst))
            return original_replace(src, dst)

        with patch("os.replace", side_effect=recording_replace):
            store.set_last_check()
            store.set_last_reminded()
            store.write_report('{"ok": true}', "# Report\nDone")

        state_dir = tmp_path / ".cc-os" / "dochygiene"
        for p in written_paths:
            assert str(p).startswith(str(state_dir)), (
                f"Write escaped .cc-os/dochygiene/: {p}"
            )

    def test_no_global_index_created(self, tmp_path, monkeypatch):
        """Operating on one project must not create anything in ~/ or /tmp.

        HOME is redirected to an empty temp dir: asserting on the user's real
        home is flaky — other tools (e.g. os-backlog's projects registry)
        legitimately create ~/.cc-os outside this plugin's control.
        """
        fake_home = tmp_path / "home"
        fake_home.mkdir()
        monkeypatch.setenv("HOME", str(fake_home))

        project = tmp_path / "project"
        project.mkdir()
        (project / ".git").mkdir()
        store = StateStore(project_root=project)
        store.set_last_check()
        store.write_report("{}", "md")

        home_dh = fake_home / ".dochygiene"
        home_cc_os = fake_home / ".cc-os"
        assert not home_dh.exists(), "Global ~/.dochygiene must not be created"
        assert not home_cc_os.exists(), "Global ~/.cc-os must not be created"

    def test_gitignore_never_opened_or_written(self, tmp_path):
        """
        .gitignore must never be opened or written.

        Strategy: pre-create .gitignore with sentinel content, run every store
        operation, assert bytes are unchanged AND no write path ends in .gitignore.
        """
        (tmp_path / ".git").mkdir()
        gitignore = tmp_path / ".gitignore"
        sentinel = b"# sentinel -- must not change\n"
        gitignore.write_bytes(sentinel)

        written_paths: list[Path] = []
        original_replace = os.replace

        def recording_replace(src, dst):
            written_paths.append(Path(dst))
            return original_replace(src, dst)

        with patch("os.replace", side_effect=recording_replace):
            store = StateStore(project_root=tmp_path)
            store.set_last_check()
            store.set_last_clean()
            store.set_last_reminded()
            store.write_report("{}", "md")

        # Byte-identical sentinel check
        assert gitignore.read_bytes() == sentinel, ".gitignore content was altered"

        # No write path touches .gitignore
        for p in written_paths:
            assert p.name != ".gitignore", f"Write went to .gitignore: {p}"

    def test_assert_confined_raises_for_out_of_root_path(self, tmp_path):
        """_assert_confined must raise ValueError for a path outside .cc-os/dochygiene."""
        store = StateStore(project_root=tmp_path)
        with pytest.raises(ValueError, match="outside .cc-os/dochygiene"):
            store._assert_confined(tmp_path / "other" / "file.txt")


# ===========================================================================
# Task 3.3 — Lifecycle timestamps (via real disk; injected clock)
# ===========================================================================

class FrozenClock:
    def __init__(self, dt: datetime):
        self._dt = dt

    def now(self) -> datetime:
        return self._dt


class TestTimestamps:

    def test_timestamp_round_trip(self, tmp_path):
        """Write a timestamp; read it back; values match."""
        fixed = datetime(2026, 6, 18, 12, 0, 0, tzinfo=timezone.utc)
        store = StateStore(project_root=tmp_path, clock=FrozenClock(fixed))
        store.set_last_check(fixed)
        result = store.get_last_check()
        assert result == fixed

    def test_absent_timestamp_is_none(self, tmp_path):
        """Reading an absent timestamp returns None (not an error)."""
        store = StateStore(project_root=tmp_path)
        assert store.get_last_check() is None
        assert store.get_last_clean() is None
        assert store.get_last_reminded() is None

    def test_all_three_timestamps_coexist(self, tmp_path):
        """All three timestamps can be written and read independently."""
        t1 = datetime(2026, 1, 1, tzinfo=timezone.utc)
        t2 = datetime(2026, 3, 1, tzinfo=timezone.utc)
        t3 = datetime(2026, 6, 1, tzinfo=timezone.utc)
        store = StateStore(project_root=tmp_path)
        store.set_last_check(t1)
        store.set_last_clean(t2)
        store.set_last_reminded(t3)
        assert store.get_last_check() == t1
        assert store.get_last_clean() == t2
        assert store.get_last_reminded() == t3

    def test_clock_now_used_when_no_value_given(self, tmp_path):
        """set_timestamp without an explicit value uses the injected clock."""
        fixed = datetime(2026, 6, 18, 9, 30, 0, tzinfo=timezone.utc)
        clock = FrozenClock(fixed)
        store = StateStore(project_root=tmp_path, clock=clock)
        store.set_last_reminded()  # no explicit value → clock.now()
        assert store.get_last_reminded() == fixed

    def test_missing_state_file_reads_as_unset(self, tmp_path):
        """If state.json does not exist, all timestamps are None."""
        store = StateStore(project_root=tmp_path)
        # Do not call any setter — state.json is absent.
        assert store.get_timestamp("last_check") is None

    def test_corrupt_state_file_reads_as_unset(self, tmp_path):
        """A corrupt state.json does not raise — timestamps read as None."""
        state_dir = tmp_path / ".cc-os" / "dochygiene"
        state_dir.mkdir(parents=True)
        (state_dir / "state.json").write_bytes(b"not json {{")
        store = StateStore(project_root=tmp_path)
        assert store.get_last_check() is None

    def test_invalid_timestamp_key_raises(self, tmp_path):
        store = StateStore(project_root=tmp_path)
        with pytest.raises(ValueError, match="Unknown timestamp key"):
            store.get_timestamp("last_synced")


# ===========================================================================
# Task 3.4 — Atomic writes
# ===========================================================================

class TestAtomicWrites:

    def test_temp_file_in_same_directory(self, tmp_path):
        """
        The temp file must be created in the same directory as the target,
        not in /tmp — os.replace is only atomic within one filesystem.
        """
        store = StateStore(project_root=tmp_path)
        state_dir = tmp_path / ".cc-os" / "dochygiene"

        temp_paths_seen: list[Path] = []
        original_replace = os.replace

        def spy_replace(src, dst):
            temp_paths_seen.append(Path(src))
            return original_replace(src, dst)

        with patch("os.replace", side_effect=spy_replace):
            store.set_last_check()

        assert temp_paths_seen, "os.replace was never called"
        for tp in temp_paths_seen:
            assert tp.parent == state_dir, (
                f"Temp file {tp} is not in state_dir {state_dir}"
            )

    def test_torn_write_safety(self, tmp_path):
        """
        Prove the atomic-write mechanism:
        At the moment os.replace is called, the *source* (temp) holds the complete
        new content and the *destination* holds either the complete old content or
        does not exist — never a partial write.

        We monkeypatch os.replace to intercept the call and check both invariants.
        """
        store = StateStore(project_root=tmp_path)
        state_path = tmp_path / ".cc-os" / "dochygiene" / "state.json"

        # Write an initial "old" state.
        store.set_last_check(datetime(2026, 1, 1, tzinfo=timezone.utc))
        old_bytes = state_path.read_bytes()

        observed_src_content: list[bytes] = []
        observed_dst_content: list[bytes | None] = []
        original_replace = os.replace

        def intercepting_replace(src, dst):
            # Record source content (must be complete new content).
            observed_src_content.append(Path(src).read_bytes())
            # Record destination content (must be old-complete or non-existent).
            dst_path = Path(dst)
            if dst_path.exists():
                observed_dst_content.append(dst_path.read_bytes())
            else:
                observed_dst_content.append(None)
            return original_replace(src, dst)

        with patch("os.replace", side_effect=intercepting_replace):
            store.set_last_clean(datetime(2026, 6, 1, tzinfo=timezone.utc))

        assert observed_src_content, "os.replace was not called"

        for src_bytes in observed_src_content:
            # Source must be valid JSON (complete new write).
            parsed = json.loads(src_bytes)
            assert isinstance(parsed, dict)

        for dst_bytes in observed_dst_content:
            if dst_bytes is not None:
                # Destination must be the old complete content.
                assert dst_bytes == old_bytes, (
                    "Destination was not the prior complete state at replace time"
                )
            # None means no prior file — valid (first write).

    def test_no_direct_truncate_write_to_target(self, tmp_path):
        """
        The store must never open the target in 'w'/'wb' mode directly —
        all writes go through the temp-then-replace path.
        """
        store = StateStore(project_root=tmp_path)
        state_dir = tmp_path / ".cc-os" / "dochygiene"
        state_dir.mkdir(parents=True, exist_ok=True)
        state_target = state_dir / "state.json"

        opened_paths_with_write_mode: list[str] = []
        real_open = open

        def spying_open(file, mode="r", *args, **kwargs):
            if isinstance(mode, str) and ("w" in mode or "x" in mode):
                opened_paths_with_write_mode.append(str(file))
            return real_open(file, mode, *args, **kwargs)

        with patch("builtins.open", side_effect=spying_open):
            store.set_last_check()

        for p in opened_paths_with_write_mode:
            assert Path(p) != state_target, (
                f"Target {state_target} was opened directly in write mode"
            )


# ===========================================================================
# Task 3.5 — Report rollover
# ===========================================================================

class TestReportRollover:

    def _report_files(self, tmp_path: Path) -> list[Path]:
        state_dir = tmp_path / ".cc-os" / "dochygiene"
        return sorted(state_dir.glob("report.*"))

    def test_first_write_creates_exactly_one_pair(self, tmp_path):
        store = StateStore(project_root=tmp_path)
        store.write_report('{"run": 1}', "# Run 1")
        files = self._report_files(tmp_path)
        names = {f.name for f in files}
        assert names == {"report.json", "report.md"}

    def test_second_write_replaces_first(self, tmp_path):
        store = StateStore(project_root=tmp_path)
        store.write_report('{"run": 1}', "# Run 1")
        store.write_report('{"run": 2}', "# Run 2")

        files = self._report_files(tmp_path)
        names = {f.name for f in files}
        assert names == {"report.json", "report.md"}, (
            f"Expected exactly one pair, found: {names}"
        )
        json_content = json.loads((tmp_path / ".cc-os" / "dochygiene" / "report.json").read_text())
        assert json_content["run"] == 2, "Old report JSON was not replaced"
        md_content = (tmp_path / ".cc-os" / "dochygiene" / "report.md").read_text()
        assert "Run 2" in md_content, "Old report MD was not replaced"

    def test_many_writes_leave_exactly_one_pair(self, tmp_path):
        store = StateStore(project_root=tmp_path)
        for i in range(10):
            store.write_report(f'{{"run": {i}}}', f"# Run {i}")

        files = self._report_files(tmp_path)
        names = {f.name for f in files}
        assert names == {"report.json", "report.md"}

    def test_rollover_does_not_delete_state_json(self, tmp_path):
        """Report rollover must NEVER delete state.json (critical trap #1)."""
        store = StateStore(project_root=tmp_path)
        store.set_last_check(datetime(2026, 6, 1, tzinfo=timezone.utc))

        state_before = (tmp_path / ".cc-os" / "dochygiene" / "state.json").read_bytes()

        store.write_report("{}", "md")
        store.write_report("{}", "md2")  # second rollover

        state_after = (tmp_path / ".cc-os" / "dochygiene" / "state.json").read_bytes()
        assert state_before == state_after, (
            "state.json was modified or deleted during report rollover"
        )

    def test_report_content_survives_rollover(self, tmp_path):
        """After a rollover the latest content is readable."""
        store = StateStore(project_root=tmp_path)
        store.write_report('{"v": "first"}', "# First")
        store.write_report('{"v": "second"}', "# Second")
        pair = store.read_report()
        assert pair is not None
        json_blob, md_blob = pair
        assert json.loads(json_blob)["v"] == "second"
        assert "Second" in md_blob

    def test_read_report_returns_none_when_absent(self, tmp_path):
        store = StateStore(project_root=tmp_path)
        assert store.read_report() is None


# ===========================================================================
# Task 3.6 — Root resolution: write confinement (no path outside root written)
# ===========================================================================

class TestRootResolutionConfinement:

    def test_writes_confined_after_git_root_resolution(self, tmp_path):
        """
        Scenario: git-root case — writes are under <git-root>/.cc-os/dochygiene/.
        """
        (tmp_path / ".git").mkdir()
        store = StateStore(project_root=tmp_path)
        store.set_last_check()
        store.write_report("{}", "md")

        state_dir = tmp_path / ".cc-os" / "dochygiene"
        for child in state_dir.iterdir():
            # All files live inside .cc-os/dochygiene
            assert child.parent == state_dir

        # Nothing written outside the project root
        cc_os_dir = tmp_path / ".cc-os"
        unexpected = [
            p for p in tmp_path.iterdir()
            if p != cc_os_dir and p.name != ".git"
        ]
        assert not unexpected, f"Unexpected files outside .cc-os/dochygiene: {unexpected}"

    def test_writes_confined_in_cwd_fallback(self, tmp_path):
        """
        Scenario: cwd-fallback case — no .git anywhere, store still confines writes.
        """
        store = StateStore(project_root=tmp_path)  # no .git created
        store.set_last_reminded()

        state_dir = tmp_path / ".cc-os" / "dochygiene"
        assert state_dir.exists()
        for child in state_dir.iterdir():
            assert child.parent == state_dir

    def test_gitignore_not_edited_when_creating_state_dir(self, tmp_path):
        """
        Creating .cc-os/dochygiene/ for the first time must not touch .gitignore.
        """
        (tmp_path / ".git").mkdir()
        gitignore = tmp_path / ".gitignore"
        sentinel = b"*.pyc\n"
        gitignore.write_bytes(sentinel)

        store = StateStore(project_root=tmp_path)
        store.set_last_check()  # triggers _ensure_state_dir internally

        assert gitignore.read_bytes() == sentinel


# ===========================================================================
# Fixtures under tests/fixtures/state_store/ are used as doc-tree inputs
# for integration smoke tests below.
# ===========================================================================

class TestFixtures:
    """
    Smoke-test that the fixture directory exists and the store works on a
    fixture-style project layout (a directory with .git).
    """

    FIXTURE_DIR = Path(__file__).parent / "fixtures" / "state_store"

    def test_fixture_dir_exists(self):
        assert self.FIXTURE_DIR.exists(), (
            f"Fixture dir missing: {self.FIXTURE_DIR}"
        )

    def test_store_works_in_fixture_project(self):
        fixture_proj = self.FIXTURE_DIR / "sample_project"
        assert fixture_proj.exists(), (
            f"sample_project fixture missing: {fixture_proj}"
        )
        # Use the fixture as a read-only project root; write state into a tmp copy.
        import tempfile, shutil
        with tempfile.TemporaryDirectory() as td:
            proj = Path(td) / "proj"
            shutil.copytree(fixture_proj, proj)
            store = StateStore(project_root=proj)
            store.set_last_check(datetime(2026, 6, 18, tzinfo=timezone.utc))
            assert store.get_last_check() == datetime(2026, 6, 18, tzinfo=timezone.utc)


# ===========================================================================
# ADR-027 — legacy .dochygiene/ fallback reads + auto-migration on first write
# ===========================================================================

class TestLegacyFallbackAndMigration:

    def _write_legacy_state(self, tmp_path: Path, state: dict) -> Path:
        legacy_dir = tmp_path / ".dochygiene"
        legacy_dir.mkdir(parents=True, exist_ok=True)
        (legacy_dir / "state.json").write_text(json.dumps(state))
        return legacy_dir

    def _write_legacy_report(self, tmp_path: Path, json_blob: str, md_blob: str) -> Path:
        legacy_dir = tmp_path / ".dochygiene"
        legacy_dir.mkdir(parents=True, exist_ok=True)
        (legacy_dir / "report.json").write_text(json_blob)
        (legacy_dir / "report.md").write_text(md_blob)
        return legacy_dir

    def test_reads_fall_back_to_legacy_timestamp(self, tmp_path):
        """A timestamp written only under legacy .dochygiene/ is still readable."""
        fixed = datetime(2026, 6, 18, 12, 0, 0, tzinfo=timezone.utc)
        self._write_legacy_state(tmp_path, {"last_check": fixed.isoformat()})

        store = StateStore(project_root=tmp_path)
        assert store.get_last_check() == fixed
        # Canonical dir must NOT have been created by a pure read.
        assert not (tmp_path / ".cc-os").exists()

    def test_reads_fall_back_to_legacy_report(self, tmp_path):
        """A report written only under legacy .dochygiene/ is still readable."""
        self._write_legacy_report(tmp_path, '{"v": "legacy"}', "# Legacy")

        store = StateStore(project_root=tmp_path)
        pair = store.read_report()
        assert pair is not None
        json_blob, md_blob = pair
        assert json.loads(json_blob)["v"] == "legacy"
        assert "Legacy" in md_blob
        assert not (tmp_path / ".cc-os").exists()

    def test_canonical_state_takes_precedence_over_legacy(self, tmp_path):
        """If both canonical and legacy state exist, canonical wins."""
        legacy_ts = datetime(2026, 1, 1, tzinfo=timezone.utc)
        canonical_ts = datetime(2026, 6, 1, tzinfo=timezone.utc)
        self._write_legacy_state(tmp_path, {"last_check": legacy_ts.isoformat()})

        store = StateStore(project_root=tmp_path)
        store.set_last_check(canonical_ts)  # triggers migration + then overwrites
        assert store.get_last_check() == canonical_ts

    def test_first_write_migrates_legacy_state_and_removes_legacy_dir(self, tmp_path):
        """
        First write triggers migration: pre-existing legacy files are copied into
        the canonical dir, and the legacy dir is removed entirely.
        """
        legacy_ts = datetime(2026, 1, 1, tzinfo=timezone.utc)
        self._write_legacy_state(tmp_path, {"last_check": legacy_ts.isoformat()})
        self._write_legacy_report(tmp_path, '{"v": "legacy"}', "# Legacy")

        store = StateStore(project_root=tmp_path)
        store.set_last_clean(datetime(2026, 6, 1, tzinfo=timezone.utc))

        legacy_dir = tmp_path / ".dochygiene"
        canonical_dir = tmp_path / ".cc-os" / "dochygiene"

        assert not legacy_dir.exists(), "Legacy .dochygiene/ must be removed after migration"
        assert canonical_dir.is_dir()
        # Migrated last_check must be preserved alongside the newly-written last_clean.
        assert store.get_last_check() == legacy_ts
        assert store.get_last_clean() == datetime(2026, 6, 1, tzinfo=timezone.utc)
        # Migrated report is preserved too.
        pair = store.read_report()
        assert pair is not None
        assert json.loads(pair[0])["v"] == "legacy"

    def test_migration_does_not_clobber_existing_canonical_files(self, tmp_path):
        """
        If the canonical dir already has a file with the same name as a legacy
        file, the canonical file wins (never overwritten by the legacy copy).
        """
        canonical_dir = tmp_path / ".cc-os" / "dochygiene"
        canonical_dir.mkdir(parents=True)
        (canonical_dir / "state.json").write_text(
            json.dumps({"last_check": "2026-06-01T00:00:00+00:00"})
        )
        self._write_legacy_state(tmp_path, {"last_check": "2026-01-01T00:00:00+00:00"})

        store = StateStore(project_root=tmp_path)
        # Any write triggers the migration pass.
        store.set_last_reminded(datetime(2026, 7, 1, tzinfo=timezone.utc))

        assert not (tmp_path / ".dochygiene").exists()
        assert store.get_last_check() == datetime(2026, 6, 1, tzinfo=timezone.utc)

    def test_new_project_with_no_legacy_dir_is_unaffected(self, tmp_path):
        """No legacy dir present → normal operation, no migration attempted."""
        store = StateStore(project_root=tmp_path)
        store.set_last_check(datetime(2026, 6, 1, tzinfo=timezone.utc))
        assert not (tmp_path / ".dochygiene").exists()
        assert (tmp_path / ".cc-os" / "dochygiene" / "state.json").is_file()
