"""Macro recording: capture edits, then replay them or emit a `.py` macro.

A :class:`MacroRecorder` observes editing operations (set/clear/sheet ops) as a
list of JSON-serializable :class:`Action` records. From that list it can:

* **replay** the actions against any workbook,
* **emit a `.py` macro** (``@macro``) that reproduces them — runnable through the
  normal macro machinery (CLI/TUI/GUI), and
* **round-trip through JSON** using the spec's self-describing envelope.

The TUI (``:rec``) and GUI (command palette) feed edits to a recorder and call
``save_macro`` / ``replay``.
"""

from __future__ import annotations

import json
import re
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path

from .core.reference import parse_a1
from .core.translate import REF_ERROR, shift_formula, shift_reference

SCHEMA_VERSION = 1


@dataclass
class Action:
    kind: str  # "set" | "clear" | "add_sheet" | "select_sheet"
    ref: str = ""
    raw: str = ""
    name: str = ""  # for sheet operations


class MacroRecorder:
    def __init__(self) -> None:
        self.actions: list[Action] = []
        self.recording = False
        self.relative = False
        self.anchor: tuple[int, int] | None = None
        self.name = ""

    # --- control ----------------------------------------------------------

    def start(self, name: str = "", relative: bool = False) -> None:
        self.recording = True
        self.relative = relative
        self.anchor = None  # set on first recorded action
        self.actions = []
        self.name = name or _default_name()

    def stop(self) -> None:
        self.recording = False

    def toggle(self, name: str = "", relative: bool = False) -> bool:
        self.stop() if self.recording else self.start(name, relative)
        return self.recording

    @property
    def mode(self) -> str:
        return "relative" if self.relative else "absolute"

    @property
    def count(self) -> int:
        return len(self.actions)

    # --- capture ----------------------------------------------------------

    def _note_anchor(self, ref: str) -> None:
        if self.relative and self.anchor is None:
            self.anchor = parse_a1(ref)

    def record_set(self, ref: str, raw: str) -> None:
        if not self.recording:
            return
        self._note_anchor(ref)
        # Collapse repeated edits of the same cell into the final value.
        if self.actions and self.actions[-1].kind == "set" and self.actions[-1].ref == ref:
            self.actions[-1].raw = raw
        else:
            self.actions.append(Action("set", ref=ref, raw=raw))

    def record_clear(self, ref: str) -> None:
        if self.recording:
            self._note_anchor(ref)
            self.actions.append(Action("clear", ref=ref))

    def record_add_sheet(self, name: str) -> None:
        if self.recording:
            self.actions.append(Action("add_sheet", name=name))

    def record_select_sheet(self, name: str) -> None:
        if self.recording:
            self.actions.append(Action("select_sheet", name=name))

    # --- replay -----------------------------------------------------------

    def replay(self, workbook, at: tuple[int, int] | None = None) -> None:
        """Apply the recorded actions to ``workbook``.

        For a *relative* recording, ``at`` is the new anchor (e.g. the current
        cursor); every target cell and relative formula reference shifts by
        ``at - recorded_anchor``. Absolute recordings ignore ``at``.
        """
        dr = dc = 0
        if self.relative and self.anchor is not None and at is not None:
            dr, dc = at[0] - self.anchor[0], at[1] - self.anchor[1]
        sheet = workbook.sheet
        for a in self.actions:
            if a.kind == "set":
                ref = shift_reference(a.ref, dr, dc)
                if ref == REF_ERROR:
                    continue
                sheet.set(ref, shift_formula(a.raw, dr, dc))
            elif a.kind == "clear":
                ref = shift_reference(a.ref, dr, dc)
                if ref != REF_ERROR:
                    sheet.set(ref, "")
            elif a.kind == "add_sheet":
                sheet = workbook.add_sheet(a.name or None)
            elif a.kind == "select_sheet":
                target = workbook.get_sheet(a.name)
                if target is not None:
                    sheet = target
        workbook.recalculate()

    # --- emit Python macro -----------------------------------------------

    def to_macro_source(self, name: str | None = None) -> str:
        macro_name = _sanitize(name or self.name or "recorded")
        if self.relative:
            return self._relative_source(macro_name)
        lines = [
            "# Generated by abax macro recording. Edit freely.",
            f'@macro("{macro_name}")',
            f"def {macro_name}(ctx):",
        ]
        body: list[str] = []
        for a in self.actions:
            if a.kind == "set":
                body.append(f"    ctx.set({a.ref!r}, {a.raw!r})")
            elif a.kind == "clear":
                body.append(f"    ctx.set({a.ref!r}, '')")
            elif a.kind == "add_sheet":
                body.append(f"    ctx.add_sheet({a.name!r})")
            elif a.kind == "select_sheet":
                body.append(f"    # (replay note) sheet {a.name!r} selected")
        if not body:
            body.append("    pass")
        else:
            body.append("    ctx.recalc()")
        return "\n".join(lines + body) + "\n"

    def _relative_source(self, macro_name: str) -> str:
        """Emit a relative macro: cells and refs shift by ctx.cursor - anchor.

        Run headless (no cursor), it reproduces the pattern at the original
        anchor; run from an editor at cell C, it replays the pattern at C.
        """
        ar, ac = self.anchor or (0, 0)
        lines = [
            "# Generated by abax macro recording (relative).",
            "# Replays relative to ctx.cursor (the active cell), or the",
            f"# recorded anchor ({ar}, {ac}) when run headlessly.",
            f'@macro("{macro_name}")',
            f"def {macro_name}(ctx):",
            f"    ar, ac = {ar}, {ac}",
            "    cur = (ar, ac) if ctx.cursor is None else ctx.cursor",
            "    dr, dc = cur[0] - ar, cur[1] - ac",
        ]
        body: list[str] = []
        for a in self.actions:
            tr, tc = parse_a1(a.ref)
            if a.kind == "set":
                body.append(
                    f"    ctx.set_rc({tr} + dr, {tc} + dc, shift_refs({a.raw!r}, dr, dc))"
                )
            elif a.kind == "clear":
                body.append(f"    ctx.set_rc({tr} + dr, {tc} + dc, '')")
        if not body:
            body.append("    pass")
        else:
            body.append("    ctx.recalc()")
        return "\n".join(lines + body) + "\n"

    def save_macro(self, path: str | Path, name: str | None = None) -> Path:
        path = Path(path)
        macro_name = name or self.name or path.stem
        path.write_text(self.to_macro_source(macro_name), encoding="utf-8")
        return path

    # --- JSON round-trip --------------------------------------------------

    def to_envelope(self, name: str | None = None) -> dict:
        return {
            "app": "abax",
            "schema_version": SCHEMA_VERSION,
            "kind": "macro-recording",
            "written_at": datetime.now(timezone.utc).isoformat(),
            "name": name or self.name,
            "relative": self.relative,
            "anchor": list(self.anchor) if self.anchor is not None else None,
            "actions": [asdict(a) for a in self.actions],
        }

    def save_json(self, path: str | Path, name: str | None = None) -> Path:
        path = Path(path)
        path.write_text(json.dumps(self.to_envelope(name), indent=2), encoding="utf-8")
        return path

    @classmethod
    def from_envelope(cls, env: dict) -> "MacroRecorder":
        rec = cls()
        rec.name = env.get("name", "")
        rec.relative = env.get("relative", False)
        anchor = env.get("anchor")
        rec.anchor = tuple(anchor) if anchor is not None else None
        rec.actions = [Action(**a) for a in env.get("actions", [])]
        return rec

    @classmethod
    def load_json(cls, path: str | Path) -> "MacroRecorder":
        return cls.from_envelope(json.loads(Path(path).read_text(encoding="utf-8")))


def _default_name() -> str:
    return "recorded_" + datetime.now().strftime("%Y%m%d_%H%M%S")


def _sanitize(name: str) -> str:
    """Make ``name`` a valid Python identifier suitable for a def/decorator."""
    out = re.sub(r"\W", "_", name.strip())
    if not out or out[0].isdigit():
        out = "m_" + out
    return out
