# -*- coding: utf-8 -*-
"""
TEITAN SELF-REPAIR V1 — ROOT CAUSE ANALYZER (AR-76 HARDENED)
Explicitly separates and grounds in physical evidence:
  A. observed_mission_stage: Stage of the mission where the failure was observed (symptom stage)
  B. defective_module: Module where the defect actually originated
  C. responsible_function: Qualified function or method responsible for the defect
  D. downstream_circuit_effects: Downstream consequences across the circuit

Fails closed with ROOT_CAUSE=UNPROVEN when evidence is insufficient or contradictory.
Never attributes root cause to a synthetic test fixture node (e.g. 'executor') when the
actual failure occurred in an implementation module under test (e.g. 'CircuitTracer.trace').
"""
from __future__ import annotations

import ast
import json
import os
import re
from typing import Any, Dict, List, Optional, Tuple

from app.remediation.self_repair.schemas import (
    CircuitStatus,
    CircuitTrace,
    EvidenceBundle,
    IncidentRecord,
    RootCauseClassification,
)


def _is_test_rel_path(rel_path: str) -> bool:
    norm = os.path.normpath(rel_path).replace("\\", "/")
    base = os.path.basename(norm).lower()
    parts = [p.lower() for p in norm.split("/")]
    return (
        (base.startswith("test_") and base.endswith(".py"))
        or base.endswith("_test.py")
        or ("tests" in parts and base.endswith(".py"))
        or ("test" in parts and base.endswith(".py"))
    )


def _resolve_qualified_function_at_line(file_abs: str, line_no: Optional[int], fallback_fn: Optional[str] = None) -> Optional[str]:
    """
    Uses Python AST to find the qualified function or Class.method enclosing `line_no`
    or matching `fallback_fn` in `file_abs`.
    """
    if not file_abs or not os.path.isfile(file_abs):
        return fallback_fn
    try:
        with open(file_abs, "r", encoding="utf-8", errors="replace") as f:
            src = f.read()
        tree = ast.parse(src, filename=file_abs)
    except Exception:
        return fallback_fn

    best_match: Optional[str] = None
    best_span: int = 10**9

    for node in ast.walk(tree):
        if isinstance(node, ast.ClassDef):
            for item in node.body:
                if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
                    qname = f"{node.name}.{item.name}"
                    start = getattr(item, "lineno", 0)
                    end = getattr(item, "end_lineno", start + 200)
                    if line_no and start <= line_no <= end:
                        span = end - start
                        if span < best_span:
                            best_span = span
                            best_match = qname
                    elif fallback_fn and item.name == fallback_fn and not best_match:
                        best_match = qname
        elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            # Check top-level functions
            start = getattr(node, "lineno", 0)
            end = getattr(node, "end_lineno", start + 200)
            if line_no and start <= line_no <= end:
                span = end - start
                if span < best_span and best_match is None:
                    best_span = span
                    best_match = node.name
            elif fallback_fn and node.name == fallback_fn and not best_match:
                best_match = node.name

    return best_match or fallback_fn


def _infer_target_from_test_ast(
    test_abs: str,
    failing_test_fn: Optional[str],
    workspace_root: str,
) -> Tuple[Optional[str], Optional[str]]:
    """
    Inspects a failing test file's AST to identify the single implementation module
    and qualified method/function being exercised by the failing test function.
    Returns (defective_module_rel, responsible_function_qualified).
    """
    if not os.path.isfile(test_abs):
        return None, None
    try:
        with open(test_abs, "r", encoding="utf-8", errors="replace") as f:
            test_src = f.read()
        tree = ast.parse(test_src, filename=test_abs)
    except Exception:
        return None, None

    # Map imported symbol -> module relative path in workspace_root
    symbol_to_module: Dict[str, str] = {}
    imported_modules: List[str] = []

    for node in ast.walk(tree):
        if isinstance(node, ast.ImportFrom) and node.module:
            mod_rel = node.module.replace(".", "/") + ".py"
            mod_abs = os.path.join(workspace_root, mod_rel)
            if os.path.isfile(mod_abs) and not _is_test_rel_path(mod_rel):
                if mod_rel not in imported_modules:
                    imported_modules.append(mod_rel)
                for alias in node.names:
                    local_name = alias.asname or alias.name
                    symbol_to_module[local_name] = mod_rel

    # Locate the failing test function node (or inspect all test_* functions if unspecified)
    target_test_nodes: List[ast.AST] = []
    for node in tree.body:
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            if failing_test_fn and node.name == failing_test_fn:
                target_test_nodes = [node]
                break
            elif node.name.startswith("test_"):
                target_test_nodes.append(node)

    if not target_test_nodes:
        target_test_nodes = [tree]

    # Track variable assignments: var_name -> imported Class/Symbol name
    var_to_symbol: Dict[str, str] = {}
    called_candidates: List[Tuple[str, str]] = []  # (mod_rel, qualified_fn)

    for tnode in target_test_nodes:
        for sub in ast.walk(tnode):
            if isinstance(sub, ast.Assign):
                if isinstance(sub.value, ast.Call) and isinstance(sub.value.func, ast.Name):
                    ctor_name = sub.value.func.id
                    if ctor_name in symbol_to_module:
                        for tgt in sub.targets:
                            if isinstance(tgt, ast.Name):
                                var_to_symbol[tgt.id] = ctor_name
            elif isinstance(sub, ast.Call):
                if isinstance(sub.func, ast.Attribute) and isinstance(sub.func.value, ast.Name):
                    obj_name = sub.func.value.id
                    method_name = sub.func.attr
                    if obj_name in var_to_symbol:
                        cls_name = var_to_symbol[obj_name]
                        mod_rel = symbol_to_module[cls_name]
                        called_candidates.append((mod_rel, f"{cls_name}.{method_name}"))
                    elif obj_name in symbol_to_module:
                        mod_rel = symbol_to_module[obj_name]
                        called_candidates.append((mod_rel, f"{obj_name}.{method_name}"))
                elif isinstance(sub.func, ast.Name):
                    fn_name = sub.func.id
                    if fn_name in symbol_to_module:
                        mod_rel = symbol_to_module[fn_name]
                        # Check if fn_name is a function (not class constructor) in mod_rel
                        mod_abs = os.path.join(workspace_root, mod_rel)
                        resolved_q = _resolve_qualified_function_at_line(mod_abs, None, fn_name)
                        if resolved_q and "." not in resolved_q and not fn_name[0].isupper():
                            called_candidates.append((mod_rel, fn_name))

    if called_candidates:
        # Prefer non-schema operational methods called in the test
        non_schema = [c for c in called_candidates if not c[0].endswith("/schemas.py")]
        chosen = non_schema[-1] if non_schema else called_candidates[-1]
        return chosen[0], chosen[1]

    non_schema_mods = [m for m in imported_modules if not m.endswith("/schemas.py")]
    if len(non_schema_mods) == 1:
        mod_rel = non_schema_mods[0]
        return mod_rel, None

    return None, None


class RootCauseAnalyzer:
    """
    Analyzes incidents, evidence bundles, and circuit traces to isolate the true root cause:
      A. observed_mission_stage (symptom stage)
      B. defective_module (originating module)
      C. responsible_function (originating function/method)
      D. downstream_circuit_effects (downstream effects across the circuit)
    """

    def analyze(
        self,
        incident: IncidentRecord,
        evidence: EvidenceBundle,
        trace: CircuitTrace,
        workspace_root: Optional[str] = None,
    ) -> RootCauseClassification:
        """
        Determines root cause based strictly on verified evidence.
        """
        citations: List[Dict[str, Any]] = []

        has_sufficient_evidence = bool(
            trace.first_proven_break
            or evidence.tracebacks
            or evidence.logs
            or evidence.action_history
            or evidence.verification_results
            or (incident.initial_evidence and len(incident.initial_evidence) > 0)
        )

        if not incident.observed_symptom and not incident.initial_evidence and not trace.first_proven_break:
            has_sufficient_evidence = False

        if not has_sufficient_evidence:
            return RootCauseClassification(
                incident_id=incident.incident_id,
                symptom=incident.observed_symptom or "UNKNOWN_SYMPTOM",
                direct_failure="UNKNOWN",
                root_cause="UNPROVEN",
                proven=False,
                reasoning="Insufficient evidence: no diagnostic logs, traces, or verified failure point available.",
                evidence_citations=[],
                observed_mission_stage="unknown",
                defective_module=None,
                responsible_function=None,
                defect_summary="Insufficient diagnostic evidence",
                downstream_circuit_effects=[],
                confidence="UNPROVEN",
            )

        ws_root = os.path.abspath(
            workspace_root
            or (evidence.workspace_state or {}).get("workspace_root")
            or (evidence.mission_state or {}).get("workspace_root")
            or "."
        )

        symptom = incident.observed_symptom
        direct_failure = incident.source_component or "unknown"

        # ------------------------------------------------------------------
        # A. Determine observed_mission_stage (Symptom Stage)
        # ------------------------------------------------------------------
        mission_stage = str(
            (evidence.mission_state or {}).get("failure_stage")
            or (incident.initial_evidence or {}).get("failure_stage")
            or direct_failure
            or "unknown"
        ).strip()

        verif_failed = False
        if isinstance(evidence.verification_results, dict) and evidence.verification_results:
            vr = evidence.verification_results
            if (
                vr.get("passed") is False
                or str(vr.get("status") or "").upper() in ("FAIL", "FAILED", "VERIFICATION_FAILED", "ERROR")
                or bool(vr.get("failures"))
            ):
                verif_failed = True

        if verif_failed or direct_failure in ("verification", "verifier", "test_runner") or "assert " in symptom:
            observed_mission_stage = "verification"
        else:
            observed_mission_stage = mission_stage

        # ------------------------------------------------------------------
        # B & C. Determine defective_module and responsible_function from
        # physical tracebacks, pytest frames, and AST inspection
        # ------------------------------------------------------------------
        text_blobs: List[str] = []
        if symptom:
            text_blobs.append(symptom)
        for tb in (evidence.tracebacks or []):
            text_blobs.append(str(tb))
        for lg in (evidence.logs or []):
            text_blobs.append(str(lg))
        if isinstance(evidence.verification_results, dict):
            for k in ("stdout", "stderr", "output", "verification_reason", "reason", "details"):
                v = evidence.verification_results.get(k)
                if v:
                    text_blobs.append(str(v))
            for f_item in (evidence.verification_results.get("failures") or []):
                text_blobs.append(str(f_item))

        combined_text = "\n".join(text_blobs)

        # Parse standard Python traceback frames: File "path", line N, in func
        std_frame_re = re.compile(r'File\s+["\']([^"\']+\.py)["\'],\s+line\s+(\d+)(?:,\s+in\s+([A-Za-z_]\w*))?')
        # Parse pytest frame format: path/to/file.py:123: in func_name
        pytest_frame_re = re.compile(r'([^\s:"\']+\.py):(\d+):\s+in\s+([A-Za-z_]\w*)')
        # Parse pytest node id: path/to/test_file.py::test_func_name
        pytest_node_re = re.compile(r'([^\s:"\']+test[^\s:"\']*\.py)::([A-Za-z_]\w*)')

        impl_frames: List[Tuple[str, int, Optional[str]]] = []
        test_frames: List[Tuple[str, int, Optional[str]]] = []

        def _normalize_rel(raw_p: str) -> Optional[str]:
            p_clean = raw_p.strip().replace("\\", "/")
            if os.path.isabs(p_clean):
                p_abs = os.path.abspath(p_clean)
                try:
                    rel = os.path.relpath(p_abs, ws_root).replace("\\", "/")
                except ValueError:
                    return None
                if rel.startswith("../") or ".." in rel.split("/"):
                    return None
                return rel
            norm = os.path.normpath(p_clean).replace("\\", "/")
            if norm.startswith("../") or ".." in norm.split("/"):
                return None
            if os.path.isfile(os.path.join(ws_root, norm)):
                return norm
            return None

        for m in std_frame_re.finditer(combined_text):
            rel = _normalize_rel(m.group(1))
            if rel:
                lineno = int(m.group(2))
                fn = m.group(3)
                if _is_test_rel_path(rel):
                    test_frames.append((rel, lineno, fn))
                else:
                    impl_frames.append((rel, lineno, fn))

        for m in pytest_frame_re.finditer(combined_text):
            rel = _normalize_rel(m.group(1))
            if rel:
                lineno = int(m.group(2))
                fn = m.group(3)
                if _is_test_rel_path(rel):
                    test_frames.append((rel, lineno, fn))
                else:
                    impl_frames.append((rel, lineno, fn))

        for m in pytest_node_re.finditer(combined_text):
            rel = _normalize_rel(m.group(1))
            if rel and _is_test_rel_path(rel):
                test_frames.append((rel, 0, m.group(2)))

        defective_module: Optional[str] = None
        responsible_function: Optional[str] = None

        if impl_frames:
            # Innermost implementation frame in traceback
            last_rel, last_line, last_fn = impl_frames[-1]
            defective_module = last_rel
            abs_mod = os.path.join(ws_root, last_rel)
            responsible_function = _resolve_qualified_function_at_line(abs_mod, last_line, last_fn)
            citations.append({
                "type": "implementation_traceback_frame",
                "module": defective_module,
                "line": last_line,
                "responsible_function": responsible_function,
            })
        elif test_frames:
            # Failure was caught by an assertion in a test frame; inspect the test AST to find
            # the implementation module and method under test
            test_rel, test_line, test_fn = test_frames[-1]
            test_abs = os.path.join(ws_root, test_rel)
            inferred_mod, inferred_fn = _infer_target_from_test_ast(test_abs, test_fn, ws_root)
            if inferred_mod:
                defective_module = inferred_mod
                responsible_function = inferred_fn
                citations.append({
                    "type": "test_ast_call_graph_localization",
                    "test_file": test_rel,
                    "test_function": test_fn,
                    "defective_module": defective_module,
                    "responsible_function": responsible_function,
                })

        # Fallback to explicit target_file / source_files if still unresolved
        if not defective_module:
            explicit_tf = (
                evidence.target_file
                or (evidence.mission_state or {}).get("target_file")
                or (incident.initial_evidence or {}).get("target_file")
            )
            if explicit_tf:
                rel = _normalize_rel(str(explicit_tf))
                if rel and not _is_test_rel_path(rel):
                    defective_module = rel
            if not defective_module and evidence.source_files:
                for sf in evidence.source_files:
                    rel = _normalize_rel(str(sf))
                    if rel and not _is_test_rel_path(rel):
                        defective_module = rel
                        break

        if defective_module and not responsible_function:
            # Inspect module AST to see if a single primary method/function exists or is mentioned in symptom
            mod_abs = os.path.join(ws_root, defective_module)
            if os.path.isfile(mod_abs):
                try:
                    with open(mod_abs, "r", encoding="utf-8", errors="replace") as f:
                        mod_src = f.read()
                    mod_tree = ast.parse(mod_src, filename=mod_abs)
                    q_methods: List[str] = []
                    for node in ast.walk(mod_tree):
                        if isinstance(node, ast.ClassDef):
                            for item in node.body:
                                if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and not item.name.startswith("_"):
                                    q_methods.append(f"{node.name}.{item.name}")
                    # Prefer method whose name or class appears in symptom or whose class is the primary tracer/comparator/collector
                    for qm in q_methods:
                        cls_n, fn_n = qm.split(".", 1)
                        if cls_n in combined_text or f".{fn_n}(" in combined_text:
                            responsible_function = qm
                            break
                    if not responsible_function and q_methods:
                        responsible_function = q_methods[0]
                except Exception:
                    pass

        # ------------------------------------------------------------------
        # D. Determine downstream_circuit_effects & synthesize classification
        # ------------------------------------------------------------------
        downstream_effects: List[str] = []
        first_break = trace.first_proven_break

        if defective_module:
            qualified_target = (
                f"{defective_module}::{responsible_function}"
                if responsible_function
                else defective_module
            )
            root_cause = qualified_target
            proven = True
            if observed_mission_stage == "verification":
                downstream_effect = (
                    f"Stage '{observed_mission_stage}' failed assertion/verification because "
                    f"'{qualified_target}' produced an incorrect state or return value."
                )
                downstream_effects.append(
                    f"verification: test/verification failed due to defect in {qualified_target}"
                )
                downstream_effects.append("final_mission_state: mission marked FAILED after verification failure")
            else:
                downstream_effect = f"Stage '{observed_mission_stage}' failed due to defect in '{qualified_target}'."
                downstream_effects.append(f"{observed_mission_stage}: execution failed at {qualified_target}")

            for dn in (trace.downstream_not_reached or []):
                eff_str = f"circuit_node_not_reached:{dn}"
                if eff_str not in downstream_effects:
                    downstream_effects.append(eff_str)

            defect_summary = (
                f"Symptom observed at stage '{observed_mission_stage}'; "
                f"originating defect localized to module '{defective_module}'"
                + (f", function '{responsible_function}'." if responsible_function else ".")
            )
            reasoning = (
                f"Separated symptom stage ('{observed_mission_stage}') from originating code defect: "
                f"module='{defective_module}', function='{responsible_function or 'unspecified'}'. "
                f"Downstream circuit effects: {downstream_effects}."
            )
        elif first_break:
            # No specific code module/function could be proven from workspace files; fall back to circuit node
            if observed_mission_stage in ("verification", "verifier", "test_runner") and first_break not in ("verification", "verifier", "test_runner"):
                root_cause = first_break
                downstream_effect = (
                    f"{observed_mission_stage} failed because upstream component '{first_break}' "
                    f"produced invalid or missing state."
                )
                downstream_effects.append(f"{observed_mission_stage}: downstream verification failure")
                reasoning = (
                    f"The visible symptom was reported at stage '{observed_mission_stage}', but circuit tracing "
                    f"proves the earliest break occurred upstream at '{first_break}'."
                )
                citations.append({
                    "type": "circuit_trace_break",
                    "component": first_break,
                    "details": f"First proven break identified at node '{first_break}'",
                })
                proven = True
            else:
                root_cause = first_break
                proven = True
                for dn in (trace.downstream_not_reached or []):
                    downstream_effects.append(f"circuit_node_not_reached:{dn}")
                citations.append({
                    "type": "circuit_first_break",
                    "component": first_break,
                    "evidence_count": len([n for n in trace.nodes if n.component == first_break and n.evidence]),
                })
                reasoning = f"Root cause isolated to circuit component '{first_break}' via circuit trace."
            defect_summary = reasoning
        elif incident.source_component and incident.source_component != "unknown":
            root_cause = incident.source_component
            proven = True
            citations.append({
                "type": "incident_source",
                "component": incident.source_component,
                "evidence": incident.initial_evidence,
            })
            reasoning = f"Direct failure at '{incident.source_component}' with validated initial evidence."
            defect_summary = reasoning
        else:
            root_cause = "UNPROVEN"
            proven = False
            reasoning = "Evidence present but inconclusive; unable to determine definitive component break."
            defect_summary = reasoning

        for idx, tb in enumerate(evidence.tracebacks[:3]):
            citations.append({"type": f"traceback_{idx+1}", "excerpt": tb[:240]})

        if evidence.verification_results:
            citations.append({"type": "verification_results", "summary": str(evidence.verification_results)[:240]})

        return RootCauseClassification(
            incident_id=incident.incident_id,
            symptom=symptom,
            direct_failure=direct_failure,
            root_cause=root_cause,
            downstream_effect=downstream_effect if 'downstream_effect' in locals() else None,
            sibling_failure=None,
            proven=proven,
            evidence_citations=citations,
            reasoning=reasoning,
            observed_mission_stage=observed_mission_stage,
            defective_module=defective_module,
            responsible_function=responsible_function,
            defect_summary=defect_summary,
            downstream_circuit_effects=downstream_effects,
            confidence="HIGH" if proven else "UNPROVEN",
        )

    def persist(self, classification: RootCauseClassification, output_dir: str) -> str:
        """
        Persists root_cause_report.json to the specified output directory.
        """
        os.makedirs(output_dir, exist_ok=True)
        path = os.path.join(output_dir, "root_cause_report.json")
        with open(path, "w", encoding="utf-8") as f:
            json.dump(classification.to_dict(), f, indent=2, ensure_ascii=False)
        return path
