"""Ledger-driven self-reflection (Phase 2b §6).

Once a week, sweep ``task_ledger.jsonl`` and produce a markdown self-review:

    * Aggregate run counts by (kind, task_name, status).
    * Surface "stale producers" — task names that have failed or produced no
      output for several consecutive runs — as ``open_loops`` entries.
    * Ask the utility model for a short human-readable reflection, append it
      to ``memory/insights.md`` under an ISO-week anchor (dedup by week id),
      and write a ``reflection`` row to the ledger.

Scheduling lives in :mod:`mugi_runner` — this module only provides the
compute, persistence, and dedupe logic.

The feature is default-off and obeys ``ledger_reflection.enabled``. The
utility-model step is optional: when the utility model is unavailable, we
still write a skeleton markdown summary with raw aggregates so the sweep is
useful even without an LLM.
"""

from __future__ import annotations

import json
import logging
import os
import re
import threading
from collections import Counter, defaultdict
from datetime import datetime, timedelta, timezone
from typing import Any

from utils.assistant_visibility_placeholders import is_visibility_placeholder

_log = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Paths + constants
# ---------------------------------------------------------------------------

_STATE_FILENAME = "ledger_reflection_state.json"
_INSIGHTS_FILENAME = "insights.md"
_MEMORY_SUBDIR = "memory"

_WEEK_SECONDS = 7 * 24 * 3600
_STALE_MIN_FAILED = 3
_MAX_TASKS_IN_SECTION = 40

_lock = threading.Lock()


def _state_path() -> str:
    from persistence import MUGI_DIR
    return os.path.join(os.path.expanduser(MUGI_DIR), _STATE_FILENAME)


def _insights_path(workspace: str) -> str:
    return os.path.join(workspace, _MEMORY_SUBDIR, _INSIGHTS_FILENAME)


def _default_reflection_workspace() -> str:
    """Same root as MEMORY.md / consolidation: configured Mugi workspace, not ``$HOME``."""
    try:
        from core.config import get_workspace_path

        return get_workspace_path()
    except Exception:
        _log.debug("ledger_reflection: get_workspace_path failed", exc_info=True)
        return os.path.expanduser("~")


def is_enabled() -> bool:
    try:
        from core.config import get_ledger_reflection_config
        return bool((get_ledger_reflection_config() or {}).get("enabled", False))
    except Exception:
        return False


def _memory_strictness() -> str:
    """Return the current memory semantic strictness (default 'balanced')."""
    try:
        from core.config import get_memory_policy_prefs
        return str((get_memory_policy_prefs() or {}).get("memory_semantic_strictness", "balanced"))
    except Exception:
        return "balanced"


# ---------------------------------------------------------------------------
# State (last_run + iso_week dedup)
# ---------------------------------------------------------------------------

def _load_state() -> dict[str, Any]:
    p = _state_path()
    if not os.path.isfile(p):
        return {}
    try:
        with open(p, "r", encoding="utf-8") as f:
            data = json.load(f)
        return data if isinstance(data, dict) else {}
    except Exception:
        return {}


def _save_state(state: dict[str, Any]) -> None:
    p = _state_path()
    try:
        os.makedirs(os.path.dirname(p), exist_ok=True, mode=0o700)
        tmp = p + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(state, f)
        os.replace(tmp, p)
        try:
            os.chmod(p, 0o600)
        except OSError:
            pass
    except Exception:
        _log.debug("ledger_reflection state save failed", exc_info=True)


def _iso_week_id(dt: datetime) -> str:
    iso = dt.isocalendar()
    return f"{iso[0]:04d}-W{iso[1]:02d}"


def should_run_now(now: datetime | None = None) -> bool:
    """Return True when the last run was > 1 ISO week ago or missing."""
    ref = now or datetime.now(timezone.utc)
    state = _load_state()
    last = str(state.get("last_run_ts") or "")
    if not last:
        return True
    try:
        last_dt = datetime.fromisoformat(last.replace("Z", "+00:00"))
        if last_dt.tzinfo is None:
            last_dt = last_dt.replace(tzinfo=timezone.utc)
    except Exception:
        return True
    return (ref - last_dt).total_seconds() >= _WEEK_SECONDS


# ---------------------------------------------------------------------------
# Aggregation
# ---------------------------------------------------------------------------

def _aggregate(rows: list[dict[str, Any]]) -> dict[str, Any]:
    by_kind: Counter = Counter()
    by_status: Counter = Counter()
    by_task: dict[str, Counter] = defaultdict(Counter)
    for row in rows:
        kind = str(row.get("kind") or "unknown")
        status = str(row.get("status") or "unknown")
        task = str(row.get("task_name") or row.get("task") or "(unnamed)")
        by_kind[kind] += 1
        by_status[status] += 1
        by_task[task][status] += 1

    stale: list[str] = []
    for task, counts in by_task.items():
        failed = counts.get("error", 0) + counts.get("timeout", 0)
        ok = counts.get("ok", 0)
        if failed >= _STALE_MIN_FAILED and ok == 0:
            stale.append(task)

    return {
        "total": len(rows),
        "by_kind": dict(by_kind),
        "by_status": dict(by_status),
        "by_task": {t: dict(c) for t, c in by_task.items()},
        "stale_tasks": sorted(stale)[:_MAX_TASKS_IN_SECTION],
    }


# ---------------------------------------------------------------------------
# Insights markdown
# ---------------------------------------------------------------------------

def _render_block(
    week_id: str,
    aggregate: dict[str, Any],
    utility_text: str,
) -> str:
    lines: list[str] = []
    lines.append(f"## Self-Review — {week_id}")
    lines.append("")
    lines.append(f"- Total runs: **{aggregate['total']}**")
    if aggregate["by_kind"]:
        fragments = ", ".join(
            f"{k}: {v}" for k, v in sorted(aggregate["by_kind"].items(), key=lambda x: -x[1])
        )
        lines.append(f"- By kind: {fragments}")
    if aggregate["by_status"]:
        fragments = ", ".join(
            f"{k}: {v}" for k, v in sorted(aggregate["by_status"].items(), key=lambda x: -x[1])
        )
        lines.append(f"- By status: {fragments}")
    if aggregate["stale_tasks"]:
        lines.append("")
        lines.append("### Stale producers")
        for task in aggregate["stale_tasks"]:
            lines.append(f"- `{task}`")
    lines.append("")
    if utility_text.strip():
        lines.append("### Reflection")
        lines.append(utility_text.strip())
        lines.append("")
    return "\n".join(lines) + "\n"


_WEEK_HEADER_RE = re.compile(r"^## Self-Review — (\S+)\s*$", re.MULTILINE)


def _upsert_weekly_block(workspace: str, week_id: str, block: str) -> str:
    """Insert or replace the block for *week_id* in insights.md. Returns the path."""
    path = _insights_path(workspace)
    os.makedirs(os.path.dirname(path), exist_ok=True)
    existing = ""
    if os.path.isfile(path):
        try:
            with open(path, "r", encoding="utf-8") as f:
                existing = f.read()
        except OSError:
            existing = ""

    matches = list(_WEEK_HEADER_RE.finditer(existing))
    target_start: int | None = None
    target_end: int | None = None
    for idx, m in enumerate(matches):
        if m.group(1) == week_id:
            target_start = m.start()
            target_end = matches[idx + 1].start() if idx + 1 < len(matches) else len(existing)
            break

    if target_start is not None and target_end is not None:
        new_text = existing[:target_start] + block + existing[target_end:]
    else:
        sep = "\n" if existing and not existing.endswith("\n") else ""
        new_text = existing + sep + block

    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        f.write(new_text)
    os.replace(tmp, path)
    try:
        os.chmod(path, 0o600)
    except OSError:
        pass
    return path


# ---------------------------------------------------------------------------
# Utility-model prompt
# ---------------------------------------------------------------------------

_PROMPT_TEMPLATE = (
    "You write the assistant's weekly reflection. Read the aggregates below and "
    "write a 3-6 sentence self-review in first-person plural. Focus on what "
    "improved, what stalled, and one concrete follow-up. Keep it grounded in the "
    "numbers. If strictness is '{strictness}', prefer concise, factual language.\n\n"
    "{json_summary}\n\n"
    "Write the reflection only — no headings."
)


def _ask_utility_model(aggregate: dict[str, Any]) -> str:
    try:
        from utility_model import utility_complete, is_available
    except Exception:
        return ""
    if not is_available():
        return ""
    prompt = _PROMPT_TEMPLATE.format(
        strictness=_memory_strictness(),
        json_summary=json.dumps(aggregate, indent=2, default=str),
    )
    try:
        text = utility_complete(
            [{"role": "user", "content": prompt}],
            max_tokens=400,
            temperature=0.4,
            activity_operation="ledger_reflection",
        )
    except Exception:
        _log.debug("ledger_reflection utility call failed", exc_info=True)
        return ""
    out = (text or "").strip()
    if is_visibility_placeholder(out):
        _log.warning(
            "ledger_reflection: utility model echoed chat placeholder; omitting narrative"
        )
        return ""
    return out


# ---------------------------------------------------------------------------
# Open-loop synthesis
# ---------------------------------------------------------------------------

def _emit_open_loops_for_stale(workspace: str, stale: list[str], week_id: str) -> list[str]:
    if not stale:
        return []
    try:
        from utils.open_loops import add_open_loop
    except Exception:
        return []
    created: list[str] = []
    for task in stale:
        text = (
            f"[self-review {week_id}] '{task}' has failed/timed out repeatedly this "
            f"week with no successes. Investigate or prune."
        )
        loop_id = add_open_loop(workspace, text, source="ledger_reflection")
        if loop_id:
            created.append(loop_id)
    return created


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

def read_last_reflection() -> dict[str, Any]:
    """Return the last reflection metadata (or ``{}``)."""
    return _load_state()


def run_reflection(
    *,
    workspace: str | None = None,
    now: datetime | None = None,
    force: bool = False,
) -> dict[str, Any]:
    """Run a single reflection sweep. Returns a summary dict (safe to log)."""
    if not force and not is_enabled():
        return {"skipped": "disabled"}

    ref = now or datetime.now(timezone.utc)
    week_id = _iso_week_id(ref)
    summary: dict[str, Any] = {"week": week_id, "ran_at": ref.isoformat()}

    with _lock:
        state = _load_state()
        if not force and state.get("last_week_id") == week_id:
            summary["skipped"] = "duplicate_week"
            return summary
        # Pull the last 7 days of ledger rows.
        try:
            from utils.task_ledger import recent
            since_ts = (ref - timedelta(days=7)).isoformat()
            rows = recent(limit=2000, since_ts=since_ts)
        except Exception:
            rows = []
        aggregate = _aggregate(rows)
        summary["aggregate"] = aggregate

        utility_text = _ask_utility_model(aggregate)
        summary["has_utility_text"] = bool(utility_text)

        ws = workspace if workspace is not None else _default_reflection_workspace()
        block = _render_block(week_id, aggregate, utility_text)
        try:
            path = _upsert_weekly_block(ws, week_id, block)
            summary["insights_path"] = path
        except Exception as e:
            _log.warning("ledger_reflection write failed: %s", e)
            summary["write_error"] = str(e)

        summary["open_loops"] = _emit_open_loops_for_stale(
            ws, aggregate["stale_tasks"], week_id,
        )

        try:
            from utils import task_ledger as _tl
            _tl.record_run(
                _tl.KIND_REFLECTION,
                "weekly_self_review",
                status=_tl.STATUS_OK,
                meta={
                    "week": week_id,
                    "total_rows": aggregate["total"],
                    "stale_count": len(aggregate["stale_tasks"]),
                    "open_loops_added": len(summary["open_loops"]),
                },
            )
        except Exception:
            _log.debug("ledger_reflection ledger record failed", exc_info=True)

        state["last_week_id"] = week_id
        state["last_run_ts"] = ref.isoformat()
        state["last_summary"] = {
            k: v for k, v in summary.items() if k in ("week", "ran_at", "insights_path", "open_loops")
        }
        _save_state(state)

    return summary


def _tick_interval_seconds() -> float:
    try:
        from core.config import get_ledger_reflection_config
        cfg = get_ledger_reflection_config() or {}
        raw = float(cfg.get("tick_interval_seconds") or 3600.0)
        return max(60.0, min(raw, 6 * 3600.0))
    except Exception:
        return 3600.0


def run_reflection_scheduler_loop(should_stop=None) -> None:
    """Runner-facing loop: wake hourly and fire ``run_reflection`` when due."""
    import time as _time

    while True:
        if should_stop and should_stop():
            return
        try:
            if is_enabled() and should_run_now():
                run_reflection()
        except Exception:
            _log.debug("ledger_reflection scheduler tick failed", exc_info=True)
        # Sleep in small slices so shutdown stays responsive.
        slept = 0.0
        interval = _tick_interval_seconds()
        while slept < interval:
            if should_stop and should_stop():
                return
            _time.sleep(min(5.0, interval - slept))
            slept += 5.0


def _reset_for_tests() -> None:
    p = _state_path()
    try:
        if os.path.isfile(p):
            os.remove(p)
    except OSError:
        pass
