"""Config swap: write a config variant into a worktree.

M1 variants are whole files (a path on disk, or None for "no config" /
/dev/null). Section-level variants (ablation, M3) build on the same
`ConfigVariant.apply` contract with a rendered subset of sections instead
of raw file bytes — see docs/07-ablation.md.
"""

from __future__ import annotations

import hashlib
from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True)
class ConfigVariant:
    """`text=None` means "no config": the file is removed from the worktree
    rather than written empty, matching `--config-b /dev/null` semantics."""

    id: str
    text: str | None
    source: str  # human-readable origin, e.g. a file path or "/dev/null"

    @classmethod
    def from_path(cls, variant_id: str, path: str | Path) -> ConfigVariant:
        p = Path(path)
        if str(path) in ("/dev/null", "") or not p.exists():
            return cls(id=variant_id, text=None, source="/dev/null")
        return cls(id=variant_id, text=p.read_text(), source=str(p))

    def apply(self, worktree: Path, config_path: str) -> None:
        target = worktree / config_path
        if self.text is None:
            target.unlink(missing_ok=True)
            return
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(self.text)

    @property
    def content_hash(self) -> str:
        """Identity of what the agent actually sees.

        Over the config *text*, not its path: renaming or moving a file must
        not invalidate cached trials, and editing it must invalidate exactly
        the arm that changed. This is the memo key's discriminating component
        (docs/06-cost.md).
        """
        return "sha256:" + hashlib.sha256((self.text or "").encode()).hexdigest()[:16]

    @property
    def line_count(self) -> int:
        return 0 if self.text is None else len(self.text.splitlines())

    @property
    def char_count(self) -> int:
        return 0 if self.text is None else len(self.text)
