#!/usr/bin/env python3
# Copyright (c) 2026 PULSE Contributors. Proprietary. All rights reserved.
"""
CausalClaim Recovery Daemon — Brain V8 Phase 7 (D7)

Intercepts failing tasks (retry_count 3-4) before POISON_PILL, enriches
with knowledge graph context, re-queues. Mines POISON_PILL outcomes into brain.
Scheduled every 30min. CLI: --once --dry-run --status -v
"""
from __future__ import annotations

import logging
import os
import signal
import sys
import time
from pathlib import Path

if os.name == "nt" and __name__ == "__main__":
    sys.stdout.reconfigure(encoding="utf-8")
    sys.stderr.reconfigure(encoding="utf-8")

ROOT_DIR = Path(__file__).resolve().parents[4]
if str(ROOT_DIR) not in sys.path:
    sys.path.insert(0, str(ROOT_DIR))

from pulse.core.brain_utils import (
    STATE_DIR, TASK_BOARD_FILE, load_json_dict, now_iso as _now_iso,
)
from pulse.core.brain_io import atomic_update_json, save_json, _acquire
from pulse.core.llm_router import dispatch as llm_dispatch

log = logging.getLogger("pulse.d7_recovery")

RECOVERY_RETRY_FLOOR = 3
RECOVERY_RETRY_CEILING = 4
MAX_RECOVERIES = 1
MAX_GRAPH_RESULTS = 5
MAX_DESC_LEN = 2000
POLL_INTERVAL = 30 * 60
STATE_FILE = STATE_DIR / "causal_recovery_state.json"

RECOVERY_PROMPT = (
    "You are the PULSE Task Recovery Engine. A task has failed {retry_count} times.\n"
    "TASK: {title}\nDOMAIN: {domain}\nLAST OUTCOME: {outcome}\n\n"
    "KNOWLEDGE GRAPH INSIGHTS:\n{graph_context}\n\n"
    "Write 2-3 sentences of SPECIFIC, ACTIONABLE guidance for the retry agent. "
    "Focus on what to DO DIFFERENTLY. Output ONLY the guidance text."
)


# --- Private Helpers ---

def _load_state() -> dict:
    if STATE_FILE.exists():
        return load_json_dict(STATE_FILE)
    return {"total_scans": 0, "total_recovered": 0, "total_mined": 0,
            "total_skipped": 0, "total_errors": 0, "last_run": None, "recoveries": []}


def _save_state(state: dict):
    state["recoveries"] = state.get("recoveries", [])[-50:]
    with _acquire(STATE_FILE):
        save_json(STATE_FILE, state)


def _format_graph_context(causes, prevents, related, chain) -> str:
    lines = []
    for label, items, key in [("CAUSES", causes, "confidence"),
                               ("SOLUTIONS", prevents, "confidence"),
                               ("RELATED", related, "relationship")]:
        if not items:
            continue
        lines.append(f"{label}:")
        for it in items[:3]:
            detail = f"{it.get(key, '?')}" if key != "confidence" else f"conf: {it.get(key, 0):.1f}"
            lines.append(f"  - {it.get('text', '')} ({detail})")
    if chain:
        lines.append("Chain: " + " -> ".join(s.get("node", "?") for s in chain))
    return "\n".join(lines)


def _build_recovery_context(task: dict) -> str | None:
    """Build enriched context for a stuck task via Graph RAG + LLM."""
    title = task.get("title", "")
    outcome = task.get("outcome", "")
    query = f"{title} {outcome}".strip()
    if not query:
        return None

    try:
        from pulse.brain.integrity.graph_rag_extractor import extract_graph_rag_concepts
        concepts = extract_graph_rag_concepts(query)
    except Exception:
        concepts = None
    if not concepts:
        parts = query.split()
        if len(parts) < 2:
            return None
        concepts = (parts[0], parts[-1])

    c1, c2 = concepts
    try:
        from pulse.graph.knowledge_graph_query import (
            query_causes, query_prevents, query_related, query_chain)
        causes = query_causes(c1, MAX_GRAPH_RESULTS)
        prevents = query_prevents(c2, MAX_GRAPH_RESULTS)
        related = query_related(c1, MAX_GRAPH_RESULTS)
        chain = query_chain(c1, c2)
    except Exception as e:
        log.warning("Graph query failed: %s", e)
        causes = prevents = related = chain = []

    total_hits = len(causes) + len(prevents) + len(related) + len(chain)
    if total_hits == 0:
        return None

    graph_text = _format_graph_context(causes, prevents, related, chain)
    guidance = ""
    if total_hits >= 2:
        try:
            guidance = llm_dispatch(RECOVERY_PROMPT.format(
                retry_count=task.get("retry_count", "?"), title=title[:100],
                domain=task.get("domain", "general"), outcome=outcome[:200],
                graph_context=graph_text), task_type="fast")
        except Exception as e:
            log.warning("LLM guidance failed (raw graph used): %s", e)

    parts = [f"[RECOVERY CONTEXT — Attempt {task.get('retry_count', '?')}]"]
    if guidance:
        parts.append(f"APPROACH: {guidance.strip()[:300]}")
    parts.append(graph_text)
    return "\n".join(parts)[:800]


def _update_board_task(task_id: str, **fields):
    """Atomically update fields on a task in the board."""
    def _updater(board):
        for dispatch in board.get("dispatches", []):
            for t in dispatch.get("tasks", []):
                if t.get("task_id", "") == task_id:
                    t.update(fields)
                    return board
        return board
    atomic_update_json(TASK_BOARD_FILE, _updater, default={"dispatches": []})


# --- Public API (5 functions) ---

def scan_stuck_tasks(dry_run: bool = False, verbose: bool = False) -> dict:
    """Scan board for retry_count 3-4 tasks, enrich with graph context."""
    board = load_json_dict(TASK_BOARD_FILE)
    recovered = skipped = errors = 0

    for dispatch in board.get("dispatches", []):
        for task in dispatch.get("tasks", []):
            if task.get("status") != "open":
                continue
            retry = task.get("retry_count", 0)
            if retry < RECOVERY_RETRY_FLOOR or retry > RECOVERY_RETRY_CEILING:
                continue
            if task.get("_recovery_attempted", 0) >= MAX_RECOVERIES:
                skipped += 1
                continue

            task_id = task.get("task_id", "")
            if verbose:
                log.info("Recovery attempt for %s (retries=%d)", task_id, retry)
            try:
                context = _build_recovery_context(task)
                if context is None:
                    skipped += 1
                    continue
                new_desc = context + "\n\n--- ORIGINAL TASK ---\n" + task.get("description", "")
                if dry_run:
                    log.info("  [DRY-RUN] Would enrich %s", task_id)
                    recovered += 1
                    continue
                _update_board_task(task_id, description=new_desc[:MAX_DESC_LEN],
                                   _recovery_attempted=task.get("_recovery_attempted", 0) + 1,
                                   claimed_by=None, claimed_at=None, status="open")
                recovered += 1
                if verbose:
                    log.info("  Enriched and re-queued %s", task_id)
            except Exception as e:
                errors += 1
                log.error("Recovery failed for %s: %s", task_id, e)

    return {"recovered": recovered, "skipped": skipped, "errors": errors}


def mine_poison_pills(dry_run: bool = False, verbose: bool = False) -> dict:
    """Mine POISON_PILL outcomes into brain failure records."""
    board = load_json_dict(TASK_BOARD_FILE)
    mined = already_mined = 0
    failures = []

    for dispatch in board.get("dispatches", []):
        for task in dispatch.get("tasks", []):
            if task.get("status") != "done":
                continue
            outcome = task.get("outcome", "")
            if not outcome.startswith("POISON_PILL:"):
                continue
            if task.get("_d7_mined"):
                already_mined += 1
                continue
            failures.append(
                f"[D7] Task '{task.get('title', '?')}' (domain={task.get('domain', '?')}) "
                f"force-closed after {task.get('retry_count', '?')} retries. {outcome[:200]}")
            if not dry_run:
                _update_board_task(task.get("task_id", ""), _d7_mined=True)
            mined += 1

    if failures and not dry_run:
        try:
            from pulse.brain.save_writers import write_failures
            write_failures(failures, "d7_recovery_daemon")
            if verbose:
                log.info("Wrote %d failure records to brain", len(failures))
        except Exception as e:
            log.error("Failed to write brain failures: %s", e)

    return {"mined": mined, "already_mined": already_mined}


def get_recovery_stats() -> dict:
    """Return daemon state from state file."""
    return _load_state()


def run_once(dry_run: bool = False, verbose: bool = False) -> dict:
    """Single cycle: scan stuck tasks + mine POISON_PILLs."""
    scan = scan_stuck_tasks(dry_run=dry_run, verbose=verbose)
    mine = mine_poison_pills(dry_run=dry_run, verbose=verbose)
    if not dry_run:
        state = _load_state()
        state["total_scans"] = state.get("total_scans", 0) + 1
        state["total_recovered"] = state.get("total_recovered", 0) + scan["recovered"]
        state["total_mined"] = state.get("total_mined", 0) + mine["mined"]
        state["total_skipped"] = state.get("total_skipped", 0) + scan["skipped"]
        state["total_errors"] = state.get("total_errors", 0) + scan["errors"]
        state["last_run"] = _now_iso()
        _save_state(state)
    return {"scan": scan, "mine": mine}


def run_daemon(verbose: bool = False, dry_run: bool = False):
    """Main daemon loop — runs every POLL_INTERVAL."""
    _running = True

    def _shutdown(*_):
        nonlocal _running
        _running = False

    signal.signal(signal.SIGINT, _shutdown)
    signal.signal(signal.SIGTERM, _shutdown)
    log.info("[D7] CausalClaim Recovery started (interval=%ds)", POLL_INTERVAL)
    while _running:
        try:
            result = run_once(dry_run=dry_run, verbose=verbose)
            if verbose:
                log.info("[D7] Cycle: %s", result)
        except Exception as e:
            log.error("[D7] Cycle error: %s", e)
        for _ in range(POLL_INTERVAL):
            if not _running:
                break
            time.sleep(1)


# --- CLI ---

if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO, format="%(asctime)s [D7] %(message)s", datefmt="%H:%M:%S")
    _v = "--verbose" in sys.argv or "-v" in sys.argv
    _dr = "--dry-run" in sys.argv

    if "--once" in sys.argv or _dr:
        r = run_once(dry_run=_dr, verbose=_v or _dr)
        s, m = r["scan"], r["mine"]
        print(f"\n[D7] Scan: {s['recovered']} recovered, {s['skipped']} skipped, {s['errors']} errors")
        print(f"[D7] Mine: {m['mined']} mined, {m['already_mined']} already mined")
    elif "--status" in sys.argv:
        st = get_recovery_stats()
        print(f"  D7 CausalClaim Recovery — last: {st.get('last_run', 'never')}")
        print(f"  Scans: {st.get('total_scans', 0)} | Recovered: {st.get('total_recovered', 0)} "
              f"| Mined: {st.get('total_mined', 0)} | Errors: {st.get('total_errors', 0)}")
    else:
        print("[D7] CausalClaim Recovery: --once | --dry-run | --status | -v")
