"""SkillSchool: detect patterns, propose skills, refine from feedback."""

from __future__ import annotations

import logging

from agntspace.llm.base import LLMProvider, Message
from agntspace.tasks.service import TaskService
from agntspace.vault.reader import VaultReader
from agntspace.vault.writer import VaultWriter

log = logging.getLogger(__name__)

_SKILL_PROPOSAL_PROMPT = """You are the AgntSpace SkillSchool engine. Analyze the task history below and identify repeatable patterns that could become reusable skills.

A skill is a structured instruction set (SKILL.md) that captures:
- What the task pattern is
- The user's preferences for how it should be done
- Step-by-step instructions the agent should follow

For each pattern you identify, produce a skill proposal in this format:

```skill
---
name: skill-name-slug
title: "Human Readable Title"
description: "One-line description"
category: work
triggers:
  - "keyword that activates this skill"
  - "another trigger phrase"
status: draft
author: agent
agent: main
---

## Instructions

Step-by-step instructions for the agent when this skill is triggered.
Include the user's observed preferences.
```

If no clear patterns exist, say "No skill proposals at this time."

Focus on tasks that were done 3+ times with similar patterns."""

_REFINEMENT_PROMPT = """You are the AgntSpace SkillSchool engine. Based on accumulated feedback for tasks related to a skill, propose refinements.

Current skill instructions:
{current_skill}

Feedback history:
{feedback}

Propose updated instructions that incorporate the feedback. Output the full revised skill body (just the ## Instructions section content)."""


class SkillSchoolService:
    """Detects patterns and proposes skills from task history."""

    def __init__(
        self,
        vault_reader: VaultReader,
        vault_writer: VaultWriter,
        task_service: TaskService,
        llm: LLMProvider,
    ) -> None:
        self._reader = vault_reader
        self._writer = vault_writer
        self._tasks = task_service
        self._llm = llm

    async def analyze_patterns(self) -> list[dict]:
        """Analyze completed tasks for repeatable patterns.

        Returns list of skill proposals.
        """
        tasks = self._tasks.list_tasks(status_filter="done")
        if len(tasks) < 3:
            return []

        # Build task summary for LLM
        task_lines = []
        for t in tasks[:30]:  # limit context
            task_lines.append(
                f"- {t.get('title', '?')} (domain: {t.get('domain', '?')}, "
                f"mode: {t.get('mode', '?')})"
            )
        task_summary = "\n".join(task_lines)

        response = await self._llm.complete(
            messages=[Message(role="user", content=task_summary)],
            system=_SKILL_PROPOSAL_PROMPT,
            max_tokens=2048,
            temperature=0.3,
        )

        proposals = self._parse_proposals(response.content)

        # Save proposals to vault/skills-review/
        for proposal in proposals:
            self._save_proposal(proposal)

        log.info("SkillSchool: %d skill proposals generated", len(proposals))
        return proposals

    def list_proposals(self) -> list[dict]:
        """List pending skill drafts under system/skills/pending/.

        Draft proposals written by `analyze_patterns` and the healer live in
        `system/skills/pending/{slug}/SKILL.md`. Approval is handled by
        `POST /api/orchestrator/simulate/proposals/approve-skill`, which moves
        the draft to `system/skills/custom/{slug}/` and sets `status: active`.
        """
        pending_dir = self._reader._resolve("system/skills/pending")  # noqa: SLF001
        if not pending_dir.is_dir():
            return []
        proposals: list[dict] = []
        for draft_dir in sorted(pending_dir.iterdir()):
            if not draft_dir.is_dir():
                continue
            skill_file = draft_dir / "SKILL.md"
            if not skill_file.is_file():
                continue
            try:
                logical = f"system/skills/pending/{draft_dir.name}/SKILL.md"
                doc = self._reader.read(logical)
                proposals.append({
                    "slug": draft_dir.name,
                    "title": doc.metadata.get("title", draft_dir.name),
                    "status": doc.metadata.get("status", "draft"),
                    "description": doc.metadata.get("description", ""),
                    "category": doc.metadata.get("category", ""),
                })
            except Exception:
                continue
        return proposals

    async def refine_from_feedback(self, skill_name: str) -> dict:
        """Refine a skill's instructions based on accumulated task feedback.

        Looks at completed tasks in the skill's domain, gathers feedback,
        and asks the LLM to revise the skill instructions.
        """
        import re

        # Find the skill file
        skill_path = f"system/skills/custom/{skill_name}/SKILL.md"
        if not self._reader.exists(skill_path):
            return {"error": f"Skill not found: {skill_name}"}

        doc = self._reader.read(skill_path)
        current_skill = doc.content

        # Gather feedback from completed tasks in this skill's domain
        tasks = self._tasks.list_tasks(status_filter="done")
        feedback_lines = []
        for t in tasks:
            fb = t.get("feedback")
            if fb and skill_name in t.get("domain", ""):
                feedback_lines.append(
                    f"- Task: {t.get('title', '?')} — Feedback: {fb}"
                )

        if not feedback_lines:
            return {"skill": skill_name, "status": "no_feedback"}

        feedback_text = "\n".join(feedback_lines[:20])

        response = await self._llm.complete(
            messages=[Message(role="user", content="Refine this skill based on feedback.")],
            system=_REFINEMENT_PROMPT.format(
                current_skill=current_skill,
                feedback=feedback_text,
            ),
            max_tokens=2048,
            temperature=0.3,
        )

        refined_instructions = response.content

        # Replace the ## Instructions section
        new_content = re.sub(
            r"(## Instructions\n).*",
            f"\\1\n{refined_instructions}",
            doc.raw,
            flags=re.DOTALL,
        )
        self._writer.write(skill_path, new_content)

        log.info("SkillSchool: refined skill '%s' from %d feedback items", skill_name, len(feedback_lines))
        return {
            "skill": skill_name,
            "status": "refined",
            "feedback_count": len(feedback_lines),
        }

    def _save_proposal(self, proposal: dict) -> None:
        """Save a drafted skill to system/skills/pending/{name}/SKILL.md.

        From there, users approve via the simulate tab's approve-skill endpoint,
        which promotes the draft to `system/skills/custom/{name}/`.
        """
        name = proposal.get("name", "unnamed")
        path = f"system/skills/pending/{name}/SKILL.md"
        self._writer.write(path, proposal.get("content", ""))

    # ── Zero-effect learning loop (Rule #17) ──
    #
    # When Agent.invoke_skill raises SkillZeroEffect, the watcher enqueues
    # a work queue retry AND the decision log records a skill_invoke
    # decision with contract_result="zero_effect". SkillSchool reads those
    # decisions and proposes action plan items to fix the skill.
    #
    # The learning cycle:
    #   1. invoke_skill raises SkillZeroEffect → decision logged
    #   2. Heartbeat calls analyze_zero_effects() every cycle
    #   3. SkillSchool reads recent zero_effect decisions
    #   4. Groups by skill name, counts frequency
    #   5. For skills with 2+ zero-effects in the last 24h:
    #      a. Proposes an action plan item (auto-fixable type=tune_skill)
    #         with what: the skill name, why: the failure count, who: user,
    #         how: "add few-shot examples" or "upgrade model tier"
    #   6. For skills with 5+ zero-effects: proposes model tier upgrade
    #   7. The action plan surfaces in the Simulate/Agents page for user
    #      approval — user can apply the fix or reject it.

    async def analyze_zero_effects(self) -> list[dict]:
        """Read recent zero-effect decisions and propose fixes.

        Called by the heartbeat (every 15 minutes). Returns a list of
        action plan item proposals, each with ``what``, ``why``, ``who``,
        ``how``, and ``fix_type``.
        """
        if not hasattr(self, "_decisions") or not self._decisions:
            return []
        try:
            rows = await self._decisions.query(
                action_type="skill_invoke",
                since=_24h_ago_iso(),
                limit=100,
            )
        except Exception:
            log.debug("SkillSchool: could not query decisions", exc_info=True)
            return []

        # Group zero-effect decisions by skill name
        zero_counts: dict[str, int] = {}
        zero_details: dict[str, list[dict]] = {}
        for row in rows:
            if row.get("contract_result") != "zero_effect":
                continue
            target = row.get("action_target", "")
            zero_counts[target] = zero_counts.get(target, 0) + 1
            zero_details.setdefault(target, []).append(row)

        proposals: list[dict] = []
        for skill_name, count in zero_counts.items():
            if count < 2:
                continue  # noise threshold

            details = zero_details.get(skill_name, [])
            latest = details[-1] if details else {}
            agent = latest.get("actor", "unknown")

            # Propose fix based on frequency
            if count >= 5:
                proposals.append({
                    "what": f"Skill '{skill_name}' has {count} zero-effect failures in 24h",
                    "why": (
                        f"The current LLM model cannot reliably execute this skill. "
                        f"The subagent ({agent}) was dispatched {count} times but "
                        f"produced no verifiable mutations (zero tool calls, zero "
                        f"vault writes, zero graph triples). The few-shot examples "
                        f"in the skill's SKILL.md are insufficient for this model."
                    ),
                    "who": "user",
                    "how": [
                        f"1. Check the model tier for '{agent}' — consider upgrading to 'capable' or 'reasoning'",
                        f"2. Or add more explicit few-shot examples to the skill's SKILL.md (see defaults/skills/core/{skill_name}/SKILL.md)",
                        "3. Or set agent_mediated_flows=false in config.toml to fall back to direct service calls while debugging",
                    ],
                    "fix_type": "tune_skill",
                    "skill": skill_name,
                    "agent": agent,
                    "count": count,
                    "severity": "high",
                })
            else:
                proposals.append({
                    "what": f"Skill '{skill_name}' had {count} zero-effect failure(s) in 24h",
                    "why": (
                        f"The subagent ({agent}) was dispatched but didn't call "
                        f"the expected tools. This may be transient (model load, "
                        f"prompt truncation) or a sign the skill's tool grants "
                        f"need adjustment."
                    ),
                    "who": "auto",
                    "how": [
                        "1. Review the skill's tools_granted in SKILL.md — ensure the right tools are listed",
                        "2. Check if the few-shot examples match the current tool names",
                    ],
                    "fix_type": "tune_skill",
                    "skill": skill_name,
                    "agent": agent,
                    "count": count,
                    "severity": "medium",
                })

        if proposals:
            log.info("SkillSchool: %d zero-effect skill proposals", len(proposals))
        return proposals

    # ──────────────────────────────────────────────────────────────
    # Reply-signal analysis — the self-correction loop for chat output.
    #
    # Two signals flow into this loop from reply_guardrails.py:
    #   - capability_hallucinated — agent refused a capability it has
    #   - fabrication_in_chat     — placeholder tokens were scrubbed
    #
    # Both are written to the decision log with action_type="reply_signal".
    # The capability_hallucinated ones carry action_target=<tool_name> so
    # grouping by target gives a clean frequency map of which capabilities
    # the current model fails on. The fabrication_in_chat ones are written
    # via activity_logger (no decision target) and we tally those via a
    # secondary activity query.
    #
    # Threshold: 2+ capability hallucinations on the same tool in 24h =
    # propose a skill tuning. 5+ = propose model tier upgrade or new skill.

    async def analyze_reply_signals(self) -> list[dict]:
        """Read recent reply-guardrail signals and propose fixes.

        Called by the heartbeat alongside analyze_zero_effects(). Same
        output shape so the caller can treat them uniformly.
        """
        if not hasattr(self, "_decisions") or not self._decisions:
            return []
        try:
            rows = await self._decisions.query(
                action_type="reply_signal",
                since=_24h_ago_iso(),
                limit=100,
            )
        except Exception:
            log.debug("SkillSchool: could not query reply_signal decisions", exc_info=True)
            return []

        # Group capability_hallucinated by tool name
        hallucinated: dict[str, int] = {}
        samples: dict[str, list[dict]] = {}
        for row in rows:
            if row.get("contract_result") != "capability_hallucinated":
                continue
            tool = row.get("action_target", "unknown")
            hallucinated[tool] = hallucinated.get(tool, 0) + 1
            samples.setdefault(tool, []).append(row)

        proposals: list[dict] = []
        for tool, count in hallucinated.items():
            if count < 2:
                continue  # noise threshold, same as zero-effect

            latest = samples.get(tool, [{}])[-1]
            details_raw = latest.get("details", {})
            if isinstance(details_raw, str):
                try:
                    import json
                    details_raw = json.loads(details_raw)
                except Exception:
                    details_raw = {}
            phrase = details_raw.get("refusal_phrase", "") if isinstance(details_raw, dict) else ""
            keyword = details_raw.get("capability_keyword", "") if isinstance(details_raw, dict) else ""

            if count >= 5:
                proposals.append({
                    "what": (
                        f"Agent repeatedly refused capability '{tool}' "
                        f"(seen {count}× in 24h) despite having the tool."
                    ),
                    "why": (
                        f'The agent keeps replying with refusals like "{phrase}" '
                        f'when the user asks to "{keyword}". The {tool} tool is '
                        f"registered and contract-allowed, but no skill teaches "
                        f"the agent when to invoke it. Users have to babysit "
                        f"every such interaction — this is exactly the UX "
                        f"anti-pattern Rule 12 forbids."
                    ),
                    "who": "user",
                    "how": [
                        f"1. Check whether a skill exists that grants `{tool}` in its tools_granted list.",
                        "2. If not, create a skill that teaches the flow. Use email-compose as a template for tool-chaining skills.",
                        f"3. If a skill exists but isn't triggering, add the natural-language phrases (e.g., '{keyword}') to its triggers: list.",
                        "4. Consider upgrading the main agent to a more capable model tier — some small models hallucinate refusals even with skills present.",
                    ],
                    "fix_type": "tune_skill",
                    "capability": tool,
                    "count": count,
                    "severity": "high",
                })
            else:
                proposals.append({
                    "what": (
                        f"Agent refused capability '{tool}' {count}× in 24h "
                        f"despite having the tool."
                    ),
                    "why": (
                        f'Refusal phrase "{phrase}" matched near user keyword '
                        f'"{keyword}", but {tool} is in the tool registry. '
                        f"This is a hallucinated refusal — either the skill "
                        f"teaching this flow is missing or its triggers don't "
                        f"cover the user's phrasing."
                    ),
                    "who": "auto",
                    "how": [
                        f"1. Review skills that reference {tool} — add explicit trigger phrases covering '{keyword}'.",
                        "2. If no skill covers this tool, create one (use email-compose as reference).",
                    ],
                    "fix_type": "tune_skill",
                    "capability": tool,
                    "count": count,
                    "severity": "medium",
                })

        if proposals:
            log.info(
                "SkillSchool: %d reply-signal proposals from capability hallucinations",
                len(proposals),
            )
        return proposals

    @staticmethod
    def _parse_proposals(text: str) -> list[dict]:
        """Parse skill proposals from LLM output."""
        proposals = []
        parts = text.split("```skill")
        for part in parts[1:]:
            end = part.find("```")
            content = part.strip() if end == -1 else part[:end].strip()

            # Extract name from frontmatter
            import re

            name_match = re.search(r"name:\s*(\S+)", content)
            name = name_match.group(1) if name_match else f"skill-{len(proposals)}"

            proposals.append({"name": name, "content": content})

        return proposals


def _24h_ago_iso() -> str:
    """Return ISO timestamp for 24 hours ago."""
    from datetime import UTC, datetime, timedelta
    return (datetime.now(UTC) - timedelta(hours=24)).strftime("%Y-%m-%dT%H:%M:%SZ")
