#!/usr/bin/env python3
# Copyright (c) 2026 PULSE Contributors. Proprietary. All rights reserved.
"""
Synaptic Gate-Feedback Engine — Brain V8 Phase 5 (D5)

Event-driven: called from autopilot loop.py on gate failures.
Captures exact failure context, generates temporary anti-patterns,
and produces corrective work orders for retry.

Closes the loop: failure -> learning -> informed retry.

Scheduler wiring handles decay only (not generation).
"""
from __future__ import annotations

import hashlib
import json
import logging
import os
import sys
import time
from datetime import datetime, timezone, timedelta
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[3]
if str(ROOT_DIR) not in sys.path:
    sys.path.insert(0, str(ROOT_DIR))

from pulse.core.brain_utils import (
    load_json_dict, save_json, STATE_DIR,
    now_iso as _now_iso,
)
from pulse.core.brain_io import _acquire

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

FEEDBACK_FILE = STATE_DIR / "gate_feedback.json"
MAX_ENTRIES = 20
DEFAULT_EXPIRY_HOURS = 48


# --- Failure Classification ---

_FAILURE_TYPES = {
    "diff-guard": "diff_guard",
    "smoke-test": "smoke_test",
    "agent-crash": "agent_crash",
    "zero-work": "zero_work",
}


def _classify_failure(gate_failures: list[str]) -> tuple[str, str]:
    """Classify failure type and extract detail from gate failure strings."""
    if not gate_failures:
        return "unknown", "No failure detail"
    raw = gate_failures[0]
    for prefix, ftype in _FAILURE_TYPES.items():
        if raw.startswith(prefix):
            return ftype, raw
    return "unknown", raw


def _extract_top_files(post_snapshot: dict[str, tuple[int, int]] | None,
                       max_files: int = 3) -> list[str]:
    """Extract most-changed files from diff snapshot."""
    if not post_snapshot:
        return []
    scored = []
    for f, (added, removed) in post_snapshot.items():
        scored.append((added + removed, f))
    scored.sort(reverse=True)
    return [f for _, f in scored[:max_files]]


def _feedback_id(task_id: str, failure_type: str) -> str:
    """Stable ID from task_id + failure_type."""
    raw = f"{task_id}|{failure_type}"
    return f"gf_{hashlib.md5(raw.encode()).hexdigest()[:12]}"


# --- Anti-Pattern Templates ---

def _generate_anti_pattern(failure_type: str, detail: str,
                           top_files: list[str], title: str) -> str:
    """Generate natural-language anti-pattern from failure context."""
    short_title = title[:50]
    files_str = ", ".join(top_files[:3]) if top_files else "unknown files"

    templates = {
        "diff_guard": f"Don't modify too many lines in a single task — hit limit on '{short_title}' ({files_str})",
        "smoke_test": f"Smoke test broke after changes to {files_str} in task '{short_title}' — {detail[:100]}",
        "agent_crash": f"Agent crash on task '{short_title}' — likely missing context or import error",
        "zero_work": f"Zero-work detected on '{short_title}' — agent produced no file changes",
    }
    return templates.get(failure_type,
                         f"Gate failure on '{short_title}': {detail[:100]}")


def _generate_corrective(failure_type: str, top_files: list[str],
                         title: str, description: str) -> str:
    """Generate corrective constraint for retry."""
    files_str = ", ".join(top_files[:3]) if top_files else "the target files"

    templates = {
        "diff_guard": f"Retry with smaller scope. Split by file: {files_str}. Change fewer files per pass.",
        "smoke_test": f"Retry with explicit smoke test validation before completing. Focus on: {files_str}",
        "agent_crash": f"Retry with simpler approach. Read the task description carefully before starting.",
        "zero_work": "Retry — ensure you actually modify files. Read the task, make changes, verify.",
    }
    return templates.get(failure_type,
                         f"Retry task '{title[:50]}' with more careful approach.")


# --- Core API (3 public functions) ---

def record_gate_failure(
    task_id: str,
    task_title: str,
    task_description: str,
    task_domain: str,
    gate_failures: list[str],
    post_snapshot: dict[str, tuple[int, int]] | None = None,
) -> dict | None:
    """Record a gate failure, generate anti-pattern, return corrective task dict.

    Called by loop.py on gate fail. Returns corrective task dict or None.
    """
    failure_type, detail = _classify_failure(gate_failures)
    top_files = _extract_top_files(post_snapshot)
    anti_pattern = _generate_anti_pattern(
        failure_type, detail, top_files, task_title)
    corrective = _generate_corrective(
        failure_type, top_files, task_title, task_description)
    now = _now_iso()

    entry = {
        "id": _feedback_id(task_id, failure_type),
        "task_id": task_id,
        "task_title": task_title[:100],
        "domain": task_domain,
        "failure_type": failure_type,
        "failure_detail": detail[:300],
        "top_changed_files": top_files,
        "anti_pattern": anti_pattern[:300],
        "corrective_constraint": corrective[:300],
        "timestamp": now,
        "expires_at": (datetime.now(timezone.utc)
                       + timedelta(hours=DEFAULT_EXPIRY_HOURS)).isoformat(),
        "status": "active",
    }

    # Write to state file with filelock
    try:
        with _acquire(FEEDBACK_FILE):
            data = load_json_dict(FEEDBACK_FILE)
            entries = data.get("entries", [])

            # Dedup: replace existing entry for same task+type
            entries = [e for e in entries if e.get("id") != entry["id"]]
            entries.insert(0, entry)

            # Cap at MAX_ENTRIES
            entries = entries[:MAX_ENTRIES]

            save_json(FEEDBACK_FILE, {
                "entries": entries,
                "last_updated": now,
            })
    except Exception as e:
        log.error("Failed to write gate feedback: %s", e)
        return None

    log.info("[D5] Recorded: %s — %s", failure_type, anti_pattern[:80])

    # Return corrective task dict for re-injection into task description
    return {
        "anti_pattern": anti_pattern,
        "corrective_constraint": corrective,
        "failure_type": failure_type,
    }


def get_active_feedback(max_items: int = 5) -> list[dict]:
    """Return active (non-expired) feedback entries sorted by recency.

    Used by boot loaders to inject gate feedback into agent briefings.
    """
    if not FEEDBACK_FILE.exists():
        return []
    try:
        data = load_json_dict(FEEDBACK_FILE)
        entries = data.get("entries", [])
        now = datetime.now(timezone.utc)
        active = []
        for e in entries:
            if e.get("status") != "active":
                continue
            try:
                expires = datetime.fromisoformat(e.get("expires_at", ""))
                if expires.tzinfo is None:
                    expires = expires.replace(tzinfo=timezone.utc)
                if expires < now:
                    continue
            except (ValueError, TypeError):
                pass
            active.append(e)
        return active[:max_items]
    except Exception:
        return []


def decay_old_feedback(max_age_hours: int = DEFAULT_EXPIRY_HOURS) -> int:
    """Expire entries older than threshold. Returns count of expired entries.

    Called by orchestrator scheduler with --decay flag.
    """
    if not FEEDBACK_FILE.exists():
        return 0
    try:
        with _acquire(FEEDBACK_FILE):
            data = load_json_dict(FEEDBACK_FILE)
            entries = data.get("entries", [])
            now = datetime.now(timezone.utc)
            expired_count = 0

            for e in entries:
                if e.get("status") != "active":
                    continue
                try:
                    expires = datetime.fromisoformat(e.get("expires_at", ""))
                    if expires.tzinfo is None:
                        expires = expires.replace(tzinfo=timezone.utc)
                    if expires < now:
                        e["status"] = "expired"
                        expired_count += 1
                except (ValueError, TypeError):
                    pass

            # Remove expired entries entirely
            entries = [e for e in entries if e.get("status") == "active"]
            save_json(FEEDBACK_FILE, {
                "entries": entries,
                "last_updated": _now_iso(),
            })

        if expired_count:
            log.info("[D5] Decayed %d expired feedback entries", expired_count)
        return expired_count
    except Exception as e:
        log.error("[D5] Decay error: %s", e)
        return 0


# --- CLI Entry Point ---

if __name__ == "__main__":
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s [D5] %(message)s",
        datefmt="%H:%M:%S",
    )
    if "--decay" in sys.argv:
        count = decay_old_feedback()
        print(f"[D5] Decayed {count} expired entries")
    elif "--status" in sys.argv:
        active = get_active_feedback(max_items=20)
        print(f"Active gate feedback entries: {len(active)}")
        for e in active:
            print(f"  [{e['failure_type']}] {e['task_title'][:60]} — {e['anti_pattern'][:60]}")
    elif "--once" in sys.argv:
        # --once just runs decay (generation is event-driven, not scheduled)
        count = decay_old_feedback()
        print(f"[D5] Decay pass: {count} expired")
    else:
        print("[D5] Gate Feedback Engine is event-driven (called from autopilot).")
        print("     Use --decay to expire old entries, --status to view active.")
