"""
reflect.py — File-Handling-Layer für den reflector-Skill.

Deterministische Aufgaben laufen hier in Python, semantische Aufgaben
übernimmt das LLM. Drei Modi:

    --mode list-unprocessed
        Listet alle Session-Logs unter {logbuch}/Z Session Logs/ mit
        status: complete und processed: false. Gibt JSON auf stdout.

    --mode append-proposals
        Liest eine JSON-Liste von Vorschlägen über stdin oder
        --stdin-file, validiert Pflichtfelder, dedupliziert gegen
        bestehende Einträge und hängt einen zeitgestempelten Block
        an reflector-staging.md an.

    --mode mark-processed
        Setzt processed: true im Frontmatter der übergebenen Dateien.
        Verweigert die Arbeit, wenn eine Datei nicht unter
        {logbuch}/Z Session Logs/ liegt.

Security by Design: Der Skill darf ausschließlich in die Staging-Datei
und in Session-Logs schreiben. Jeder andere Pfad führt zu Exit 2.

Pfade werden über den config_loader aus bundle.json aufgelöst.
Default-Pfade (falls keine skill.config.yaml vorhanden):
  - session_logs_dir: {logbuch}/Z Session Logs
  - staging_file:     {system}/Lerner Loop/reflector-staging.md
"""

from __future__ import annotations

import argparse
import json
import re
import sys
from datetime import datetime
from pathlib import Path


# _lib liegt eine Ebene über diesem Skript im .skills/-Baum.
_SKILL_DIR = Path(__file__).resolve().parent
_SKILLS_ROOT = _SKILL_DIR.parent
if str(_SKILLS_ROOT) not in sys.path:
    sys.path.insert(0, str(_SKILLS_ROOT))

from _lib.config_loader import load_skill_config  # noqa: E402


FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL)
STATUS_RE = re.compile(r"^status:\s*(.+?)\s*$", re.MULTILINE)
PROCESSED_RE = re.compile(r"^processed:\s*(.+?)\s*$", re.MULTILINE)
ALLOWED_TYPES = {"lektion", "fakt", "regel", "muster", "frage"}

# Diese Werte können über skill.config.yaml (features-Sektion) überschrieben werden.
# float-/int-Defaults hier im Skript — Features aus der Config haben Vorrang.
_DEFAULT_MAX_CONTENT_LEN = 500
_DEFAULT_DEDUP_THRESHOLD = 0.8

# Default-Pfade mit Layout-Platzhaltern. Single Source of Truth für sowohl
# `_load_config` als auch den Eval-Runner (run_evals.py importiert diese
# Konstante, damit Fixture- und Produktiv-Pfad identisch aufgelöst werden).
DEFAULT_PATHS: dict[str, str] = {
    "session_logs_dir": "{logbuch}/Z Session Logs",
    "staging_file": "{system}/Lerner Loop/reflector-staging.md",
}


def _load_config(vault: Path, bundle_root: Path | None = None):
    """Lädt die Skill-Konfiguration mit Default-Pfaden."""
    return load_skill_config(
        skill_dir=_SKILL_DIR,
        vault_root=vault,
        bundle_root=bundle_root,
        default_paths=DEFAULT_PATHS,
    )


def get_session_logs_dir(vault: Path, bundle_root: Path | None = None) -> Path:
    """Gibt das aufgelöste Session-Logs-Verzeichnis zurück."""
    cfg = _load_config(vault, bundle_root)
    return cfg.paths["session_logs_dir"]


def get_staging_path(vault: Path, bundle_root: Path | None = None) -> Path:
    """Gibt den aufgelösten Pfad zur Staging-Datei zurück."""
    cfg = _load_config(vault, bundle_root)
    return cfg.paths["staging_file"]


def read_frontmatter(path: Path) -> str:
    text = path.read_text(encoding="utf-8", errors="replace")
    match = FRONTMATTER_RE.match(text)
    return match.group(1) if match else ""


def is_complete_and_unprocessed(frontmatter: str) -> bool:
    status_match = STATUS_RE.search(frontmatter)
    processed_match = PROCESSED_RE.search(frontmatter)
    if not status_match or not processed_match:
        return False
    status = status_match.group(1).strip().lower()
    processed = processed_match.group(1).strip().lower()
    return status == "complete" and processed == "false"


def list_unprocessed(vault: Path, bundle_root: Path | None = None) -> dict:
    logs_dir = get_session_logs_dir(vault, bundle_root)
    staging = get_staging_path(vault, bundle_root)
    paths: list[str] = []
    if logs_dir.is_dir():
        for md in sorted(logs_dir.rglob("session-*.md")):
            try:
                fm = read_frontmatter(md)
            except OSError as exc:
                sys.stderr.write(
                    f"reflect.py list-unprocessed: konnte Datei nicht lesen, "
                    f"übersprungen: {md} ({exc})\n"
                )
                continue
            if is_complete_and_unprocessed(fm):
                paths.append(str(md))
    return {
        "count": len(paths),
        "paths": paths,
        "staging_path": str(staging),
    }


def validate_proposal(p: dict, max_content_len: int) -> tuple[bool, str]:
    if not isinstance(p, dict):
        return False, "proposal must be an object"
    for field in ("type", "source", "content", "target", "reason"):
        if field not in p or not isinstance(p[field], str) or not p[field].strip():
            return False, f"missing or empty field: {field}"
    if p["type"].lower() not in ALLOWED_TYPES:
        return False, f"invalid type: {p['type']}"
    if len(p["content"]) > max_content_len:
        return False, f"content exceeds {max_content_len} chars"
    return True, ""


def normalize_for_dedup(text: str) -> str:
    return re.sub(r"\s+", " ", text.lower()).strip()


def similarity(a: str, b: str) -> float:
    """Einfache Token-Overlap-Ähnlichkeit. Genau genug für Dedup."""
    tokens_a = set(normalize_for_dedup(a).split())
    tokens_b = set(normalize_for_dedup(b).split())
    if not tokens_a or not tokens_b:
        return 0.0
    intersection = tokens_a & tokens_b
    union = tokens_a | tokens_b
    return len(intersection) / len(union)


def extract_existing_contents(staging_text: str) -> list[str]:
    """Holt alle content-Zeilen aus vorhandenen Vorschlägen."""
    results: list[str] = []
    for match in re.finditer(
        r"^\- \*\*Inhalt:\*\* (.+?)$", staging_text, re.MULTILINE
    ):
        results.append(match.group(1).strip())
    return results


def next_proposal_number(staging_text: str) -> int:
    nums = [
        int(m.group(1))
        for m in re.finditer(r"^### Vorschlag #(\d+)", staging_text, re.MULTILINE)
    ]
    return max(nums) + 1 if nums else 1


def staging_header(frontmatter_defaults: dict | None = None) -> str:
    """Erzeugt den Kopf der Staging-Datei.

    Tags und Frontmatter-Werte können über frontmatter_defaults aus der
    skill.config.yaml überschrieben werden. Defaults sind generisch gehalten
    (keine skill-spezifischen oder persönlichen Namen).
    """
    defaults = frontmatter_defaults or {}
    fm_type = defaults.get("type", "reflector-staging")
    fm_tags = defaults.get("tags", ["reflector", "staging", "second-brain"])
    tags_yaml = "\n".join(f"  - {t}" for t in fm_tags)
    today = datetime.now().strftime("%Y-%m-%d")
    return (
        "---\n"
        f"type: {fm_type}\n"
        'description: "Staging-Datei des Reflector-Skills. Hält Vorschläge mit '
        'Status proposed, accepted oder rejected. Wird vom Curator verarbeitet."\n'
        "tags:\n"
        f"{tags_yaml}\n"
        f"created: {today}\n"
        f"updated: {today}\n"
        "---\n\n"
        "# Reflector Staging\n\n"
        "Diese Datei enthält alle Vorschläge des Reflectors. Jeder Vorschlag hat "
        "einen Status (`proposed`, `accepted`, `rejected`). "
        "Der Nutzer oder ein Curator-Skill liest diese Datei, baut Diff-Vorschläge "
        "und setzt die Status.\n\n"
    )


def ensure_staging(
    path: Path,
    allow_create: bool = True,
    frontmatter_defaults: dict | None = None,
) -> str:
    """Stellt sicher, dass die Staging-Datei existiert. Gibt den Inhalt zurück."""
    if path.exists():
        return path.read_text(encoding="utf-8")
    if not allow_create:
        raise FileNotFoundError(
            f"Staging-Datei fehlt und allow_create_staging=false: {path}"
        )
    path.parent.mkdir(parents=True, exist_ok=True)
    content = staging_header(frontmatter_defaults)
    path.write_text(content, encoding="utf-8")
    return content


def update_staging_updated(text: str) -> str:
    today = datetime.now().strftime("%Y-%m-%d")
    return re.sub(
        r"^updated:\s*.+?$",
        f"updated: {today}",
        text,
        count=1,
        flags=re.MULTILINE,
    )


def render_proposal(number: int, proposal: dict) -> str:
    now = datetime.now().strftime("%Y-%m-%d %H:%M")
    return (
        f"### Vorschlag #{number} — {now}\n\n"
        f"- **Typ:** {proposal['type'].lower()}\n"
        f"- **Quelle:** {proposal['source']}\n"
        f"- **Inhalt:** {proposal['content']}\n"
        f"- **Vorgeschlagene Ablage:** {proposal['target']}\n"
        f"- **Begründung:** {proposal['reason']}\n"
        f"- **Status:** proposed\n\n"
    )


def append_proposals(
    vault: Path,
    raw: str,
    bundle_root: Path | None = None,
) -> dict:
    try:
        proposals = json.loads(raw)
    except json.JSONDecodeError as exc:
        return {"ok": False, "error": f"invalid json: {exc}"}
    if not isinstance(proposals, list):
        return {"ok": False, "error": "payload must be a JSON array"}

    cfg = _load_config(vault, bundle_root)
    max_content_len = int(cfg.features.get("max_content_length", _DEFAULT_MAX_CONTENT_LEN))
    dedup_threshold = float(cfg.features.get("dedup_threshold", _DEFAULT_DEDUP_THRESHOLD))
    allow_create = bool(cfg.features.get("allow_create_staging", True))

    target = cfg.paths["staging_file"]
    try:
        text = ensure_staging(target, allow_create=allow_create, frontmatter_defaults=cfg.frontmatter)
    except FileNotFoundError:
        return {"ok": False, "error": "staging file missing and allow_create_staging=False"}
    existing = extract_existing_contents(text)

    accepted: list[dict] = []
    rejected: list[dict] = []
    start = next_proposal_number(text)

    for raw_proposal in proposals:
        ok, reason = validate_proposal(raw_proposal, max_content_len)
        if not ok:
            rejected.append({"proposal": raw_proposal, "reason": reason})
            continue
        content = raw_proposal["content"]
        is_dup = any(similarity(content, ex) >= dedup_threshold for ex in existing)
        if is_dup:
            rejected.append({"proposal": raw_proposal, "reason": "duplicate"})
            continue
        accepted.append(raw_proposal)
        existing.append(content)

    if accepted:
        block_parts: list[str] = []
        for idx, proposal in enumerate(accepted):
            block_parts.append(render_proposal(start + idx, proposal))
        text = update_staging_updated(text)
        if not text.endswith("\n"):
            text += "\n"
        text += "".join(block_parts)
        target.write_text(text, encoding="utf-8")

    return {
        "ok": True,
        "accepted": len(accepted),
        "rejected": len(rejected),
        "rejected_details": rejected,
        "staging_path": str(target),
    }


def mark_processed(
    vault: Path,
    log_paths: list[str],
    bundle_root: Path | None = None,
) -> dict:
    logs_dir = get_session_logs_dir(vault, bundle_root).resolve()
    updated: list[str] = []
    errors: list[dict] = []

    for raw_path in log_paths:
        path = Path(raw_path).resolve()
        try:
            path.relative_to(logs_dir)
        except ValueError:
            errors.append({"path": raw_path, "error": "outside session-logs directory"})
            continue
        if not path.exists():
            errors.append({"path": raw_path, "error": "file not found"})
            continue
        try:
            text = path.read_text(encoding="utf-8", errors="replace")
        except OSError as exc:
            errors.append({"path": raw_path, "error": str(exc)})
            continue
        # PROCESSED_RE nur auf den Frontmatter-Block anwenden, nicht auf den Body.
        # Sonst würde ein 'processed: false' in einer Tabelle oder einem Beispiel-Block
        # im Body ebenfalls ersetzt.
        fm_match = FRONTMATTER_RE.match(text)
        if fm_match:
            fm_block = fm_match.group(1)
            fm_start = fm_match.start(1)
            fm_end_pos = fm_match.end(1)
            if PROCESSED_RE.search(fm_block):
                new_fm = PROCESSED_RE.sub("processed: true", fm_block, count=1)
            else:
                # Kein processed-Feld im Frontmatter? Nach dem status-Feld einfügen.
                new_fm = re.sub(
                    r"(^status:\s*.+?$)",
                    r"\1\nprocessed: true",
                    fm_block,
                    count=1,
                    flags=re.MULTILINE,
                )
            new_text = text[:fm_start] + new_fm + text[fm_end_pos:]
        else:
            # Kein YAML-Frontmatter vorhanden — gesamten Text wie bisher behandeln
            if PROCESSED_RE.search(text):
                new_text = PROCESSED_RE.sub("processed: true", text, count=1)
            else:
                new_text = re.sub(
                    r"(^status:\s*.+?$)",
                    r"\1\nprocessed: true",
                    text,
                    count=1,
                    flags=re.MULTILINE,
                )
        path.write_text(new_text, encoding="utf-8")
        updated.append(str(path))

    return {"ok": not errors, "updated": updated, "errors": errors}


def main() -> int:
    parser = argparse.ArgumentParser(
        description="File-Handling-Layer für den reflector-Skill."
    )
    parser.add_argument(
        "--mode",
        choices=["list-unprocessed", "append-proposals", "mark-processed"],
        required=True,
    )
    parser.add_argument("--vault", type=Path, default=Path.cwd())
    parser.add_argument("--bundle-root", type=Path, default=None)
    parser.add_argument("--stdin-file", type=Path, default=None)
    parser.add_argument("--logs", nargs="*", default=[])
    args = parser.parse_args()

    vault = args.vault.resolve()
    bundle_root = args.bundle_root.resolve() if args.bundle_root else None

    if args.mode == "list-unprocessed":
        result = list_unprocessed(vault, bundle_root)
        print(json.dumps(result, ensure_ascii=False, indent=2))
        return 0

    if args.mode == "append-proposals":
        if args.stdin_file:
            raw = args.stdin_file.read_text(encoding="utf-8")
        else:
            raw = sys.stdin.read()
        result = append_proposals(vault, raw, bundle_root)
        print(json.dumps(result, ensure_ascii=False, indent=2))
        return 0 if result.get("ok") else 1

    if args.mode == "mark-processed":
        result = mark_processed(vault, args.logs, bundle_root)
        print(json.dumps(result, ensure_ascii=False, indent=2))
        return 0 if result.get("ok") else 1

    return 1


if __name__ == "__main__":
    raise SystemExit(main())
