"""Tests for ast_to_facts.py.

Run with:  python3 -m unittest test_extractor -v
(from the souffle-lintq/extractor directory).

Includes:
  * Pure-Python checks of the emitted .facts against the EDB schema.
  * An end-to-end check (skipped automatically if `souffle` is not on PATH)
    that the .facts actually load and fire the three analyses.
"""

import os
import shutil
import subprocess
import sys
import tempfile
import unittest

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)

import ast_to_facts  # noqa: E402

SAMPLE = os.path.join(HERE, "sample_circuit.py")
ENGINE_DL = os.path.join(HERE, "..", "engine", "lintq.dl")


def read_facts(path):
    rows = []
    with open(path) as fh:
        for line in fh:
            line = line.rstrip("\n")
            rows.append(tuple(line.split("\t")) if line != "" else ())
    return rows


class ExtractorTests(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.mkdtemp(prefix="lintq_test_")
        with open(SAMPLE) as fh:
            source = fh.read()
        self.ext = ast_to_facts.QiskitFactExtractor()
        self.ext.run(source, self.tmp)

    def tearDown(self):
        shutil.rmtree(self.tmp, ignore_errors=True)

    # ---- schema / formatting ----
    def test_files_exist_and_tab_sep(self):
        for fname in ("Stmt.facts", "CFGEdge.facts", "Assign.facts",
                      "CircuitAlloc.facts", "GateOp.facts",
                      "MeasureOp.facts", "CircuitCall.facts"):
            self.assertTrue(
                os.path.exists(os.path.join(self.tmp, fname)),
                f"missing {fname}")

    def test_stmt_schema(self):
        rows = read_facts(os.path.join(self.tmp, "Stmt.facts"))
        self.assertTrue(rows, "Stmt.facts is empty")
        for r in rows:
            sid, line, func = r
            self.assertTrue(sid.isdigit(), f"Stmt id not numeric: {r}")
            self.assertTrue(line.isdigit(), f"Stmt line not numeric: {r}")
            self.assertTrue(isinstance(func, str) and func, f"Stmt func empty: {r}")

    def test_cfg_edge_schema(self):
        rows = read_facts(os.path.join(self.tmp, "CFGEdge.facts"))
        self.assertTrue(rows, "CFGEdge.facts is empty")
        for f, t in rows:
            self.assertTrue(f.isdigit() and t.isdigit(), f"bad CFGEdge: {(f, t)}")

    def test_assign_schema(self):
        rows = read_facts(os.path.join(self.tmp, "Assign.facts"))
        self.assertTrue(rows, "Assign.facts is empty")
        for stmt, dest, src in rows:
            self.assertTrue(stmt.isdigit())

    def test_circuit_alloc_schema(self):
        rows = read_facts(os.path.join(self.tmp, "CircuitAlloc.facts"))
        # sample has two QuantumCircuit(...) allocations
        self.assertEqual(len(rows), 3, f"CircuitAlloc count: {rows}")
        for stmt, var, nq, nc in rows:
            self.assertTrue(stmt.isdigit())
            self.assertTrue(nq.isdigit() and nc.isdigit())

    def test_gate_op_schema(self):
        rows = read_facts(os.path.join(self.tmp, "GateOp.facts"))
        self.assertTrue(rows, "GateOp.facts is empty")
        for stmt, circuit, gate, qidx in rows:
            self.assertTrue(stmt.isdigit() and qidx.isdigit())
            self.assertTrue(gate, "gate name empty")

    def test_measure_op_schema(self):
        rows = read_facts(os.path.join(self.tmp, "MeasureOp.facts"))
        # sample has 3 measure() calls
        self.assertEqual(len(rows), 3, f"MeasureOp count: {rows}")
        for stmt, circuit, qidx, cidx in rows:
            self.assertTrue(stmt.isdigit() and qidx.isdigit() and cidx.isdigit())

    def test_circuit_call_schema(self):
        rows = read_facts(os.path.join(self.tmp, "CircuitCall.facts"))
        self.assertTrue(rows, "CircuitCall.facts is empty")
        for stmt, method, target, arg in rows:
            self.assertTrue(stmt.isdigit() and method)

    # ---- domain correctness on the sample ----
    def test_detects_ghost_compose(self):
        calls = read_facts(os.path.join(self.tmp, "CircuitCall.facts"))
        compose = [r for r in calls if r[1] == "compose"]
        self.assertTrue(compose, "expected a compose CircuitCall")

    def test_detects_double_measure(self):
        meas = read_facts(os.path.join(self.tmp, "MeasureOp.facts"))
        q0 = [r for r in meas if r[2] == "0"]
        self.assertGreaterEqual(len(q0), 2, "expected >=2 measures of qubit 0")


@unittest.skipUnless(
    shutil.which("souffle"), "souffle binary not installed")
class PipelineTests(unittest.TestCase):
    """End-to-end: extract facts, run Souffle, check real warnings fire."""

    def setUp(self):
        self.tmp = tempfile.mkdtemp(prefix="lintq_pipe_")
        with open(SAMPLE) as fh:
            source = fh.read()
        self.ext = ast_to_facts.QiskitFactExtractor()
        self.ext.run(source, self.tmp)
        self.out = os.path.join(self.tmp, "out")

    def tearDown(self):
        shutil.rmtree(self.tmp, ignore_errors=True)

    def _run(self):
        os.makedirs(self.out, exist_ok=True)
        subprocess.run(
            ["souffle", "-F", self.tmp, "-D", self.out, ENGINE_DL],
            check=True, capture_output=True, text=True)

    def test_op_after_meas_fires(self):
        self._run()
        path = os.path.join(self.out, "WarnOpAfterMeas.csv")
        self.assertTrue(os.path.exists(path), "no WarnOpAfterMeas.csv")
        self.assertTrue(os.path.getsize(path) > 0, "OpAfterMeas found nothing")

    def test_double_meas_fires(self):
        self._run()
        path = os.path.join(self.out, "WarnDoubleMeas.csv")
        self.assertTrue(os.path.exists(path), "no WarnDoubleMeas.csv")
        self.assertTrue(os.path.getsize(path) > 0, "DoubleMeas found nothing")

    def test_ghost_compose_fires(self):
        self._run()
        path = os.path.join(self.out, "WarnGhostCompose.csv")
        self.assertTrue(os.path.exists(path), "no WarnGhostCompose.csv")
        self.assertTrue(os.path.getsize(path) > 0, "GhostCompose found nothing")


if __name__ == "__main__":
    unittest.main(verbosity=2)
