"""Agent Insights Engine: the agent's proactive intelligence layer.

Analyzes the current state of goals, tasks, knowledge bases, memory,
and activity to produce actionable insights and suggestions. This is
what makes the agent a true partner rather than a passive responder.

Called by the heartbeat every 15 minutes. Insights are served to the
dashboard and injected into the chat system prompt so the agent can
lead conversations with what it's been thinking about.
"""

from __future__ import annotations

import json
import logging
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from agntspace.activity.logger import ActivityLogger
    from agntspace.coach.service import CoachService
    from agntspace.kb.service import KnowledgeBaseService
    from agntspace.memory.graph import KnowledgeGraph
    from agntspace.tasks.service import TaskService
    from agntspace.vault.reader import VaultReader

log = logging.getLogger(__name__)


@dataclass
class AgentInsight:
    """A single observation or suggestion from the agent."""

    id: str
    category: str  # goal_progress, stale_knowledge, pipeline_health,
    #                task_health, unlinked_work, memory_health, relationship
    severity: str  # info, suggestion, warning
    title: str
    description: str
    suggested_action: str
    action_type: str | None = None  # auto, manual, chat
    action_data: dict | None = None
    created_at: str = ""
    dismissed: bool = False


@dataclass
class InsightsSnapshot:
    """A point-in-time collection of agent insights."""

    insights: list[AgentInsight] = field(default_factory=list)
    generated_at: str = ""
    next_refresh: str = ""


class AgentInsightsEngine:
    """Proactive intelligence that notices things the user hasn't asked about.

    This is the engine that shifts AgntSpace from UI-first to agent-first.
    It observes, analyzes, and suggests — without being asked.
    """

    def __init__(
        self,
        vault_reader: VaultReader,
        *,
        coach: CoachService | None = None,
        task_service: TaskService | None = None,
        kb_service: KnowledgeBaseService | None = None,
        knowledge_graph: KnowledgeGraph | None = None,
        activity_logger: ActivityLogger | None = None,
        vault_dir: str | Path | None = None,
    ) -> None:
        self._reader = vault_reader
        self._coach = coach
        self._tasks = task_service
        self._kb = kb_service
        self._graph = knowledge_graph
        self._activity = activity_logger
        self._vault_dir = Path(vault_dir) if vault_dir else None

        # Current snapshot — refreshed by generate()
        self._snapshot = InsightsSnapshot()
        # Dismissed insight IDs (persisted to vault)
        self._dismissed: set[str] = set()
        self._state_path: Path | None = None
        if self._vault_dir:
            self._state_path = self._vault_dir / "agntspace" / ".insights-state.json"
            self._load_state()

    # ── Public API ────────────────────────────────────────────────

    async def generate(self) -> InsightsSnapshot:
        """Run all insight analyzers and produce a fresh snapshot.

        Called by the heartbeat every 15 minutes. Each analyzer is
        independent and wrapped in try/except — one failure never
        blocks others.
        """
        insights: list[AgentInsight] = []
        now = datetime.now(UTC)
        now_iso = now.isoformat()

        analyzers = [
            self._analyze_goal_progress,
            self._analyze_task_health,
            self._analyze_pipeline_health,
            self._analyze_stale_knowledge,
            self._analyze_unlinked_work,
            self._analyze_memory_health,
        ]

        for analyzer in analyzers:
            try:
                results = await analyzer()
                for r in results:
                    r.created_at = now_iso
                insights.extend(results)
            except Exception:
                log.debug("Insight analyzer %s failed", analyzer.__name__, exc_info=True)

        # Filter out dismissed insights (by title+category fingerprint)
        active = [i for i in insights if self._fingerprint(i) not in self._dismissed]

        # Sort: warnings first, then suggestions, then info
        severity_order = {"warning": 0, "suggestion": 1, "info": 2}
        active.sort(key=lambda i: severity_order.get(i.severity, 3))

        self._snapshot = InsightsSnapshot(
            insights=active,
            generated_at=now.isoformat(),
            next_refresh=(now + timedelta(minutes=15)).isoformat(),
        )

        # Emit activity event if there are notable insights
        warnings = [i for i in active if i.severity == "warning"]
        if warnings and self._activity:
            self._activity.log(
                "system",
                "agent_insights",
                f"Agent noticed {len(warnings)} issue(s) and {len(active) - len(warnings)} suggestion(s)",
                {"warning_count": len(warnings), "total": len(active)},
                importance="medium",
            )

        return self._snapshot

    def get_snapshot(self) -> InsightsSnapshot:
        """Return the most recent insights snapshot."""
        return self._snapshot

    def dismiss(self, insight_id: str) -> bool:
        """Dismiss an insight so it doesn't reappear."""
        for i in self._snapshot.insights:
            if i.id == insight_id:
                i.dismissed = True
                self._dismissed.add(self._fingerprint(i))
                self._save_state()
                return True
        return False

    def get_chat_nudges(self, max_count: int = 3) -> str:
        """Build a nudge context string for the chat system prompt.

        Returns the top N insights as a compact block that the agent
        can naturally reference when starting a conversation.
        """
        active = [i for i in self._snapshot.insights if not i.dismissed]
        if not active:
            return ""

        top = active[:max_count]
        lines = ["## Agent Observations (things you noticed — mention naturally if relevant)"]
        for i, insight in enumerate(top, 1):
            lines.append(f"{i}. **{insight.title}** — {insight.description}")
            if insight.suggested_action:
                lines.append(f"   → Suggestion: {insight.suggested_action}")
        return "\n".join(lines)

    def to_dict(self) -> dict:
        """Serialize snapshot for API response."""
        return {
            "insights": [asdict(i) for i in self._snapshot.insights if not i.dismissed],
            "generated_at": self._snapshot.generated_at,
            "next_refresh": self._snapshot.next_refresh,
            "total": len(self._snapshot.insights),
            "dismissed_count": len(self._dismissed),
        }

    # ── Analyzers ─────────────────────────────────────────────────

    async def _analyze_goal_progress(self) -> list[AgentInsight]:
        """Detect stalled goals, approaching deadlines, budget issues."""
        insights: list[AgentInsight] = []
        if not self._coach:
            return insights

        # Stalled goals
        try:
            stalls = self._coach.detect_stalls(stall_days=5)
            for s in stalls:
                insights.append(AgentInsight(
                    id=f"goal-stall-{s['slug']}",
                    category="goal_progress",
                    severity="warning",
                    title=f"Goal \"{s['goal']}\" has stalled",
                    description=f"No activity in {s['days_inactive']} days. {s.get('nudge', '')}",
                    suggested_action="Consider breaking it into smaller tasks or revisiting the goal's relevance.",
                    action_type="chat",
                ))
        except Exception:
            log.debug("Goal stall detection failed", exc_info=True)

        # Approaching deadlines + budget warnings
        try:
            goals = self._coach.list_goals(status_filter="active")
            today = datetime.now(UTC).date()
            for g in goals:
                # Deadline checks (only when target_date is set)
                target = g.get("target_date", "")
                if target:
                    try:
                        deadline = datetime.fromisoformat(target).date()
                        days_left = (deadline - today).days
                        if days_left < 0:
                            insights.append(AgentInsight(
                                id=f"goal-overdue-{g['slug']}",
                                category="goal_progress",
                                severity="warning",
                                title=f"Goal \"{g['title']}\" is overdue",
                                description=f"Deadline was {abs(days_left)} day(s) ago ({target}).",
                                suggested_action="Update the deadline, break into subtasks, or mark as achieved/abandoned.",
                                action_type="chat",
                            ))
                        elif days_left <= 7:
                            insights.append(AgentInsight(
                                id=f"goal-deadline-{g['slug']}",
                                category="goal_progress",
                                severity="suggestion",
                                title=f"Goal \"{g['title']}\" due in {days_left} day(s)",
                                description=f"Deadline: {target}. Success metric: {g.get('success_metric', 'not defined')}.",
                                suggested_action="Focus effort here this week.",
                                action_type="chat",
                            ))
                    except (ValueError, TypeError):
                        pass  # malformed date — skip deadline check

                # Budget warnings (independent of deadline)
                budget = g.get("budget", 0)
                consumed = g.get("budget_consumed", 0)
                if budget > 0 and consumed > 0:
                    ratio = consumed / budget
                    if ratio > 0.8:
                        insights.append(AgentInsight(
                            id=f"goal-budget-{g['slug']}",
                            category="goal_progress",
                            severity="warning" if ratio > 0.95 else "suggestion",
                            title=f"Goal \"{g['title']}\" budget {int(ratio * 100)}% used",
                            description=f"${consumed:.2f} of ${budget:.2f} consumed across {g.get('dispatches', 0)} dispatches.",
                            suggested_action="Increase the budget or pause autonomous pursuit.",
                            action_type="manual",
                        ))
        except Exception:
            log.debug("Goal deadline analysis failed", exc_info=True)

        return insights

    async def _analyze_task_health(self) -> list[AgentInsight]:
        """Detect blocked tasks, orphan tasks, overdue items."""
        insights: list[AgentInsight] = []
        if not self._tasks:
            return insights

        try:
            tasks = self._tasks.list_tasks()
        except Exception:
            return insights

        blocked = [t for t in tasks if t.get("status") == "blocked"]
        if blocked:
            names = ", ".join(t.get("title", "?") for t in blocked[:3])
            severity = "warning" if len(blocked) >= 2 else "suggestion"
            insights.append(AgentInsight(
                id="tasks-blocked",
                category="task_health",
                severity=severity,
                title=f"{len(blocked)} task(s) blocked",
                description=f"Blocked: {names}{'...' if len(blocked) > 3 else ''}",
                suggested_action="Review blockers — some may be resolvable or should be reassigned.",
                action_type="chat",
            ))

        # Tasks without a project (orphans)
        orphans = [t for t in tasks if not t.get("project") and t.get("status") not in ("done", "cancelled")]
        if orphans:
            names = ", ".join(t.get("title", "?") for t in orphans[:3])
            insights.append(AgentInsight(
                id="tasks-orphan",
                category="unlinked_work",
                severity="info",
                title=f"{len(orphans)} task(s) without a project",
                description=f"Unscoped: {names}",
                suggested_action="Link these to a project for better organization.",
                action_type="chat",
            ))

        # Overdue
        from agntspace.infra.timezone import local_today
        today = local_today()
        overdue = [
            t for t in tasks
            if t.get("due") and t["due"] < today and t.get("status") not in ("done", "cancelled")
        ]
        if overdue:
            insights.append(AgentInsight(
                id="tasks-overdue",
                category="task_health",
                severity="warning",
                title=f"{len(overdue)} overdue task(s)",
                description=", ".join(t.get("title", "?") for t in overdue[:3]),
                suggested_action="Update due dates or complete these tasks.",
                action_type="chat",
            ))

        return insights

    async def _analyze_pipeline_health(self) -> list[AgentInsight]:
        """Check KB inboxes, compile state, article consolidation needs."""
        insights: list[AgentInsight] = []
        if not self._kb:
            return insights

        try:
            kbs = self._kb.list_knowledge_bases()
        except Exception:
            return insights

        for kb in kbs:
            slug = kb.get("slug", "")
            if not slug:
                continue

            # Check inbox for pending files
            try:
                inbox_files = self._reader.list_files(f"knowledge/{slug}/inbox", "*")
                # Filter out quarantine
                pending = [f for f in inbox_files if ".quarantine" not in str(f)]
                if len(pending) > 0:
                    insights.append(AgentInsight(
                        id=f"kb-inbox-{slug}",
                        category="pipeline_health",
                        severity="suggestion",
                        title=f"KB \"{slug}\" has {len(pending)} file(s) waiting",
                        description="Inbox has unprocessed files ready for ingestion.",
                        suggested_action=f"Run the wiki pipeline for {slug}.",
                        action_type="auto",
                        action_data={"action": "ingest", "kb_slug": slug},
                    ))
            except Exception:
                pass

            # Check for quarantined files (failed ingest, need attention)
            try:
                quarantine_files = self._reader.list_files(
                    f"knowledge/{slug}/inbox/.quarantine", "*"
                )
                if quarantine_files:
                    insights.append(AgentInsight(
                        id=f"kb-quarantine-{slug}",
                        category="pipeline_health",
                        severity="warning",
                        title=f"KB \"{slug}\" has {len(quarantine_files)} quarantined file(s)",
                        description="Files that failed ingestion — may need a higher model tier or manual review.",
                        suggested_action="Check quarantine and retry or remove the files.",
                        action_type="manual",
                    ))
            except Exception:
                pass

            # Check for sources ingested but wiki not yet compiled
            try:
                library_files = self._reader.list_files(f"knowledge/{slug}/library", "*")
                wiki_files = self._reader.list_files(f"knowledge/{slug}/wiki", "*.md")
                # Exclude index/meta files from wiki count
                real_articles = [f for f in wiki_files if not f.name.startswith("_")]
                if library_files and not real_articles:
                    insights.append(AgentInsight(
                        id=f"kb-needs-compile-{slug}",
                        category="pipeline_health",
                        severity="suggestion",
                        title=f"KB \"{slug}\" has sources but no wiki articles",
                        description=f"{len(library_files)} source(s) in library — compile to generate wiki.",
                        suggested_action=f"Run compile for {slug} to turn sources into wiki articles.",
                        action_type="auto",
                        action_data={"action": "compile", "kb_slug": slug},
                    ))
            except Exception:
                pass

            # Check for articles needing consolidation (3+ update markers)
            try:
                wiki_files = self._reader.list_files(f"knowledge/{slug}/wiki", "*.md")
                for wf in wiki_files:
                    if wf.name.startswith("_"):
                        continue
                    try:
                        content = self._reader.read(f"knowledge/{slug}/wiki/{wf.name}")
                        text = content.content if hasattr(content, "content") else str(content)
                        marker_count = text.count("Updated from compile")
                        if marker_count >= 3:
                            insights.append(AgentInsight(
                                id=f"kb-consolidate-{slug}-{wf.stem}",
                                category="stale_knowledge",
                                severity="suggestion",
                                title=f"Article \"{wf.stem}\" needs consolidation",
                                description=f"{marker_count} compile updates stacked — content may be repetitive.",
                                suggested_action="Consolidate into a single coherent article.",
                                action_type="auto",
                                action_data={"action": "consolidate", "kb_slug": slug, "article": wf.stem},
                            ))
                    except Exception:
                        pass
            except Exception:
                pass

        return insights

    async def _analyze_stale_knowledge(self) -> list[AgentInsight]:
        """Detect wiki articles that haven't been updated in a long time."""
        insights: list[AgentInsight] = []
        if not self._kb:
            return insights

        stale_threshold = datetime.now(UTC) - timedelta(days=30)

        try:
            kbs = self._kb.list_knowledge_bases()
        except Exception:
            return insights

        stale_count = 0
        for kb in kbs:
            slug = kb.get("slug", "")
            if not slug:
                continue
            try:
                wiki_files = self._reader.list_files(f"knowledge/{slug}/wiki", "*.md")
                for wf in wiki_files:
                    if wf.name.startswith("_"):
                        continue
                    try:
                        mtime = datetime.fromtimestamp(wf.stat().st_mtime, tz=UTC)
                        if mtime < stale_threshold:
                            stale_count += 1
                    except Exception:
                        pass
            except Exception:
                pass

        if stale_count >= 5:
            insights.append(AgentInsight(
                id="kb-stale-articles",
                category="stale_knowledge",
                severity="info",
                title=f"{stale_count} wiki article(s) haven't been updated in 30+ days",
                description="Consider running a lint pass to check for stale claims.",
                suggested_action="Run wiki lint or add fresh sources to keep knowledge current.",
                action_type="auto",
                action_data={"action": "lint"},
            ))

        return insights

    async def _analyze_unlinked_work(self) -> list[AgentInsight]:
        """Detect projects without goals, goals without projects, etc."""
        insights: list[AgentInsight] = []

        # Projects without goals
        try:
            project_files = self._reader.list_files("projects", "*/PROJECT.md")
            for pf in project_files:
                try:
                    doc = self._reader.read(f"projects/{pf.parent.name}/PROJECT.md")
                    meta = doc.metadata
                    related_goals = meta.get("related_goals", [])
                    status = meta.get("status", "active")
                    if status == "active" and not related_goals:
                        title = meta.get("title", pf.parent.name)
                        insights.append(AgentInsight(
                            id=f"project-no-goal-{pf.parent.name}",
                            category="unlinked_work",
                            severity="suggestion",
                            title=f"Project \"{title}\" has no linked goals",
                            description="Projects connected to goals get tracked by the coaching system.",
                            suggested_action="Link a goal to this project so progress is monitored.",
                            action_type="chat",
                        ))
                except Exception:
                    pass
        except Exception:
            pass

        # Goals without any projects referencing them
        if self._coach:
            try:
                goals = self._coach.list_goals(status_filter="active")
                project_files = self._reader.list_files("projects", "*/PROJECT.md")
                all_referenced_goals: set[str] = set()
                for pf in project_files:
                    try:
                        doc = self._reader.read(f"projects/{pf.parent.name}/PROJECT.md")
                        for g in doc.metadata.get("related_goals", []):
                            all_referenced_goals.add(str(g))
                    except Exception:
                        pass

                for g in goals:
                    if g["slug"] not in all_referenced_goals:
                        insights.append(AgentInsight(
                            id=f"goal-no-project-{g['slug']}",
                            category="unlinked_work",
                            severity="info",
                            title=f"Goal \"{g['title']}\" isn't linked to any project",
                            description="Stand-alone goals can't leverage project resources or KB research.",
                            suggested_action="Create a project for this goal or link it to an existing one.",
                            action_type="chat",
                        ))
            except Exception:
                pass

        return insights

    async def _analyze_memory_health(self) -> list[AgentInsight]:
        """Check memory compaction freshness and graph health."""
        insights: list[AgentInsight] = []

        # Check if weekly compaction is stale
        try:
            week_file = self._reader.read("memory/summaries/week.md")
            if hasattr(week_file, "metadata"):
                generated = week_file.metadata.get("generated_at", "")
                if generated:
                    gen_dt = datetime.fromisoformat(generated.replace("Z", "+00:00"))
                    age_days = (datetime.now(UTC) - gen_dt).days
                    if age_days > 10:
                        insights.append(AgentInsight(
                            id="memory-stale-week",
                            category="memory_health",
                            severity="suggestion",
                            title="Weekly memory summary is stale",
                            description=f"Last compacted {age_days} days ago. Recent context may be missing from long-term memory.",
                            suggested_action="Run memory compaction to capture recent activity.",
                            action_type="auto",
                            action_data={"action": "compact", "layer": "week"},
                        ))
        except Exception:
            pass

        # Check graph health if available
        if self._graph:
            try:
                stats = self._graph.get_stats()
                orphaned = stats.get("orphaned_triples", 0)
                contradictions = stats.get("contradictions", 0)
                if contradictions > 3:
                    insights.append(AgentInsight(
                        id="memory-contradictions",
                        category="memory_health",
                        severity="warning",
                        title=f"{contradictions} contradictions in the knowledge graph",
                        description="Conflicting facts may confuse the agent's reasoning.",
                        suggested_action="Review and resolve contradictions in the memory graph.",
                        action_type="manual",
                    ))
                if orphaned > 10:
                    insights.append(AgentInsight(
                        id="memory-orphans",
                        category="memory_health",
                        severity="info",
                        title=f"{orphaned} orphaned triples in the knowledge graph",
                        description="References to deleted entities.",
                        suggested_action="Run memory health repair.",
                        action_type="auto",
                        action_data={"action": "repair_graph"},
                    ))
            except Exception:
                pass

        return insights

    # ── Persistence ───────────────────────────────────────────────

    def _fingerprint(self, insight: AgentInsight) -> str:
        """Stable fingerprint for dedup across regenerations."""
        return f"{insight.category}:{insight.id}"

    def _load_state(self) -> None:
        """Load dismissed insight fingerprints from disk."""
        if not self._state_path or not self._state_path.exists():
            return
        try:
            data = json.loads(self._state_path.read_text(encoding="utf-8"))
            self._dismissed = set(data.get("dismissed", []))
        except Exception:
            log.debug("Failed to load insights state", exc_info=True)

    _MAX_DISMISSED = 500

    def _save_state(self) -> None:
        """Persist dismissed fingerprints to disk.

        Caps the set at _MAX_DISMISSED to prevent unbounded growth.
        When trimming, we drop arbitrary entries since fingerprints
        have no timestamp — the worst case is a previously-dismissed
        insight reappearing once, which the user can dismiss again.
        """
        if not self._state_path:
            return
        try:
            # Trim if over cap
            if len(self._dismissed) > self._MAX_DISMISSED:
                keep = list(self._dismissed)[-self._MAX_DISMISSED:]
                self._dismissed = set(keep)
            self._state_path.parent.mkdir(parents=True, exist_ok=True)
            self._state_path.write_text(
                json.dumps({"dismissed": list(self._dismissed)}, indent=2),
                encoding="utf-8",
            )
        except Exception:
            log.debug("Failed to save insights state", exc_info=True)
