"""Tests for `ablate apply`.

This is the only command that edits a user's file, so the tests are weighted
toward what it must refuse to do and what must remain recoverable afterwards.
"""

from __future__ import annotations

from datetime import UTC, datetime
from pathlib import Path

import pytest
from click.testing import CliRunner

from ablate.ablation import plan_apply, write_apply
from ablate.cli import main
from ablate.config.parse import KEEP_MARKER, split_sections
from ablate.errors import AblateError

BODY = (
    "Guidance about how to write code in this repository, stated at enough "
    "length that it clears the small-section merge threshold comfortably. "
)


def _config(pinned: set[int] | None = None, n: int = 4) -> str:
    pinned = pinned or set()
    parts = []
    for i in range(n):
        marker = f"{KEEP_MARKER}\n" if i in pinned else ""
        parts.append(f"## Section {i}\n\n{marker}{BODY}\n")
    return "\n".join(parts)


def _plan(text: str, drop, **kw):
    return plan_apply(
        text,
        split_sections(text),
        drop,
        config_path="CLAUDE.md",
        command=f"ablate apply --drop {','.join(drop)} CLAUDE.md",
        commit="3f9a1c2",
        version="0.3.0",
        now=datetime(2026, 7, 27, 12, 0, tzinfo=UTC),
        **kw,
    )


# --- the edit ---------------------------------------------------------------


def test_dropped_sections_leave_the_file():
    text = _config()
    plan = _plan(text, ["s02"])
    assert "Section 1" not in plan.updated
    assert "Section 0" in plan.updated
    assert "Section 2" in plan.updated


def test_untouched_lines_are_byte_identical():
    """Re-rendering from parsed sections would normalize whitespace across the
    whole file, turning a two-section deletion into a diff touching every line."""
    text = "## A\n\n" + BODY + "\n\n\n## B\n\n" + BODY + "\n   \n## C\n\n" + BODY + "\n"
    plan = _plan(text, ["s02"])
    assert plan.updated.startswith("## A\n\n" + BODY + "\n\n\n")
    assert plan.updated.endswith("## C\n\n" + BODY + "\n")


def test_dropping_several_sections_at_once():
    plan = _plan(_config(), ["s01", "s03"])
    assert "Section 0" not in plan.updated
    assert "Section 2" not in plan.updated
    assert "Section 1" in plan.updated
    assert len(plan.dropped) == 2


def test_duplicate_ids_are_collapsed():
    assert len(_plan(_config(), ["s01", "s01"]).dropped) == 1


def test_tokens_saved_is_reported():
    plan = _plan(_config(), ["s01"])
    assert plan.tokens_saved == plan.dropped[0].tokens
    assert plan.lines_removed > 0


def test_dropping_everything_is_flagged():
    plan = _plan(_config(n=2), ["s01", "s02"])
    assert plan.removes_everything
    assert plan.updated.strip() == ""


# --- refusals ---------------------------------------------------------------


def test_pinned_sections_cannot_be_dropped():
    """The escape hatch would be worthless if `apply` could override it."""
    with pytest.raises(AblateError, match="pinned"):
        _plan(_config(pinned={1}), ["s02"])


def test_unknown_section_id_lists_the_real_ones():
    with pytest.raises(AblateError, match="s01"):
        _plan(_config(), ["s99"])


def test_unknown_id_mentions_granularity():
    """The commonest cause is ablating at bullet granularity and applying at
    section granularity, where the ids mean different things."""
    with pytest.raises(AblateError, match="granularity"):
        _plan(_config(), ["s99"])


def test_dropping_nothing_is_an_error():
    with pytest.raises(AblateError, match="nothing to drop"):
        _plan(_config(), [])


# --- the archive ------------------------------------------------------------


def test_archive_contains_every_removed_section_verbatim():
    text = _config()
    plan = _plan(text, ["s01", "s03"])
    for section in plan.dropped:
        assert section.text.strip() in plan.archive


def test_archive_header_records_provenance():
    plan = _plan(_config(), ["s01"])
    assert "CLAUDE.md" in plan.archive
    assert "3f9a1c2" in plan.archive
    assert "ablate apply --drop s01" in plan.archive
    assert "0.3.0" in plan.archive
    assert "To restore" in plan.archive


def test_archive_names_each_section_with_its_line_range():
    plan = _plan(_config(), ["s02"])
    section = plan.dropped[0]
    assert f"lines {section.start_line}-{section.end_line}" in plan.archive


def test_archive_path_is_timestamped():
    plan = _plan(_config(), ["s01"])
    assert plan.archive_path == ".ablate/removed/2026-07-27T12-00-00Z.md"


def test_write_creates_both_files(tmp_path: Path):
    (tmp_path / "CLAUDE.md").write_text(_config())
    plan = _plan(_config(), ["s01"])

    archive = write_apply(plan, tmp_path)

    assert archive.exists()
    assert "Section 0" not in (tmp_path / "CLAUDE.md").read_text()
    assert "Section 0" in archive.read_text()


def test_archive_is_written_before_the_edit(tmp_path: Path, monkeypatch):
    """If the config write fails, the sections must already be recoverable."""
    (tmp_path / "CLAUDE.md").write_text(_config())
    plan = _plan(_config(), ["s01"])

    original_write = Path.write_text
    seen: list[str] = []

    def tracking_write(self, *args, **kwargs):
        seen.append(self.name)
        if self.name == "CLAUDE.md":
            raise OSError("disk full")
        return original_write(self, *args, **kwargs)

    monkeypatch.setattr(Path, "write_text", tracking_write)
    with pytest.raises(OSError, match="disk full"):
        write_apply(plan, tmp_path)

    monkeypatch.undo()
    assert seen[0].endswith(".md") and seen[0] != "CLAUDE.md"
    assert (tmp_path / plan.archive_path).exists()


# --- the command ------------------------------------------------------------


def test_apply_previews_without_writing(git_repo: Path, monkeypatch):
    """Preview is the default: this command edits a file the user cares about."""
    (git_repo / "CLAUDE.md").write_text(_config())
    monkeypatch.chdir(git_repo)

    result = CliRunner().invoke(main, ["apply", "--drop", "s01"])

    assert result.exit_code == 0, result.output
    assert "Preview only" in result.output
    assert "Section 0" in (git_repo / "CLAUDE.md").read_text()


def test_apply_write_edits_and_archives(git_repo: Path, monkeypatch):
    (git_repo / "CLAUDE.md").write_text(_config())
    monkeypatch.chdir(git_repo)

    result = CliRunner().invoke(main, ["apply", "--drop", "s01", "--write"])

    assert result.exit_code == 0, result.output
    assert "Section 0" not in (git_repo / "CLAUDE.md").read_text()
    archives = list((git_repo / ".ablate" / "removed").glob("*.md"))
    assert len(archives) == 1
    assert "Section 0" in archives[0].read_text()


def test_apply_refuses_a_pinned_section(git_repo: Path, monkeypatch):
    (git_repo / "CLAUDE.md").write_text(_config(pinned={1}))
    monkeypatch.chdir(git_repo)

    result = CliRunner().invoke(main, ["apply", "--drop", "s02", "--write"])

    assert result.exit_code == 1
    assert "pinned" in result.output
    assert "Section 1" in (git_repo / "CLAUDE.md").read_text()


def test_apply_requires_drop(git_repo: Path, monkeypatch):
    monkeypatch.chdir(git_repo)
    assert CliRunner().invoke(main, ["apply"]).exit_code != 0


def test_apply_warns_when_it_would_empty_the_file(git_repo: Path, monkeypatch):
    (git_repo / "CLAUDE.md").write_text(_config(n=2))
    monkeypatch.chdir(git_repo)

    result = CliRunner().invoke(main, ["apply", "--drop", "s01,s02"])

    assert "every section" in result.output
    assert "ablate compare" in result.output
