"""Apply a recommended deletion, archiving what it removes.

Two rules from docs/02-scope.md and docs/07-ablation.md:

- **Deletions only.** `ablate` never proposes new config text. It measures what
  is there; writing prose is not something a measurement tool has any standing
  to do.
- **Nothing is ever unrecoverable.** Removed sections are written to
  `.ablate/removed/<timestamp>.md` with a provenance header naming the file,
  the command, the commit and the sections, so a deletion can be undone by
  someone who was not in the room when it happened.

The edit removes whole line ranges from the original file rather than
re-rendering it from parsed sections. Re-rendering would silently normalize
blank lines and trailing whitespace across the whole config, turning a
two-section deletion into a diff that touches every line.
"""

from __future__ import annotations

from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path

from ablate.config.parse import Section
from ablate.errors import AblateError

ARCHIVE_DIR = ".ablate/removed"


@dataclass(frozen=True)
class ApplyPlan:
    """What `ablate apply` would do, before it does it."""

    config_path: str
    dropped: tuple[Section, ...]
    kept: tuple[Section, ...]
    original: str
    updated: str
    archive: str
    archive_path: str

    @property
    def tokens_saved(self) -> int:
        return sum(s.tokens for s in self.dropped)

    @property
    def lines_removed(self) -> int:
        return len(self.original.splitlines()) - len(self.updated.splitlines())

    @property
    def removes_everything(self) -> bool:
        return not self.kept


def plan_apply(
    text: str,
    sections: list[Section],
    drop_ids: list[str] | tuple[str, ...],
    *,
    config_path: str,
    command: str = "",
    commit: str | None = None,
    version: str = "",
    now: datetime | None = None,
) -> ApplyPlan:
    """Work out the edit and its archive without touching the filesystem."""
    wanted = list(dict.fromkeys(drop_ids))  # de-duplicate, keep order
    by_id = {s.id: s for s in sections}

    unknown = [sid for sid in wanted if sid not in by_id]
    if unknown:
        known = ", ".join(s.id for s in sections)
        raise AblateError(
            f"no section {', '.join(unknown)} in {config_path}. Known sections: {known}. "
            f"Section ids come from the parse, so they must be produced with the same "
            f"--granularity you ran `ablate ablate` with."
        )

    pinned = [sid for sid in wanted if by_id[sid].required]
    if pinned:
        raise AblateError(
            f"section {', '.join(pinned)} is pinned with <!-- ablate:keep --> and "
            f"cannot be dropped. Remove the marker first if that is really what you want."
        )

    if not wanted:
        raise AblateError("nothing to drop — pass --drop with one or more section ids")

    dropped = [by_id[sid] for sid in wanted]
    kept = [s for s in sections if s.id not in set(wanted)]

    lines = text.splitlines(keepends=True)
    removed = set()
    for section in dropped:
        removed.update(range(section.start_line - 1, section.end_line))
    updated = "".join(line for i, line in enumerate(lines) if i not in removed)

    stamp = (now or datetime.now(UTC)).strftime("%Y-%m-%dT%H-%M-%SZ")
    return ApplyPlan(
        config_path=config_path,
        dropped=tuple(dropped),
        kept=tuple(kept),
        original=text,
        updated=updated,
        archive=_render_archive(
            dropped, config_path=config_path, command=command, commit=commit,
            version=version, stamp=stamp,
        ),
        archive_path=f"{ARCHIVE_DIR}/{stamp}.md",
    )


def _render_archive(
    dropped: list[Section],
    *,
    config_path: str,
    command: str,
    commit: str | None,
    version: str,
    stamp: str,
) -> str:
    """The archive file: a provenance header, then every removed section verbatim.

    The header is written for someone finding this file months later with no
    memory of the run — what it came from, what produced it, and how to put it
    back.
    """
    header = [
        "<!--",
        f"Removed from {config_path} by ablate{' ' + version if version else ''} at {stamp}.",
    ]
    if commit:
        header.append(f"Repo at {commit}.")
    if command:
        header.append(f"Command: {command}")
    header.append("")
    header.append("Sections removed:")
    for section in dropped:
        header.append(
            f"  {section.id}  {section.title}  "
            f"(lines {section.start_line}-{section.end_line}, ~{section.tokens:,} tokens)"
        )
    header.append("")
    header.append(
        f"To restore, paste the corresponding block below back into {config_path}."
    )
    header.append("-->")

    body = []
    for section in dropped:
        body.append(f"\n<!-- {section.id} · {section.title} -->\n")
        body.append(section.text if section.text.endswith("\n") else section.text + "\n")

    return "\n".join(header) + "\n" + "".join(body)


def write_apply(plan: ApplyPlan, repo: Path) -> Path:
    """Write the archive first, then the edit.

    Order matters: if writing the config fails, the archive already exists and
    the sections are recoverable. The reverse order could lose them.
    """
    archive_path = repo / plan.archive_path
    archive_path.parent.mkdir(parents=True, exist_ok=True)
    archive_path.write_text(plan.archive)

    (repo / plan.config_path).write_text(plan.updated)
    return archive_path
