"""Tests for `hermes checkpoints prune`'s orphan confirmation flow.

Covers the P1 raised on PR #69141: the confirmation preview must cover
BOTH v2 projects (`store_status()["projects"]`) and pre-v2 shadow repos
(`store_status()["pre_v2_projects"]`), since `prune_checkpoints()` deletes
orphans from both layouts. Exercises decline / accept / --force across
pre-v2-only and mixed (v2 + pre-v2) stores.
"""

from __future__ import annotations

import argparse

import pytest


def _ns(**kwargs) -> argparse.Namespace:
    defaults = {"retention_days": 7, "max_size_mb": 500, "keep_orphans": False, "force": False}
    defaults.update(kwargs)
    return argparse.Namespace(**defaults)


def _prune_result(**kwargs) -> dict:
    result = {"scanned": 0, "deleted_orphan": 0, "deleted_stale": 0, "errors": 0, "bytes_freed": 0}
    result.update(kwargs)
    return result


_V2_ORPHAN_ONLY_STATUS = {
    "projects": [],
    "pre_v2_projects": [],
}

_PRE_V2_ONLY_STATUS = {
    "projects": [],
    "pre_v2_projects": [
        {"path": "/home/user/.hermes/checkpoints/deadbeefcafebabe", "workdir": None, "exists": False},
    ],
}

_MIXED_STATUS = {
    "projects": [
        {"hash": "abc123", "workdir": "/gone/v2-project", "exists": False, "commits": 4},
    ],
    "pre_v2_projects": [
        {"path": "/home/user/.hermes/checkpoints/deadbeefcafebabe", "workdir": "/gone/pre-v2-project", "exists": False},
    ],
}


def _patch_checkpoint_manager(monkeypatch, status: dict, prune_calls: list):
    import tools.checkpoint_manager as ckpt_mgr

    monkeypatch.setattr(ckpt_mgr, "store_status", lambda *a, **k: status)

    def _fake_prune(**kwargs):
        prune_calls.append(kwargs)
        return _prune_result(
            deleted_orphan=len(status["projects"]) + len(status["pre_v2_projects"]),
        )

    monkeypatch.setattr(ckpt_mgr, "prune_checkpoints", _fake_prune)


# ─── pre-v2-only store ──────────────────────────────────────────────────────


def test_pre_v2_only_decline_aborts_without_deleting(monkeypatch, capsys):
    import hermes_cli.checkpoints as checkpoints_cli

    prune_calls: list = []
    _patch_checkpoint_manager(monkeypatch, _PRE_V2_ONLY_STATUS, prune_calls)
    monkeypatch.setattr("builtins.input", lambda _prompt: "n")

    rc = checkpoints_cli.cmd_prune(_ns())

    assert rc == 1
    assert prune_calls == []
    out = capsys.readouterr().out
    assert "pre-v2 shadow repo" in out
    assert "Aborted" in out


def test_pre_v2_only_accept_deletes(monkeypatch, capsys):
    import hermes_cli.checkpoints as checkpoints_cli

    prune_calls: list = []
    _patch_checkpoint_manager(monkeypatch, _PRE_V2_ONLY_STATUS, prune_calls)
    monkeypatch.setattr("builtins.input", lambda _prompt: "y")

    rc = checkpoints_cli.cmd_prune(_ns())

    assert rc == 0
    assert len(prune_calls) == 1
    assert prune_calls[0]["delete_orphans"] is True


def test_pre_v2_only_force_skips_prompt(monkeypatch, capsys):
    import hermes_cli.checkpoints as checkpoints_cli

    prune_calls: list = []
    _patch_checkpoint_manager(monkeypatch, _PRE_V2_ONLY_STATUS, prune_calls)

    def _unexpected_input(_prompt):
        raise AssertionError("input() must not be called when --force is passed")

    monkeypatch.setattr("builtins.input", _unexpected_input)

    rc = checkpoints_cli.cmd_prune(_ns(force=True))

    assert rc == 0
    assert len(prune_calls) == 1


# ─── mixed store (v2 + pre-v2) ──────────────────────────────────────────────


def test_mixed_store_decline_aborts_without_deleting(monkeypatch, capsys):
    import hermes_cli.checkpoints as checkpoints_cli

    prune_calls: list = []
    _patch_checkpoint_manager(monkeypatch, _MIXED_STATUS, prune_calls)
    monkeypatch.setattr("builtins.input", lambda _prompt: "n")

    rc = checkpoints_cli.cmd_prune(_ns())

    assert rc == 1
    assert prune_calls == []
    out = capsys.readouterr().out
    # Both layouts must appear in the preview, not just the v2 one.
    assert "/gone/v2-project" in out
    assert "/gone/pre-v2-project" in out
    assert "This will permanently delete 2 orphan checkpoint project(s)" in out


def test_mixed_store_accept_deletes_both_layouts(monkeypatch, capsys):
    import hermes_cli.checkpoints as checkpoints_cli

    prune_calls: list = []
    _patch_checkpoint_manager(monkeypatch, _MIXED_STATUS, prune_calls)
    monkeypatch.setattr("builtins.input", lambda _prompt: "y")

    rc = checkpoints_cli.cmd_prune(_ns())

    assert rc == 0
    assert len(prune_calls) == 1
    out = capsys.readouterr().out
    assert "Deleted orphan:  2" in out


def test_mixed_store_force_skips_prompt_deletes_both(monkeypatch, capsys):
    import hermes_cli.checkpoints as checkpoints_cli

    prune_calls: list = []
    _patch_checkpoint_manager(monkeypatch, _MIXED_STATUS, prune_calls)

    def _unexpected_input(_prompt):
        raise AssertionError("input() must not be called when --force is passed")

    monkeypatch.setattr("builtins.input", _unexpected_input)

    rc = checkpoints_cli.cmd_prune(_ns(force=True))

    assert rc == 0
    assert len(prune_calls) == 1
    assert prune_calls[0]["delete_orphans"] is True


# ─── --keep-orphans skips the prompt entirely, on either layout ───────────


@pytest.mark.parametrize("status", [_PRE_V2_ONLY_STATUS, _MIXED_STATUS], ids=["pre_v2_only", "mixed"])
def test_keep_orphans_skips_prompt(monkeypatch, capsys, status):
    import hermes_cli.checkpoints as checkpoints_cli

    prune_calls: list = []
    _patch_checkpoint_manager(monkeypatch, status, prune_calls)

    def _unexpected_input(_prompt):
        raise AssertionError("input() must not be called when --keep-orphans is passed")

    monkeypatch.setattr("builtins.input", _unexpected_input)

    rc = checkpoints_cli.cmd_prune(_ns(keep_orphans=True))

    assert rc == 0
    assert len(prune_calls) == 1
    assert prune_calls[0]["delete_orphans"] is False


# ─── no orphans present: never prompts even without --force ───────────────


def test_no_orphans_skips_prompt(monkeypatch, capsys):
    import hermes_cli.checkpoints as checkpoints_cli

    prune_calls: list = []
    _patch_checkpoint_manager(monkeypatch, _V2_ORPHAN_ONLY_STATUS, prune_calls)

    def _unexpected_input(_prompt):
        raise AssertionError("input() must not be called when there are no orphans")

    monkeypatch.setattr("builtins.input", _unexpected_input)

    rc = checkpoints_cli.cmd_prune(_ns())

    assert rc == 0
    assert len(prune_calls) == 1


# ─── allowlist binding: preview set == deletion set, even when empty ───────


def test_empty_preview_binds_empty_allowlist(monkeypatch, capsys):
    """Zero-orphan-preview timing regression (PR #69141 review).

    When the non-force preview shows zero orphans, no prompt runs — but the
    later rescan inside prune_checkpoints() may discover a project that
    became orphaned *after* the preview. That undisplayed, unconfirmed orphan
    must not be deletable: the allowlist passed down must be the exact
    (empty) displayed set, never the unrestricted None sentinel.
    """
    import hermes_cli.checkpoints as checkpoints_cli

    prune_calls: list = []
    _patch_checkpoint_manager(monkeypatch, _V2_ORPHAN_ONLY_STATUS, prune_calls)

    rc = checkpoints_cli.cmd_prune(_ns())

    assert rc == 0
    assert len(prune_calls) == 1
    assert prune_calls[0]["orphan_allowlist"] == set()


def test_nonempty_preview_allowlist_matches_displayed_set(monkeypatch, capsys):
    import hermes_cli.checkpoints as checkpoints_cli

    prune_calls: list = []
    _patch_checkpoint_manager(monkeypatch, _MIXED_STATUS, prune_calls)
    monkeypatch.setattr("builtins.input", lambda _prompt: "y")

    rc = checkpoints_cli.cmd_prune(_ns())

    assert rc == 0
    assert len(prune_calls) == 1
    assert prune_calls[0]["orphan_allowlist"] == {
        "abc123",
        "/home/user/.hermes/checkpoints/deadbeefcafebabe",
    }


def test_force_leaves_allowlist_unrestricted(monkeypatch, capsys):
    import hermes_cli.checkpoints as checkpoints_cli

    prune_calls: list = []
    _patch_checkpoint_manager(monkeypatch, _MIXED_STATUS, prune_calls)

    rc = checkpoints_cli.cmd_prune(_ns(force=True))

    assert rc == 0
    assert len(prune_calls) == 1
    assert prune_calls[0]["orphan_allowlist"] is None
