#!/usr/bin/env python3
"""Wirehead-Schranke für den Auditor.

Misst, klassifiziert und beschränkt Änderungen, die der Auditor-Judge an seinen
eigenen Skill-Dateien vornehmen will.
Wird entweder als CLI aufgerufen (JSON-Ausgabe für den Judge-Prozess) oder als
Modul importiert.

CLI-Beispiele:
    python wirehead.py status
    python wirehead.py check --file SKILL.md --new-text-file /tmp/new.md
    python wirehead.py record --file SKILL.md --percent 2.1 --class wording

Optionale Argumente für Test-Isolation:
    --state-file <pfad>   Pfad zur wirehead-state.json (Default: _SKILL_DIR/wirehead-state.json)
    --config-file <pfad>  Pfad zur autonomy-config.json (Default: _SKILL_DIR/autonomy-config.json)
"""

from __future__ import annotations

import argparse
import difflib
import json
import re
import sys
from dataclasses import dataclass, asdict
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Iterable

_SKILL_DIR = Path(__file__).resolve().parent
CONFIG_PATH = _SKILL_DIR / "autonomy-config.json"
STATE_PATH = _SKILL_DIR / "wirehead-state.json"

CLASS_WORDING = "wording"
CLASS_RULE = "rule"
CLASS_MIXED = "mixed"


# ---------------------------------------------------------------------------
# Config und State
# ---------------------------------------------------------------------------


def load_config(path: Path = CONFIG_PATH) -> dict:
    """Lädt die Wirehead-Konfiguration aus einer JSON-Datei."""
    if not path.exists():
        print(
            f"Fehler: autonomy-config.json fehlt unter {path}.\n"
            "Kopiere die Default-Konfiguration aus templates/.skills/auditor/autonomy-config.json\n"
            f"in den Skill-Ordner: cp templates/.skills/auditor/autonomy-config.json {path.parent}/",
            file=sys.stderr,
        )
        sys.exit(2)
    return json.loads(path.read_text(encoding="utf-8"))


def load_state(path: Path = STATE_PATH) -> dict:
    """Lädt den Wirehead-Zustand. Gibt leeren Zustand zurück, wenn Datei fehlt."""
    if not path.exists():
        return {"schema_version": 1, "changes": []}
    return json.loads(path.read_text(encoding="utf-8"))


def save_state(state: dict, path: Path = STATE_PATH) -> None:
    """Schreibt den Zustand atomar via temporäre Datei und Replace."""
    tmp = path.with_suffix(".tmp")
    tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
    tmp.replace(path)


def _parse_timestamp(change: dict) -> datetime | None:
    """Parst den timestamp-Eintrag defensiv. Gibt None bei fehlendem oder ungültigem Wert."""
    ts_str = change.get("timestamp")
    if not ts_str:
        return None
    try:
        return datetime.fromisoformat(ts_str)
    except (ValueError, TypeError):
        return None


def prune_state(state: dict, window_days: int, now: datetime) -> dict:
    """Entfernt Einträge außerhalb des Rolling-Fensters."""
    cutoff = now - timedelta(days=window_days)
    pruned = []
    for c in state.get("changes", []):
        ts = _parse_timestamp(c)
        if ts is None:
            continue  # korrumpierter Eintrag, überspringen
        if ts >= cutoff:
            pruned.append(c)
    state["changes"] = pruned
    return state


# ---------------------------------------------------------------------------
# Diff-Messung
# ---------------------------------------------------------------------------


def measure_change_percent(old_text: str, new_text: str) -> float:
    """Anteil geänderter Zeilen an der größeren Zeilenzahl, in Prozent."""
    old_lines = old_text.splitlines()
    new_lines = new_text.splitlines()
    denom = max(len(old_lines), len(new_lines))
    if denom == 0:
        return 0.0
    matcher = difflib.SequenceMatcher(a=old_lines, b=new_lines, autojunk=False)
    changed = 0
    for tag, i1, i2, j1, j2 in matcher.get_opcodes():
        if tag == "replace":
            changed += max(i2 - i1, j2 - j1)
        elif tag in ("delete", "insert"):
            changed += (i2 - i1) + (j2 - j1)
    return round(100.0 * changed / denom, 2)


# ---------------------------------------------------------------------------
# Wording-vs-Regel-Klassifikation
# ---------------------------------------------------------------------------


_MARKDOWN_STRUCTURE_MARKERS = (
    re.compile(r"^\s*#+\s"),
    re.compile(r"^\s*[-*+]\s"),
    re.compile(r"^\s*\d+\.\s"),
    re.compile(r"^\s*\|"),
    re.compile(r"^\s*```"),
    re.compile(r"^---\s*$"),
    re.compile(r"^[A-Za-z_]+:\s"),
)


def _md_classify_line(line: str) -> str:
    stripped = line.strip()
    if not stripped:
        return CLASS_WORDING
    for pat in _MARKDOWN_STRUCTURE_MARKERS:
        if pat.match(line):
            return CLASS_RULE
    return CLASS_WORDING


def _py_classify_line(line: str) -> str:
    stripped = line.strip()
    if not stripped:
        return CLASS_WORDING
    if stripped.startswith("#"):
        return CLASS_WORDING
    if stripped.startswith(('"""', "'''")) or stripped.endswith(('"""', "'''")):
        return CLASS_WORDING
    return CLASS_RULE


def _classify_line(file_suffix: str, line: str, in_fence: bool, in_frontmatter: bool) -> str:
    if file_suffix == ".json":
        return CLASS_RULE
    if file_suffix == ".py":
        return _py_classify_line(line)
    if file_suffix in (".md", ".markdown"):
        if in_fence or in_frontmatter:
            return CLASS_RULE
        return _md_classify_line(line)
    return CLASS_RULE


def _walk_with_context(lines: Iterable[str], file_suffix: str):
    in_fence = False
    in_frontmatter = False
    seen_frontmatter_end = False
    for idx, line in enumerate(lines):
        if file_suffix in (".md", ".markdown"):
            if idx == 0 and line.strip() == "---":
                in_frontmatter = True
                yield idx, line, in_fence, in_frontmatter
                continue
            if in_frontmatter and line.strip() == "---" and not seen_frontmatter_end:
                yield idx, line, in_fence, in_frontmatter
                in_frontmatter = False
                seen_frontmatter_end = True
                continue
            if line.lstrip().startswith("```"):
                yield idx, line, in_fence, in_frontmatter
                in_fence = not in_fence
                continue
        yield idx, line, in_fence, in_frontmatter


def classify_diff(old_text: str, new_text: str, file_suffix: str) -> dict:
    """Klassifiziert die Änderungen zwischen old_text und new_text.

    Rückgabe: {"class": wording|rule|mixed, "wording_lines": int, "rule_lines": int, "changed_lines": int}
    """
    old_lines = old_text.splitlines()
    new_lines = new_text.splitlines()

    old_ctx = {i: (in_fence, in_fm) for i, _, in_fence, in_fm in _walk_with_context(old_lines, file_suffix)}
    new_ctx = {i: (in_fence, in_fm) for i, _, in_fence, in_fm in _walk_with_context(new_lines, file_suffix)}

    matcher = difflib.SequenceMatcher(a=old_lines, b=new_lines, autojunk=False)
    wording = 0
    rule = 0
    changed = 0
    for tag, i1, i2, j1, j2 in matcher.get_opcodes():
        if tag == "equal":
            continue
        for i in range(i1, i2):
            changed += 1
            in_fence, in_fm = old_ctx.get(i, (False, False))
            cls = _classify_line(file_suffix, old_lines[i], in_fence, in_fm)
            if cls == CLASS_WORDING:
                wording += 1
            else:
                rule += 1
        for j in range(j1, j2):
            changed += 1
            in_fence, in_fm = new_ctx.get(j, (False, False))
            cls = _classify_line(file_suffix, new_lines[j], in_fence, in_fm)
            if cls == CLASS_WORDING:
                wording += 1
            else:
                rule += 1

    if rule > 0 and wording > 0:
        cls = CLASS_MIXED
    elif rule > 0:
        cls = CLASS_RULE
    elif wording > 0:
        cls = CLASS_WORDING
    else:
        cls = CLASS_WORDING
    return {"class": cls, "wording_lines": wording, "rule_lines": rule, "changed_lines": changed}


# ---------------------------------------------------------------------------
# Budget-Berechnung
# ---------------------------------------------------------------------------


@dataclass
class BudgetResult:
    consumed_percent: float
    budget_percent: float
    remaining_percent: float
    within_budget: bool
    per_file: dict


def consumed_budget(state: dict, config: dict, now: datetime) -> BudgetResult:
    """Berechnet den verbrauchten Anteil des wöchentlichen Änderungs-Budgets."""
    window = int(config.get("rolling_window_days", 7))
    budget = float(config.get("weekly_change_budget_percent", 10.0))
    targets = list(config.get("target_files", []))
    cutoff = now - timedelta(days=window)

    per_file = {t: 0.0 for t in targets}
    for change in state.get("changes", []):
        ts = _parse_timestamp(change)
        if ts is None:
            continue  # korrumpierter Eintrag, überspringen
        if ts < cutoff:
            continue
        f = change.get("file")
        if f in per_file:
            per_file[f] += float(change.get("percent", 0.0))
    consumed = round(sum(per_file.values()) / max(len(per_file), 1), 2)
    return BudgetResult(
        consumed_percent=consumed,
        budget_percent=budget,
        remaining_percent=round(budget - consumed, 2),
        within_budget=consumed < budget,
        per_file={k: round(v, 2) for k, v in per_file.items()},
    )


# ---------------------------------------------------------------------------
# Entscheidungs-Gate
# ---------------------------------------------------------------------------


def requires_approval(diff_class: str, config: dict) -> bool:
    rules = config.get("approval_rules", {})
    if diff_class == CLASS_RULE and rules.get("rule_changes_always_require_approval", True):
        return True
    if diff_class == CLASS_MIXED and rules.get("mixed_changes_treated_as_rule", True):
        return True
    if diff_class == CLASS_WORDING and rules.get("wording_changes_within_budget_allowed", True):
        return False
    return True


def evaluate_change(file_path: str, old_text: str, new_text: str, config: dict, state: dict, now: datetime) -> dict:
    """Bewertet eine geplante Änderung gegen Wirehead-Schranke und Budget."""
    targets = config.get("target_files", [])
    suffix = Path(file_path).suffix
    percent = measure_change_percent(old_text, new_text)
    classification = classify_diff(old_text, new_text, suffix)
    budget = consumed_budget(state, config, now)
    projected = round(budget.consumed_percent + percent / max(len(targets), 1), 2)
    is_target = file_path in targets

    approval = False
    reasons = []
    if not is_target:
        allow = True
        reasons.append("Datei außerhalb des Wirehead-Scopes — keine Schranke.")
    else:
        if requires_approval(classification["class"], config):
            allow = False
            approval = True
            reasons.append(f"Diff-Klasse '{classification['class']}' braucht Nutzer-Freigabe.")
        elif projected > budget.budget_percent:
            allow = False
            approval = True
            reasons.append(
                f"Projizierter Verbrauch {projected}% über Budget {budget.budget_percent}%."
            )
        else:
            allow = True
            reasons.append(
                f"Wording innerhalb Budget (verbraucht {budget.consumed_percent}% + neu {percent}% = {projected}%)."
            )

    return {
        "file": file_path,
        "is_target": is_target,
        "change_percent": percent,
        "classification": classification,
        "budget": asdict(budget),
        "projected_consumed_percent": projected,
        "allow_auto": allow,
        "requires_approval": approval,
        "reasons": reasons,
    }


def record_change(file_path: str, percent: float, diff_class: str, now: datetime,
                  state_path: Path = STATE_PATH, config_path: Path = CONFIG_PATH) -> dict:
    """Protokolliert eine erfolgreich ausgeführte Änderung im Rolling-Fenster."""
    config = load_config(config_path)
    state = load_state(state_path)
    state = prune_state(state, int(config.get("rolling_window_days", 7)), now)
    state.setdefault("changes", []).append({
        "timestamp": now.isoformat(),
        "file": file_path,
        "percent": percent,
        "class": diff_class,
    })
    save_state(state, state_path)
    return {"recorded": True, "entries": len(state["changes"])}


# ---------------------------------------------------------------------------
# Zeitquelle (testbar via --override-now / _get_now)
# ---------------------------------------------------------------------------


def _get_now(override: str | None = None) -> datetime:
    """Liefert den aktuellen Zeitpunkt. override akzeptiert ISO-Timestamp für Tests."""
    if override:
        dt = datetime.fromisoformat(override)
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        return dt
    return datetime.now(tz=timezone.utc).replace(microsecond=0)


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------


def _cmd_status(args) -> int:
    config_path = Path(args.config_file) if args.config_file else CONFIG_PATH
    state_path = Path(args.state_file) if args.state_file else STATE_PATH
    config = load_config(config_path)
    state = load_state(state_path)
    now = _get_now(getattr(args, "override_now", None))
    prune_state(state, int(config.get("rolling_window_days", 7)), now)
    budget = consumed_budget(state, config, now)
    print(json.dumps({
        "config_version": config.get("version"),
        "stage_3_enabled": config.get("stage_3_enabled"),
        "now": now.isoformat(),
        "budget": asdict(budget),
        "recent_changes": state.get("changes", []),
    }, ensure_ascii=False, indent=2))
    return 0


def _cmd_check(args) -> int:
    config_path = Path(args.config_file) if args.config_file else CONFIG_PATH
    state_path = Path(args.state_file) if args.state_file else STATE_PATH
    config = load_config(config_path)
    state = load_state(state_path)
    now = _get_now(getattr(args, "override_now", None))
    prune_state(state, int(config.get("rolling_window_days", 7)), now)
    target = Path(args.file)
    old_text = target.read_text(encoding="utf-8") if target.exists() else ""
    new_text = Path(args.new_text_file).read_text(encoding="utf-8")
    result = evaluate_change(args.file, old_text, new_text, config, state, now)
    print(json.dumps(result, ensure_ascii=False, indent=2))
    return 0 if result["allow_auto"] else 1


def _cmd_record(args) -> int:
    config_path = Path(args.config_file) if args.config_file else CONFIG_PATH
    state_path = Path(args.state_file) if args.state_file else STATE_PATH
    now = _get_now(getattr(args, "override_now", None))
    result = record_change(args.file, float(args.percent), args.cls, now,
                           state_path=state_path, config_path=config_path)
    print(json.dumps(result, ensure_ascii=False, indent=2))
    return 0


def _add_common_args(parser: argparse.ArgumentParser) -> None:
    """Gemeinsame Argumente für Test-Isolation (state-file, config-file)."""
    parser.add_argument(
        "--state-file",
        dest="state_file",
        default=None,
        help="Pfad zur wirehead-state.json (Default: _SKILL_DIR/wirehead-state.json)",
    )
    parser.add_argument(
        "--config-file",
        dest="config_file",
        default=None,
        help="Pfad zur autonomy-config.json (Default: _SKILL_DIR/autonomy-config.json)",
    )


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Wirehead-Schranke für den Auditor.")
    sub = parser.add_subparsers(dest="cmd", required=True)

    p_status = sub.add_parser("status", help="Budget-Status und Rolling-Window-Inhalt ausgeben.")
    _add_common_args(p_status)
    p_status.set_defaults(func=_cmd_status)

    p_check = sub.add_parser("check", help="Geplante Änderung gegen Schranke prüfen.")
    p_check.add_argument("--file", required=True, help="Pfad zur Ziel-Datei.")
    p_check.add_argument("--new-text-file", required=True, help="Datei mit geändertem Inhalt.")
    _add_common_args(p_check)
    p_check.set_defaults(func=_cmd_check)

    p_record = sub.add_parser("record", help="Ausgeführte Änderung im Rolling-Fenster protokollieren.")
    p_record.add_argument("--file", required=True)
    p_record.add_argument("--percent", required=True)
    p_record.add_argument("--class", dest="cls", required=True, choices=[CLASS_WORDING, CLASS_RULE, CLASS_MIXED])
    _add_common_args(p_record)
    p_record.set_defaults(func=_cmd_record)

    args = parser.parse_args(argv)
    return args.func(args)


if __name__ == "__main__":
    sys.exit(main())
