"""ARIS Predictor — Impact prediction and domino chain analysis.

Think 100 steps ahead, not 2. Every change is a domino that can
topple an entire sequence. It costs MORE to fix cascading errors
than to prevent them.

Two core systems:
1. Impact Predictor: Before ANY change, maps all downstream effects
2. Domino Chain Mapper: Visualizes cascade paths and risk scores
"""

import json
import hashlib
import time
from dataclasses import dataclass, field
from typing import Any, Optional
from pathlib import Path


# ============================================================
# DEPENDENCY GRAPH
# ============================================================

@dataclass
class Node:
    """A node in the dependency graph."""
    id: str
    type: str  # file, function, service, config, database, api, model
    name: str
    risk_weight: float = 1.0  # How critical is this node (1-10)
    dependencies: list[str] = field(default_factory=list)  # IDs this depends on
    dependents: list[str] = field(default_factory=list)  # IDs that depend on this


class DependencyGraph:
    """Directed acyclic graph of system dependencies."""

    def __init__(self) -> None:
        self.nodes: dict[str, Node] = {}

    def add_node(self, id: str, type: str, name: str,
                 risk_weight: float = 1.0) -> Node:
        node = Node(id=id, type=type, name=name, risk_weight=risk_weight)
        self.nodes[id] = node
        return node

    def add_edge(self, from_id: str, to_id: str) -> None:
        """from depends on to."""
        if from_id in self.nodes and to_id in self.nodes:
            self.nodes[from_id].dependencies.append(to_id)
            self.nodes[to_id].dependents.append(from_id)

    def get_all_dependents(self, node_id: str, visited: Optional[set] = None) -> list[str]:
        """Get ALL nodes that depend on this node (recursive)."""
        if visited is None:
            visited = set()
        if node_id in visited:
            return []
        visited.add(node_id)

        node = self.nodes.get(node_id)
        if not node:
            return []

        result = []
        for dep_id in node.dependents:
            result.append(dep_id)
            result.extend(self.get_all_dependents(dep_id, visited))
        return result

    def get_cascade_depth(self, node_id: str) -> int:
        """How deep does the domino chain go?"""
        return self._depth(node_id, set())

    def _depth(self, node_id: str, visited: set) -> int:
        if node_id in visited:
            return 0
        visited.add(node_id)
        node = self.nodes.get(node_id)
        if not node or not node.dependents:
            return 0
        return 1 + max(self._depth(d, visited) for d in node.dependents)


# ============================================================
# ARIS SYSTEM GRAPH (pre-built)
# ============================================================

def build_aris_graph() -> DependencyGraph:
    """Build the dependency graph for the ARIS ecosystem."""
    g = DependencyGraph()

    # Core infrastructure
    g.add_node("w2_hardware", "hardware", "W2 Server", 10.0)
    g.add_node("w3_hardware", "hardware", "W3 Server", 7.0)
    g.add_node("network", "infrastructure", "2.5G Network", 9.0)
    g.add_node("tailscale", "infrastructure", "Tailscale VPN", 6.0)

    # Services
    g.add_node("ollama", "service", "Ollama LLM", 8.0)
    g.add_node("supabase", "service", "Supabase DB", 9.0)
    g.add_node("cloudflare", "service", "Cloudflare CDN", 7.0)
    g.add_node("stripe", "service", "Stripe Payments", 10.0)
    g.add_node("resend", "service", "Resend Email", 6.0)

    # MCP Server
    g.add_node("mcp_server", "service", "ARIS MCP Server", 10.0)
    g.add_node("license_py", "file", "license.py", 9.0)
    g.add_node("server_py", "file", "server.py", 10.0)
    g.add_node("guardian_py", "file", "guardian.py", 5.0)
    g.add_node("memory_py", "file", "memory_store.py", 6.0)
    g.add_node("budget_py", "file", "budget.py", 5.0)
    g.add_node("analytics_py", "file", "analytics_store.py", 4.0)
    g.add_node("verifier_py", "file", "verifier.py", 8.0)
    g.add_node("predictor_py", "file", "predictor.py", 7.0)
    g.add_node("skills_registry", "file", "skills_registry.py", 8.0)

    # Skills
    g.add_node("skills_free", "data", "19 Free Skills", 7.0)
    g.add_node("skills_pro", "data", "36 Pro Skills", 8.0)
    g.add_node("skills_shield", "data", "6 Shield Skills", 6.0)
    g.add_node("skills_intel", "data", "5 Intel Skills", 5.0)

    # Website
    g.add_node("website", "service", "aris4u.dev", 8.0)
    g.add_node("index_astro", "file", "index.astro", 8.0)
    g.add_node("pricing_section", "ui", "Pricing Section", 9.0)

    # Edge Functions
    g.add_node("webhook_fn", "function", "Stripe Webhook", 10.0)
    g.add_node("validate_fn", "function", "License Validation", 9.0)

    # External
    g.add_node("npm_package", "package", "aris-init (npm)", 7.0)
    g.add_node("pypi_package", "package", "aris-mcp (PyPI)", 7.0)
    g.add_node("github_repo", "repository", "GitHub aris", 8.0)
    g.add_node("readme", "file", "README.md", 6.0)

    # Customer-facing
    g.add_node("customer_email", "process", "License Email", 9.0)
    g.add_node("customer_install", "process", "User Installation", 8.0)
    g.add_node("customer_activate", "process", "License Activation", 9.0)

    # === EDGES (dependencies) ===

    # Hardware dependencies
    g.add_edge("ollama", "w2_hardware")
    g.add_edge("mcp_server", "w2_hardware")

    # MCP Server depends on its modules
    g.add_edge("mcp_server", "server_py")
    g.add_edge("server_py", "license_py")
    g.add_edge("server_py", "guardian_py")
    g.add_edge("server_py", "memory_py")
    g.add_edge("server_py", "budget_py")
    g.add_edge("server_py", "analytics_py")
    g.add_edge("server_py", "verifier_py")
    g.add_edge("server_py", "skills_registry")

    # Skills registry depends on skill files
    g.add_edge("skills_registry", "skills_free")
    g.add_edge("skills_registry", "skills_pro")
    g.add_edge("skills_registry", "skills_shield")
    g.add_edge("skills_registry", "skills_intel")

    # License system chain
    g.add_edge("license_py", "supabase")
    g.add_edge("webhook_fn", "stripe")
    g.add_edge("webhook_fn", "supabase")
    g.add_edge("webhook_fn", "resend")
    g.add_edge("webhook_fn", "license_py")
    g.add_edge("validate_fn", "supabase")
    g.add_edge("validate_fn", "license_py")

    # Customer journey
    g.add_edge("customer_email", "webhook_fn")
    g.add_edge("customer_email", "resend")
    g.add_edge("customer_install", "npm_package")
    g.add_edge("customer_activate", "validate_fn")
    g.add_edge("customer_activate", "license_py")

    # Website
    g.add_edge("website", "cloudflare")
    g.add_edge("website", "index_astro")
    g.add_edge("pricing_section", "index_astro")
    g.add_edge("pricing_section", "stripe")

    # Packages
    g.add_edge("npm_package", "github_repo")
    g.add_edge("pypi_package", "github_repo")
    g.add_edge("github_repo", "readme")

    # Guardian needs SSH
    g.add_edge("guardian_py", "network")

    return g


# ============================================================
# IMPACT PREDICTOR
# ============================================================

@dataclass
class ImpactPrediction:
    """Predicted impact of a change."""
    change_target: str
    risk_score: float  # 0-100
    cascade_depth: int
    affected_nodes: list[dict[str, Any]]
    critical_paths: list[list[str]]
    recommendation: str  # proceed, caution, stop
    domino_chain: str  # ASCII visualization


class ImpactPredictor:
    """Predicts the full impact of any change before execution."""

    def __init__(self, graph: Optional[DependencyGraph] = None) -> None:
        self.graph = graph or build_aris_graph()
        self._predictions: list[dict[str, Any]] = []

    def predict(self, target_node_id: str,
                change_type: str = "modify") -> ImpactPrediction:
        """Predict full impact of changing a node.

        change_type: modify, delete, add, upgrade, downgrade
        """
        node = self.graph.nodes.get(target_node_id)
        if not node:
            return ImpactPrediction(
                change_target=target_node_id, risk_score=0,
                cascade_depth=0, affected_nodes=[],
                critical_paths=[], recommendation="unknown",
                domino_chain=f"Node '{target_node_id}' not found in graph"
            )

        # Get all downstream dependents
        all_dependents = self.graph.get_all_dependents(target_node_id)
        cascade_depth = self.graph.get_cascade_depth(target_node_id)

        # Calculate risk for each affected node
        affected = []
        total_risk = node.risk_weight  # Start with target's own risk

        for dep_id in all_dependents:
            dep_node = self.graph.nodes.get(dep_id)
            if dep_node:
                # Risk decreases with distance but compounds with weight
                distance = self._distance(target_node_id, dep_id)
                propagated_risk = dep_node.risk_weight * (0.8 ** distance)
                total_risk += propagated_risk
                affected.append({
                    "id": dep_id,
                    "name": dep_node.name,
                    "type": dep_node.type,
                    "distance": distance,
                    "propagated_risk": round(propagated_risk, 2),
                })

        # Sort by risk (highest first)
        affected.sort(key=lambda x: -x["propagated_risk"])

        # Multipliers based on change type
        type_multiplier = {
            "delete": 2.0, "downgrade": 1.5, "modify": 1.0,
            "upgrade": 0.8, "add": 0.5,
        }.get(change_type, 1.0)

        risk_score = min(100, total_risk * type_multiplier)

        # Find critical paths (paths to customer-facing nodes)
        critical_targets = ["customer_email", "customer_install",
                           "customer_activate", "website", "pricing_section"]
        critical_paths = []
        for ct in critical_targets:
            if ct in all_dependents:
                path = self._find_path(target_node_id, ct)
                if path:
                    critical_paths.append(path)

        # Recommendation
        if risk_score >= 70:
            recommendation = "STOP — High risk. Requires consensus verification and rollback plan."
        elif risk_score >= 40:
            recommendation = "CAUTION — Medium risk. Run property tests and sandbox first."
        elif risk_score >= 15:
            recommendation = "PROCEED with monitoring — Low risk but verify after."
        else:
            recommendation = "PROCEED — Minimal impact detected."

        # Build domino chain visualization
        domino_chain = self._visualize_chain(target_node_id, all_dependents[:10])

        prediction = ImpactPrediction(
            change_target=f"{node.name} ({target_node_id})",
            risk_score=round(risk_score, 1),
            cascade_depth=cascade_depth,
            affected_nodes=affected,
            critical_paths=critical_paths,
            recommendation=recommendation,
            domino_chain=domino_chain,
        )

        # Record for analytics
        self._predictions.append({
            "target": target_node_id,
            "change_type": change_type,
            "risk_score": risk_score,
            "affected_count": len(affected),
            "timestamp": time.time(),
        })

        return prediction

    def _distance(self, from_id: str, to_id: str,
                  visited: Optional[set] = None, depth: int = 0) -> int:
        """BFS distance between two nodes."""
        if visited is None:
            visited = set()
        if from_id == to_id:
            return depth
        if from_id in visited:
            return 99
        visited.add(from_id)

        node = self.graph.nodes.get(from_id)
        if not node:
            return 99

        min_dist = 99
        for dep_id in node.dependents:
            d = self._distance(dep_id, to_id, visited, depth + 1)
            min_dist = min(min_dist, d)
        return min_dist

    def _find_path(self, from_id: str, to_id: str,
                   path: Optional[list] = None,
                   visited: Optional[set] = None) -> Optional[list[str]]:
        """Find path from source to target via dependents."""
        if path is None:
            path = [from_id]
        if visited is None:
            visited = set()
        if from_id == to_id:
            return path
        if from_id in visited:
            return None
        visited.add(from_id)

        node = self.graph.nodes.get(from_id)
        if not node:
            return None

        for dep_id in node.dependents:
            result = self._find_path(dep_id, to_id, path + [dep_id], visited)
            if result:
                return result
        return None

    def _visualize_chain(self, target: str, affected: list[str]) -> str:
        """ASCII domino chain visualization."""
        node = self.graph.nodes.get(target)
        if not node:
            return ""

        lines = [f"  [{node.name}]  <-- YOU CHANGE THIS"]

        # Group by distance
        by_distance: dict[int, list[str]] = {}
        for dep_id in affected:
            d = self._distance(target, dep_id)
            if d < 99:
                by_distance.setdefault(d, []).append(dep_id)

        for dist in sorted(by_distance.keys()):
            nodes = by_distance[dist]
            indent = "  " + "  " * dist
            connector = "|--> " if dist == 1 else "|    " * (dist - 1) + "|--> "
            for nid in nodes[:5]:  # Max 5 per level
                n = self.graph.nodes.get(nid)
                if n:
                    risk_bar = "#" * max(1, int(n.risk_weight))
                    lines.append(f"  {connector}[{n.name}] risk={n.risk_weight} {risk_bar}")
            if len(nodes) > 5:
                lines.append(f"  {connector}... and {len(nodes) - 5} more")

        return "\n".join(lines)

    def what_if(self, changes: list[tuple[str, str]]) -> dict[str, Any]:
        """What-if analysis for multiple simultaneous changes.

        Args:
            changes: List of (node_id, change_type) tuples
        """
        if not changes:
            return {"changes": [], "combined_risk": 0, "total_affected": 0,
                    "overlap_factor": 1.0, "recommendation": "PROCEED"}

        predictions = []
        combined_risk = 0
        all_affected = set()

        for change in changes:
            if not isinstance(change, (list, tuple)) or len(change) < 2:
                continue
            node_id, change_type = change[0], change[1]
            pred = self.predict(node_id, change_type)
            predictions.append({
                "target": pred.change_target,
                "risk": pred.risk_score,
                "cascade_depth": pred.cascade_depth,
                "affected": len(pred.affected_nodes),
            })
            combined_risk += pred.risk_score
            all_affected.update(n["id"] for n in pred.affected_nodes)

        # Compound risk (overlapping affected nodes increase risk)
        overlap_factor = 1.0 + (len(all_affected) / max(len(self.graph.nodes), 1))
        combined_risk = min(100, combined_risk * overlap_factor / len(changes))

        return {
            "changes": predictions,
            "combined_risk": round(combined_risk, 1),
            "total_affected": len(all_affected),
            "overlap_factor": round(overlap_factor, 2),
            "recommendation": (
                "STOP" if combined_risk >= 70 else
                "CAUTION" if combined_risk >= 40 else
                "PROCEED"
            ),
        }

    def get_safest_order(self, changes: list[str]) -> list[str]:
        """Determine the safest order to apply changes.

        Rule: Change nodes with FEWER dependents first.
        This minimizes cascade risk during partial application.
        """
        scored = []
        for node_id in changes:
            deps = self.graph.get_all_dependents(node_id)
            node = self.graph.nodes.get(node_id)
            risk = node.risk_weight if node else 0
            scored.append((node_id, len(deps), risk))

        # Sort: fewer dependents first, lower risk first
        scored.sort(key=lambda x: (x[1], x[2]))
        return [s[0] for s in scored]
