#!/usr/bin/env python3
# Copyright (c) 2026 PULSE Contributors. Proprietary. All rights reserved.
# See LICENSE file for details.
"""PULSE Task Dispatcher Daemon (ASOP v1)
Manages task board lifecycle: stale claims, dependency unblocking, archival.
Default mode: zero LLM cost. Dependencies: Python stdlib only (Law 4)
"""
import hashlib
import hmac
import json
import os
import random
import secrets
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
ROOT_DIR = SCRIPT_DIR.parent.parent
if str(ROOT_DIR) not in sys.path:
    sys.path.insert(0, str(ROOT_DIR))
from pulse.core.brain_utils import TASK_BOARD_FILE, atomic_update_json, now_iso as _now_iso
from pulse.tasks.decomposer import decompose_prompt
try:
    from pulse.tasks.task_complexity import apply_complexity_routing as _apply_complexity_routing
except Exception:
    _apply_complexity_routing = lambda tasks: None

from pulse.tasks.dispatcher_lifecycle import (
    check_stale_claims, unblock_dependents, apply_capability_routing,
    archive_completed, gc_stale_dispatches, score_agents_for_task,
    _detect_agent_divergence,
)

# P10-31: Drift Diffusion Model for agent selection
_DDM_THRESHOLD, _DDM_NOISE, _DDM_MAX_ROUNDS = 3.0, 0.3, 10


def _ddm_select(candidates: list, scores: dict) -> str:
    """Drift Diffusion Model: accumulate noisy evidence until threshold."""
    import random as _r
    if not candidates:
        raise ValueError("No candidates for DDM selection")
    accumulators = {c: 0.0 for c in candidates}
    for _ in range(_DDM_MAX_ROUNDS):
        for c in candidates:
            accumulators[c] += scores.get(c, 0.5) + _r.gauss(0, _DDM_NOISE)
            if accumulators[c] >= _DDM_THRESHOLD:
                return c
    return max(accumulators, key=accumulators.get)


POLL_INTERVAL = 2.0


def _ts():
    return int(datetime.now(timezone.utc).timestamp())


class TaskDispatcher:
    """Lifecycle manager for the ASOP task board."""
    def __init__(self, verbose=False):
        self.verbose = verbose
        self.provider = os.environ.get("DISPATCHER_PROVIDER", "none").lower()
        self.model = os.environ.get("DISPATCHER_MODEL", "")
        self.endpoint = os.environ.get("DISPATCHER_ENDPOINT", "")
        self._hmac_key = os.environ.get("PULSE_BRAIN_KEY", "").encode() or None  # H4 HMAC

    def _sign_dispatch(self, dispatch_entry: dict) -> str:
        """HMAC-SHA256 sign dispatch. Returns hex digest."""
        if not self._hmac_key:
            return ""
        signable = json.dumps({
            "dispatch_id": dispatch_entry["dispatch_id"],
            "prompt": dispatch_entry["prompt"],
            "dispatched_at": dispatch_entry["dispatched_at"],
            "task_count": len(dispatch_entry.get("tasks", [])),
        }, sort_keys=True).encode()
        return hmac.new(self._hmac_key, signable, hashlib.sha256).hexdigest()

    def verify_dispatch(self, dispatch_entry: dict) -> bool:
        """Verify HMAC signature."""
        if not self._hmac_key:
            return True
        sig = dispatch_entry.get("_signature", "")
        if not sig:
            return False
        return hmac.compare_digest(sig, self._sign_dispatch(dispatch_entry))

    def dispatch(self, prompt: str, tasks: list[dict], founder_override: bool = False) -> str:
        """Write dispatch to task board atomically (C4). Returns dispatch_id."""
        try:  # Layer 11: compliance
            from pulse.workers.runner.worker_compliance import check_task_compliance
            for t in tasks:
                passed, reason = check_task_compliance(t)
                if not passed:
                    t["status"] = "rejected"; t["rejection_reason"] = reason
        except ImportError:
            pass
        try:  # Item 144: complexity routing
            _apply_complexity_routing([t for t in tasks if t.get("status") != "rejected"])
        except Exception:
            pass
        try:  # Phase 4: DDM-scored agent recommendation
            from pulse.tasks.evidence_accumulator import select_best_agent
            import json as _json
            reg_file = TASK_BOARD_FILE.parent / "agent_registry.json"
            if reg_file.exists():
                reg = _json.loads(reg_file.read_text(encoding="utf-8"))
                candidates = [a for a, d in reg.items() if d.get("status") in ("idle", "active", "booting")]
                if len(candidates) >= 2:
                    for t in tasks:
                        if t.get("status") == "rejected":
                            continue
                        best, scores = "", {}
                        try:
                            best, scores = select_best_agent(candidates, t)
                        except Exception:
                            pass
                        if not best:  # Item 182: DDM fallback
                            try:
                                _fb = {c: 0.5 for c in candidates}
                                best = _ddm_select(candidates, _fb); scores = _fb
                                t["_ddm_fallback"] = True
                            except Exception:
                                pass
                        if best:
                            t["recommended_agent"] = best
                        try:  # Item 117: prediction market bias by domain track record
                            domain = t.get("domain", "general")
                            from pulse.brain.metacognition.metamemory import get_agent_specialization as _gas
                            spec = _gas()
                            if spec:
                                _ds = {a: spec.get(a, {}).get(domain, {}).get("success_rate", 0.5) for a in candidates}
                                if _ds:
                                    t["_domain_recommended_agent"] = max(_ds, key=_ds.get)
                                    t["_domain_scores"] = _ds
                        except Exception:
                            pass
                        try:  # Item 52: runner-up counterfactual
                            if isinstance(scores, dict) and len(scores) >= 2:
                                sc = sorted(scores.items(), key=lambda x: float(x[1]) if isinstance(x[1], (int, float)) else 0, reverse=True)
                                if len(sc) >= 2 and all(isinstance(v, (int, float)) for _, v in sc[:2]):
                                    t["_runner_up"] = sc[1][0]; t["_runner_up_score"] = sc[1][1]
                        except Exception:
                            pass
        except Exception:
            pass
        dispatch_id = f"d_{_ts()}_{secrets.token_hex(4)}"  # G1
        dispatch_entry = {"dispatch_id": dispatch_id, "prompt": prompt,
                         "dispatched_at": _now_iso(), "tasks": tasks}
        if founder_override:
            dispatch_entry["founder_override"] = True
        sig = self._sign_dispatch(dispatch_entry)
        if sig:
            dispatch_entry["_signature"] = sig

        def _add_dispatch(board):
            board.setdefault("dispatches", []).append(dispatch_entry)
            board["version"] = board.get("version", 0) + 1
            return board
        atomic_update_json(TASK_BOARD_FILE, _add_dispatch, default={})
        
        try: # QR8 Gap 1: Pre-emptive Context Loading
            from pulse.daemons.neural.predictive_coding import pre_load_task_context
            for t in tasks:
                if t.get("status") == "open":
                    pre_load_task_context(t)
        except Exception:
            pass

        try:  # Item 70: mentalizing — track per-agent outcomes for prediction (Item 183: divergence detection)
            _AGENT_OUTCOMES = getattr(sys.modules[__name__], "_AGENT_OUTCOMES", {})
            for t in tasks:
                ag = t.get("recommended_agent", "")
                if ag:
                    k = f"{t.get('domain', 'general')}:{t.get('task_type', t.get('type', 'unknown'))}"
                    _AGENT_OUTCOMES.setdefault(k, []).append({"agent": ag, "outcome": t.get("status", "dispatched"),
                                                              "confidence": float(t.get("_runner_up_score", 0.5))})
                    _AGENT_OUTCOMES[k] = _AGENT_OUTCOMES[k][-20:]
            sys.modules[__name__]._AGENT_OUTCOMES = _AGENT_OUTCOMES
            _detect_agent_divergence(_AGENT_OUTCOMES)
        except Exception:
            pass
        if self.verbose:
            print(f"[DISPATCHER] Dispatched {len(tasks)} tasks ({dispatch_id})")
        return dispatch_id

    def decompose_with_llm(self, prompt: str) -> list[dict]:
        """Decompose prompt via LLM (fallback: empty list)."""
        return decompose_prompt(prompt, provider=self.provider, model=self.model,
                                endpoint=self.endpoint, verbose=self.verbose)

    def score_agents(self, task: dict) -> list[tuple[str, float]]:
        """Score agents for task. Returns sorted list."""
        return score_agents_for_task(task)

    def run(self):
        """Poll loop: stale claims, unblock deps, archive completed."""
        print("[DISPATCHER] Task Dispatcher running...")
        while True:
            try:
                def _lifecycle(board):
                    if not board.get("dispatches"):
                        return board
                    if self._hmac_key:  # H4: Reject tampered dispatches
                        for d in board.get("dispatches", []):
                            if not self.verify_dispatch(d):
                                if not d.get("_unsigned_flagged"):
                                    d["_unsigned_flagged"] = True
                                    if not d.get("founder_override"):
                                        for t in d.get("tasks", []):
                                            if t.get("status") == "open":
                                                t["status"] = "rejected"
                                                t["rejection_reason"] = "unsigned_dispatch"
                                        print(f"[DISPATCHER] REJECTED: Unsigned dispatch '{d.get('dispatch_id', '?')}'")
                                    else:
                                        print(f"[DISPATCHER] WARNING: Unsigned dispatch '{d.get('dispatch_id', '?')}' (Founder override)")
                            else:
                                d.pop("_unsigned_flagged", None)
                    modified = False
                    modified |= check_stale_claims(board, self.verbose)
                    modified |= unblock_dependents(board, self.verbose)
                    modified |= apply_capability_routing(board, self.verbose)
                    modified |= archive_completed(board, self.verbose)
                    modified |= gc_stale_dispatches(board, max_age_hours=6, verbose=self.verbose)
                    if modified:
                        board["version"] = board.get("version", 0) + 1
                    return board
                atomic_update_json(TASK_BOARD_FILE, _lifecycle, default={})
                time.sleep(POLL_INTERVAL)
            except Exception as e:
                if self.verbose:
                    print(f"[DISPATCHER] Error: {e}")
                time.sleep(POLL_INTERVAL)


if __name__ == "__main__":
    v = "--verbose" in sys.argv or "-v" in sys.argv
    TaskDispatcher(verbose=v).run()
