"""PolicyManager — self-organizing policy lifecycle from graph patterns.

Policies are **not** hardcoded or seeded.  They emerge organically as the
system observes recurring decision→action patterns across runs and feedback.

The lifecycle is:

1. ``discover_patterns()`` — mine the graph for recurring action patterns.
2. ``auto_evolve()``       — discover *and* promote new patterns that are
   not already tracked as policies (called automatically after each run).
3. ``promote_pattern()``   — turn a single discovered pattern into a policy.
4. ``seed_policy()``       — manual override (admin use only).
5. ``get_policy()`` / ``list_policies()`` — read helpers.
"""

from __future__ import annotations

import uuid
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any

import structlog

if TYPE_CHECKING:
    from koan.graph.client import GraphClient

log = structlog.get_logger()


@dataclass
class DiscoveredPattern:
    """A recurring pattern mined from the decision graph."""

    decision_type: str
    tool_sequence: list[str]
    frequency: int
    success_rate: float
    sample_entity_ids: list[str] = field(default_factory=list)
    description: str = ""

    @property
    def suggested_policy_name(self) -> str:
        """Generate a human-readable policy name from the pattern."""
        tools = " → ".join(self.tool_sequence) if self.tool_sequence else "no-tool"
        return f"auto:{self.decision_type}:{tools}"


class PolicyManager:
    """Self-organizing policy manager — policies emerge from observed patterns.

    No domain-specific seeding required.  As the system processes runs,
    ``auto_evolve()`` mines the graph for recurring decision→action patterns
    and promotes qualifying ones to active policies automatically.

    Usage::

        pm = PolicyManager(graph_client)

        # After each agent run, let policies evolve organically
        promoted = await pm.auto_evolve()
        # promoted = ["auto:classification:lookup_account", ...]

        # Or discover without promoting (inspection only)
        patterns = await pm.discover_patterns(min_frequency=3, min_success_rate=0.8)
    """

    def __init__(
        self,
        client: GraphClient,
        *,
        min_frequency: int = 3,
        min_success_rate: float = 0.7,
    ) -> None:
        self._client = client
        self._min_frequency = min_frequency
        self._min_success_rate = min_success_rate

    # ── Seed & create ─────────────────────────────────────────────────

    async def seed_policy(
        self,
        *,
        name: str,
        description: str,
        rules: list[str] | None = None,
        version: str = "1.0",
        source: str = "manual",
        policy_id: str | None = None,
    ) -> str:
        """Create a new policy node in the graph.

        Returns the policy_id.
        """
        pid = policy_id or str(uuid.uuid4())

        await self._client.execute_write(
            """
            MERGE (p:Policy {policy_id: $policy_id})
            ON CREATE SET
                p.name = $name,
                p.description = $description,
                p.version = $version,
                p.source = $source,
                p.rules = $rules
            ON MATCH SET
                p.name = $name,
                p.description = $description,
                p.version = $version,
                p.source = $source,
                p.rules = $rules
            """,
            {
                "policy_id": pid,
                "name": name,
                "description": description,
                "version": version,
                "source": source,
                "rules": rules or [],
            },
        )

        log.info("policy.seeded", policy_id=pid, name=name, source=source)
        return pid

    # ── Pattern discovery ─────────────────────────────────────────────

    async def discover_patterns(
        self,
        *,
        min_frequency: int = 3,
        min_success_rate: float = 0.7,
        limit: int = 10,
    ) -> list[DiscoveredPattern]:
        """Mine the graph for recurring decision→action patterns.

        Groups decisions by (decision_type, tool_name) and filters by
        frequency and success rate.
        """
        records = await self._client.execute(
            """
            MATCH (d:Decision)-[:LED_TO]->(a:Action)
            OPTIONAL MATCH (d)<-[:CONTAINS]-(r:Run)-[:PRODUCED]->(o:Outcome)
            OPTIONAL MATCH (d)-[:REFERENCED]->(e:Entity)
            WITH d.decision_type AS dtype,
                 a.tool_name AS tool,
                 count(d) AS freq,
                 collect(DISTINCT o.status) AS statuses,
                 collect(DISTINCT e.entity_id) AS entities
            WHERE freq >= $min_freq
            RETURN dtype, tool, freq, statuses, entities
            ORDER BY freq DESC
            LIMIT $limit
            """,
            {"min_freq": min_frequency, "limit": limit},
        )

        patterns: list[DiscoveredPattern] = []
        for r in records:
            statuses = r.data.get("statuses", [])
            total = len(statuses) if statuses else 1
            successes = sum(1 for s in statuses if s == "success")
            success_rate = successes / total if total > 0 else 0.0

            if success_rate < min_success_rate:
                continue

            tool = r.data.get("tool", "")
            tool_seq = [tool] if tool else []

            pattern = DiscoveredPattern(
                decision_type=r.data.get("dtype", "unknown"),
                tool_sequence=tool_seq,
                frequency=r.data.get("freq", 0),
                success_rate=success_rate,
                sample_entity_ids=r.data.get("entities", [])[:5],
                description=(
                    f"Pattern: {r.data.get('dtype', '?')} decisions using "
                    f"{tool or 'no tool'}, observed {r.data.get('freq', 0)} times "
                    f"with {success_rate:.0%} success rate."
                ),
            )
            patterns.append(pattern)

        log.info("policy.patterns_discovered", count=len(patterns))
        return patterns

    async def promote_pattern(
        self,
        pattern: DiscoveredPattern,
        *,
        version: str = "1.0",
        name: str | None = None,
        description: str | None = None,
    ) -> str:
        """Promote a discovered pattern to an active policy.

        Returns the policy_id.
        """
        policy_name = name or pattern.suggested_policy_name

        # Build an actionable description the LLM can follow
        tools_str = " then ".join(pattern.tool_sequence) if pattern.tool_sequence else "no specific tool"
        policy_desc = description or (
            f"For '{pattern.decision_type}' decisions, prefer using {tools_str}. "
            f"This pattern was observed {pattern.frequency} times with a "
            f"{pattern.success_rate:.0%} success rate."
        )

        return await self.seed_policy(
            name=policy_name,
            description=policy_desc,
            rules=[
                f"For {pattern.decision_type} decisions, use: {tools_str}",
                f"Expected success rate >= {pattern.success_rate:.0%}",
            ],
            version=version,
            source="auto-discovered",
        )

    # ── Auto-evolution ──────────────────────────────────────────────

    async def auto_evolve(
        self,
        *,
        min_frequency: int | None = None,
        min_success_rate: float | None = None,
        limit: int = 10,
    ) -> list[str]:
        """Discover new patterns and promote ones not already tracked as policies.

        Called automatically after each agent run.  Returns the names of
        newly promoted policies (empty list if nothing new qualifies).
        """
        freq = min_frequency if min_frequency is not None else self._min_frequency
        rate = min_success_rate if min_success_rate is not None else self._min_success_rate

        patterns = await self.discover_patterns(
            min_frequency=freq,
            min_success_rate=rate,
            limit=limit,
        )

        if not patterns:
            return []

        existing_names = await self._get_existing_policy_names()
        promoted: list[str] = []

        for pattern in patterns:
            name = pattern.suggested_policy_name
            if name in existing_names:
                continue

            await self.promote_pattern(pattern)
            promoted.append(name)
            log.info("policy.auto_promoted", name=name, frequency=pattern.frequency)

        if promoted:
            log.info("policy.auto_evolve", new_policies=len(promoted), total_patterns=len(patterns))

        return promoted

    async def _get_existing_policy_names(self) -> set[str]:
        """Return the set of all policy names currently in the graph."""
        records = await self._client.execute(
            """
            MATCH (p:Policy)
            RETURN p.name AS name
            """,
            {},
        )
        return {r.data.get("name", "") for r in records}

    # ── Query helpers ─────────────────────────────────────────────────

    async def get_policy(self, policy_id: str) -> dict[str, Any] | None:
        """Retrieve a single policy by ID."""
        records = await self._client.execute(
            """
            MATCH (p:Policy {policy_id: $policy_id})
            RETURN p.policy_id AS policy_id,
                   p.name AS name,
                   p.description AS description,
                   p.version AS version,
                   p.source AS source,
                   p.rules AS rules
            """,
            {"policy_id": policy_id},
        )
        return records[0].data if records else None

    async def list_policies(self, *, limit: int = 50) -> list[dict[str, Any]]:
        """List all active policies."""
        records = await self._client.execute(
            """
            MATCH (p:Policy)
            RETURN p.policy_id AS policy_id,
                   p.name AS name,
                   p.description AS description,
                   p.version AS version,
                   p.source AS source,
                   p.rules AS rules
            ORDER BY p.name
            LIMIT $limit
            """,
            {"limit": limit},
        )
        return [r.data for r in records]

    # ── Policy matching & linking ────────────────────────────────────

    async def match_policies(
        self,
        *,
        decision_type: str,
        tools_used: list[str] | None = None,
    ) -> list[dict[str, Any]]:
        """Find policies that apply to a given decision_type and tool set.

        Returns matching policy dicts with ``policy_id``, ``name``, etc.
        A policy matches if its name starts with ``auto:{decision_type}:``
        (auto-discovered) or if it was manually seeded (matches all).
        """
        records = await self._client.execute(
            """
            MATCH (p:Policy)
            WHERE p.name STARTS WITH $prefix
               OR p.source = 'manual'
            RETURN p.policy_id AS policy_id,
                   p.name AS name,
                   p.description AS description,
                   p.source AS source,
                   p.rules AS rules
            """,
            {"prefix": f"auto:{decision_type}:"},
        )
        return [r.data for r in records]

    async def link_decision_to_policy(
        self,
        *,
        decision_id: str,
        policy_id: str,
    ) -> None:
        """Link an existing decision to a policy (GOVERNED_BY edge)."""
        await self._client.execute_write(
            """
            MATCH (d:Decision {decision_id: $decision_id})
            MATCH (p:Policy {policy_id: $policy_id})
            MERGE (d)-[:GOVERNED_BY]->(p)
            """,
            {"decision_id": decision_id, "policy_id": policy_id},
        )

    async def link_decision_to_matching_policies(
        self,
        *,
        decision_id: str,
        decision_type: str,
        tools_used: list[str] | None = None,
    ) -> list[str]:
        """Find applicable policies and link them to a decision.

        Called automatically after each run to create GOVERNED_BY edges.
        Returns list of linked policy_ids.
        """
        matched = await self.match_policies(
            decision_type=decision_type, tools_used=tools_used,
        )

        linked: list[str] = []
        for p in matched:
            pid = p.get("policy_id", "")
            if pid:
                await self.link_decision_to_policy(
                    decision_id=decision_id, policy_id=pid,
                )
                linked.append(pid)

        if linked:
            log.info(
                "policy.decisions_linked",
                decision_id=decision_id,
                policies=len(linked),
            )
        return linked

    # ── Policy retirement ─────────────────────────────────────────────

    async def retire_underperforming(
        self,
        *,
        max_failure_rate: float = 0.5,
        min_governed_decisions: int = 3,
    ) -> list[str]:
        """Retire auto-discovered policies whose governed decisions mostly fail.

        Queries all auto-discovered policies, checks the outcome distribution
        of decisions linked via GOVERNED_BY, and deletes policies that
        exceed ``max_failure_rate`` (with at least ``min_governed_decisions``).

        Returns names of retired policies.
        """
        records = await self._client.execute(
            """
            MATCH (p:Policy)<-[:GOVERNED_BY]-(d:Decision)
            WHERE p.source = 'auto-discovered'
            OPTIONAL MATCH (d)<-[:CONTAINS]-(r:Run)-[:PRODUCED]->(o:Outcome)
            WITH p,
                 count(DISTINCT d) AS total_decisions,
                 sum(CASE WHEN o.status = 'failure' THEN 1 ELSE 0 END) AS failures
            WHERE total_decisions >= $min_decisions
            RETURN p.policy_id AS policy_id,
                   p.name AS name,
                   total_decisions,
                   failures
            """,
            {"min_decisions": min_governed_decisions},
        )

        retired: list[str] = []
        for r in records:
            total = r.data.get("total_decisions", 0)
            failures = r.data.get("failures", 0)
            if total < min_governed_decisions:
                continue

            failure_rate = failures / total
            if failure_rate >= max_failure_rate:
                pid = r.data.get("policy_id", "")
                name = r.data.get("name", "")
                await self._client.execute_write(
                    """
                    MATCH (p:Policy {policy_id: $policy_id})
                    DETACH DELETE p
                    """,
                    {"policy_id": pid},
                )
                retired.append(name)
                log.info(
                    "policy.retired",
                    name=name,
                    failure_rate=round(failure_rate, 2),
                    decisions=total,
                )

        return retired
