"""End-to-end integration tests for the Orxaq causal intelligence pipeline.

Exercises the full pipeline: ontology -> synthetic data -> causal discovery ->
soliton scoring -> scenario simulation -> d-separation validation -> CECL ->
fair lending audit -> result export.

All tests are deterministic (seed=42) and use stdlib-only imports.
"""

from __future__ import annotations

import io
import json
import tempfile
from pathlib import Path
from unittest.mock import patch


class TestEndToEndPipeline:
    """Integration tests covering the full Orxaq credit risk pipeline."""

    def test_full_pipeline(self) -> None:
        """Run the entire pipeline end-to-end.

        Steps:
        1. Load credit ontology into WorldDAG
        2. Generate synthetic data
        3. Run causal discovery (PC algorithm)
        4. Score solitons on the discovered DAG
        5. Simulate a stress scenario
        6. Validate DAG against data (d-separation)
        7. Compute CECL expected credit loss
        8. Run fair lending audit
        9. Export results
        """
        from orxaq.orchestration.integration import OrxaqEngine

        engine = OrxaqEngine()

        # Step 1: Load credit ontology
        dag = engine.load_credit_ontology()
        assert dag.node_count() == 19, f"Expected 19 entities, got {dag.node_count()}"
        assert dag.edge_count() == 17, f"Expected 17 edges, got {dag.edge_count()}"

        # Step 2: Generate synthetic data
        data = engine.generate_synthetic(n=500, seed=42)
        assert len(data) == 500
        assert "default_flag" in data[0]
        assert "unemployment_rate" in data[0]
        assert "credit_score" in data[0]

        # Step 3: Run causal discovery (PC algorithm, small variable subset)
        discovery_vars = [
            "unemployment_rate",
            "income",
            "credit_score",
            "dti",
            "default_flag",
        ]
        result = engine.discover_structure(
            data, variables=discovery_vars, alpha=0.05, use_consensus=False
        )
        assert hasattr(result, "skeleton"), "PC result must have skeleton attribute"
        assert result.ci_tests_run > 0, "Discovery must run CI tests"

        # Step 4: Check soliton scores on DAG edges
        for edge in dag._edges.values():
            assert 0.0 <= edge.soliton_score <= 1.0, (
                f"Soliton score out of range for {edge.edge_id}: {edge.soliton_score}"
            )

        # Step 5: Simulate a stress scenario
        from orxaq.intelligence.simulator import (
            BUILT_IN_SCENARIOS,
            ScenarioSimulator,
        )

        sim = ScenarioSimulator(dag, seed=42)
        scenario = BUILT_IN_SCENARIOS["severely_adverse"]
        sim_result = sim.simulate(scenario)
        assert sim_result.total_affected > 0, "Scenario must affect at least one variable"
        assert 0.0 <= sim_result.impact_score <= 1.0

        # Step 6: Validate DAG against data (d-separation)
        report = engine.validate_dag(data, alpha=0.05)
        assert report.total_implications > 0, "Must test at least one implication"
        assert 0.0 <= report.violation_rate <= 1.0

        # Step 7: Compute CECL expected credit loss
        ecl_result = engine.compute_ecl(data)
        assert ecl_result.total_ecl >= 0.0, "ECL must be non-negative"
        assert ecl_result.horizon_quarters in (4, 8, 12)
        assert ecl_result.lgd > 0.0
        assert ecl_result.pd_mean >= 0.0
        lo, hi = ecl_result.confidence_band
        assert lo <= ecl_result.total_ecl <= hi

        # Step 8: Run fair lending audit
        decomp = engine.fairness_audit(protected="age")
        assert decomp.protected_var == "age"
        assert decomp.outcome_var == "default_flag"
        assert decomp.total_effect >= 0.0
        assert isinstance(decomp.legitimate_paths, tuple)
        assert isinstance(decomp.concerning_paths, tuple)

        # Step 9: Export results
        export = {
            "dag_summary": dag.summary(),
            "data_rows": len(data),
            "discovery_edges": len(result.skeleton),
            "simulation": sim_result.to_dict(),
            "validation": {
                "total_implications": report.total_implications,
                "violations": report.violations,
                "violation_rate": report.violation_rate,
                "passed": report.passed,
            },
            "ecl": {
                "total_ecl": ecl_result.total_ecl,
                "horizon_quarters": ecl_result.horizon_quarters,
                "pd_mean": ecl_result.pd_mean,
            },
            "fairness": {
                "protected_var": decomp.protected_var,
                "direct_effect": decomp.direct_effect,
                "legitimate_paths": len(decomp.legitimate_paths),
                "concerning_paths": len(decomp.concerning_paths),
            },
            "audit_entries": len(engine.audit_log),
        }

        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            json.dump(export, f, indent=2, default=str)
            export_path = f.name

        # Verify the export is valid JSON and has all keys
        with open(export_path) as f:
            loaded = json.load(f)
        assert "dag_summary" in loaded
        assert "ecl" in loaded
        assert "fairness" in loaded
        assert loaded["data_rows"] == 500
        Path(export_path).unlink()

        # Verify audit trail captured all operations
        assert len(engine.audit_log) >= 5, (
            f"Expected at least 5 audit entries, got {len(engine.audit_log)}"
        )

    def test_synthetic_data_generation(self) -> None:
        """Generate 5000 rows of synthetic data and verify all 20 variables are present."""
        from orxaq.fabric.validation.synthetic import (
            VARIABLE_SPECS,
            ValidationDataGenerator,
        )

        gen = ValidationDataGenerator()
        data = gen.generate(n=5000, seed=42)

        assert len(data) == 5000, f"Expected 5000 rows, got {len(data)}"

        # Verify all 20 variables are present
        expected_vars = set(VARIABLE_SPECS.keys())
        actual_vars = set(data[0].keys())
        assert len(expected_vars) == 20, f"Expected 20 variable specs, got {len(expected_vars)}"
        assert actual_vars == expected_vars, (
            f"Variable mismatch. Missing: {expected_vars - actual_vars}, "
            f"Extra: {actual_vars - expected_vars}"
        )

        # Verify all values are numeric and within bounds
        for i, row in enumerate(data[:100]):  # spot-check first 100
            for var_name, value in row.items():
                assert isinstance(value, int | float), (
                    f"Row {i}, {var_name}: expected numeric, got {type(value)}"
                )
                spec = VARIABLE_SPECS[var_name]
                assert spec.min_val <= value <= spec.max_val, (
                    f"Row {i}, {var_name}: {value} out of bounds [{spec.min_val}, {spec.max_val}]"
                )

        # Verify determinism: same seed produces identical data
        gen2 = ValidationDataGenerator()
        data2 = gen2.generate(n=5000, seed=42)
        assert data == data2, "Same seed must produce identical data"

        # Verify different seed produces different data
        gen3 = ValidationDataGenerator()
        data3 = gen3.generate(n=5000, seed=99)
        assert data[0] != data3[0], "Different seeds must produce different data"

        # Verify the generator exposes variable list and ground truth edges
        assert len(gen.variables) == 20
        assert isinstance(gen.true_edges, list)

    def test_discovery_to_validation(self) -> None:
        """Discover causal structure from synthetic data, then validate against ground truth."""
        from orxaq.fabric.validation.metrics import compute_metrics
        from orxaq.fabric.validation.pipeline import ValidationPipeline
        from orxaq.fabric.validation.synthetic import ValidationDataGenerator

        # Generate synthetic data with known structure
        gen = ValidationDataGenerator()
        data = gen.generate(n=2000, seed=42)
        true_edges = gen.true_edges

        # Run validation pipeline with PC algorithm
        pipeline = ValidationPipeline(algorithm="pc")
        pipeline.load_data(data)
        pipeline.load_ground_truth(true_edges)

        # Discover on first 5 variables (default behavior)
        predicted = pipeline.discover()
        assert isinstance(predicted, list), "Discovery must return list of edges"

        # Evaluate metrics
        metrics = pipeline.evaluate()
        assert metrics.shd >= 0, "SHD must be non-negative"
        assert 0.0 <= metrics.precision <= 1.0
        assert 0.0 <= metrics.recall <= 1.0
        assert 0.0 <= metrics.f1 <= 1.0
        assert 0.0 <= metrics.orientation_accuracy <= 1.0
        assert 0.0 <= metrics.fdr <= 1.0
        assert metrics.true_positives >= 0
        assert metrics.false_positives >= 0
        assert metrics.false_negatives >= 0

        # Also test compute_metrics directly
        direct_metrics = compute_metrics(
            [("a", "b"), ("b", "c")],
            [("a", "b"), ("c", "b")],
        )
        assert direct_metrics.true_positives == 1  # (a,b) matches
        assert direct_metrics.reversed_edges == 1  # (c,b) vs (b,c)
        assert direct_metrics.shd >= 1

        # Test full pipeline.run() shorthand
        pipeline2 = ValidationPipeline(algorithm="pc")
        metrics2 = pipeline2.run(data=data, true_edges=true_edges)
        assert metrics2.shd >= 0
        assert isinstance(metrics2.total_true_edges, int)
        assert isinstance(metrics2.total_predicted_edges, int)

    def test_scenario_simulation_pipeline(self) -> None:
        """Load ontology, simulate adverse scenario, and check impact scores."""
        from orxaq.intelligence.simulator import (
            BUILT_IN_SCENARIOS,
            Scenario,
            ScenarioSimulator,
            Shock,
            SimulationResult,
        )
        from orxaq.orchestration.integration import OrxaqEngine

        engine = OrxaqEngine()
        dag = engine.load_credit_ontology()

        # Test all three built-in scenarios
        sim = ScenarioSimulator(dag, seed=42)

        for scenario_name in ("baseline", "adverse", "severely_adverse"):
            scenario = BUILT_IN_SCENARIOS[scenario_name]
            result = sim.simulate(scenario)

            assert isinstance(result, SimulationResult)
            assert result.scenario.name == scenario_name
            assert len(result.steps) > 0, f"{scenario_name}: must have propagation steps"
            assert result.total_affected > 0
            assert result.max_depth >= 0
            assert 0.0 <= result.impact_score <= 1.0

            # Verify serialization round-trip
            as_dict = result.to_dict()
            assert "scenario" in as_dict
            assert "steps" in as_dict
            assert "affected_variables" in as_dict
            assert as_dict["total_affected"] == result.total_affected

        # Severely adverse must have higher impact than baseline
        baseline_result = sim.simulate(BUILT_IN_SCENARIOS["baseline"])
        severe_result = sim.simulate(BUILT_IN_SCENARIOS["severely_adverse"])
        # Both should affect variables; severity comparison is structural
        assert severe_result.total_affected > 0
        assert baseline_result.total_affected > 0

        # Test custom scenario creation
        custom = Scenario(
            name="custom_downturn",
            description="Custom stress test",
            shocks=(
                Shock("unemployment_rate", 0.12, "absolute"),
                Shock("gdp_growth", -0.05, "absolute"),
            ),
            severity="custom",
            horizon_quarters=8,
        )
        custom_result = sim.simulate(custom)
        assert custom_result.scenario.name == "custom_downturn"
        assert custom_result.total_affected > 0

        # Test Scenario serialization
        as_dict = custom.to_dict()
        restored = Scenario.from_dict(as_dict)
        assert restored.name == custom.name
        assert len(restored.shocks) == 2
        assert restored.severity == "custom"

        # Test Monte Carlo simulation
        mc = sim.monte_carlo(
            BUILT_IN_SCENARIOS["adverse"],
            n_simulations=10,
        )
        assert mc["n_simulations"] == 10
        assert mc["scenario"] == "adverse"
        assert "variable_stats" in mc
        assert "impact_score_mean" in mc
        assert 0.0 <= mc["impact_score_mean"] <= 1.0

        # Test stress test via engine facade
        data = engine.generate_synthetic(n=200, seed=42)
        stress_result = engine.stress_test(data, scenario_name="severely_adverse")
        assert "base_pd" in stress_result
        assert "stress_pd" in stress_result
        assert "delta" in stress_result
        assert stress_result["scenario"] == "severely_adverse"

    def test_cli_commands(self) -> None:
        """Test the CLI main function with various subcommands."""
        from orxaq.experiences.cli import main

        # Test --help on validate (exits with 0)
        try:
            main(["validate", "--help"])
        except SystemExit as e:
            assert e.code == 0

        # Test status command produces output
        captured = io.StringIO()
        with patch("sys.stdout", captured):
            rc = main(["status"])
        assert rc == 0

        # Test status --json
        captured = io.StringIO()
        with patch("sys.stdout", captured):
            rc = main(["--json", "status"])
        assert rc == 0
        output = captured.getvalue()
        parsed = json.loads(output)
        assert "dag" in parsed
        assert "audit_entries" in parsed

        # Test credit ontology --json
        captured = io.StringIO()
        with patch("sys.stdout", captured):
            rc = main(["--json", "credit", "ontology"])
        assert rc == 0
        output = captured.getvalue()
        parsed = json.loads(output)
        assert parsed["entity_count"] == 19
        assert len(parsed["entities"]) == 19

        # Test credit fairness --json
        captured = io.StringIO()
        with patch("sys.stdout", captured):
            rc = main(["--json", "credit", "fairness"])
        assert rc == 0
        output = captured.getvalue()
        parsed = json.loads(output)
        assert parsed["protected_var"] == "age"
        assert parsed["outcome_var"] == "default_flag"
        assert "direct_effect" in parsed

        # Test simulate --json
        captured = io.StringIO()
        with patch("sys.stdout", captured):
            rc = main(["--json", "simulate", "--scenario", "adverse"])
        assert rc == 0
        output = captured.getvalue()
        parsed = json.loads(output)
        assert parsed["scenario"]["name"] == "adverse"
        assert "impact_score" in parsed

        # Test no-command returns error code
        captured = io.StringIO()
        with patch("sys.stdout", captured), patch("sys.stderr", io.StringIO()):
            rc = main([])
        assert rc == 1


# ── Targeted coverage (integration.py lines 226-227, 230) ──


class TestQualityGateBranches:
    """Cover lines 226-227 (bounds gate) and 230 (no-gates fallback)."""

    def test_bounds_gate(self):
        """Cover lines 226-227: gate_names includes 'bounds'."""
        from orxaq.orchestration.integration import OrxaqEngine

        engine = OrxaqEngine()
        data = [{"x": 1.0, "y": 2.0}, {"x": 3.0, "y": 4.0}]
        result = engine.run_quality_gates(data, gate_names=["bounds"])
        assert result.all_passed is not None

    def test_no_matching_gates_fallback(self):
        """Cover line 230: gate_names with no known names → fallback."""
        from orxaq.orchestration.integration import OrxaqEngine

        engine = OrxaqEngine()
        data = [{"x": 1.0}, {"x": 2.0}]
        result = engine.run_quality_gates(data, gate_names=["nonexistent"])
        assert result.all_passed is not None
