"""CTF compliance checker — 23-rule validation for jkpfactors.com submissions.

AUTO checks (Rules 1, 4, 5, 6, 8, 10, 11, 12, 13, 14, 15, 16, 17, 18):
    Fully programmatic; each produces a CheckResult with severity and detail.

MANUAL checks (Rules 2, 3, 7, 9, 19, 20, 21, 22, 23):
    Cannot be automated; surfaced as a human-review checklist.

Usage::

    result = validate(script_path="model.py", weights_df=weights)
    print(result)  # human-readable report
    result.passed  # True if no FAIL-severity AUTO checks
"""

from __future__ import annotations

import ast
import io
import subprocess
import tempfile
import textwrap
from dataclasses import dataclass, field
from pathlib import Path

import numpy as np
import pandas as pd

from eapctf.ctf.interface import CTF_PREINSTALLED

# ---------------------------------------------------------------------------
# Result data structures
# ---------------------------------------------------------------------------


@dataclass
class CheckResult:
    """Result of a single CTF rule check."""

    rule: int
    name: str
    severity: str  # "PASS", "FAIL", "WARNING", "INFO", "SKIP"
    detail: str
    auto: bool = True  # False for manual checklist items


@dataclass
class ValidationResult:
    """Aggregated result of all CTF compliance checks."""

    checks: list[CheckResult] = field(default_factory=list)

    @property
    def passed(self) -> bool:
        """True if no FAIL-severity AUTO check exists."""
        return all(c.severity != "FAIL" for c in self.checks if c.auto)

    @property
    def auto_checks(self) -> list[CheckResult]:
        return [c for c in self.checks if c.auto]

    @property
    def manual_checks(self) -> list[CheckResult]:
        return [c for c in self.checks if not c.auto]

    def __str__(self) -> str:
        lines: list[str] = []
        lines.append("=" * 70)
        lines.append("CTF COMPLIANCE REPORT")
        lines.append("=" * 70)

        lines.append("\n--- AUTO CHECKS ---")
        for c in self.auto_checks:
            icon = {"PASS": "✓", "FAIL": "✗", "WARNING": "!", "INFO": "i", "SKIP": "-"}.get(
                c.severity, "?"
            )
            lines.append(f"  [{icon}] Rule {c.rule:2d}  {c.severity:<8} {c.name}")
            if c.detail:
                for line in textwrap.wrap(c.detail, width=66, initial_indent="            "):
                    lines.append(line)

        lines.append("\n--- MANUAL REVIEW CHECKLIST ---")
        for c in self.manual_checks:
            lines.append(f"  [ ] Rule {c.rule:2d}  {c.name}")
            if c.detail:
                for line in textwrap.wrap(c.detail, width=66, initial_indent="            "):
                    lines.append(line)

        status = "PASS" if self.passed else "FAIL"
        lines.append(f"\nOverall AUTO result: {status}")
        return "\n".join(lines)


# ---------------------------------------------------------------------------
# AST helpers
# ---------------------------------------------------------------------------


def _parse_ast(script_path: Path) -> ast.Module | None:
    """Return parsed AST or None on syntax error."""
    try:
        source = script_path.read_text(encoding="utf-8")
        return ast.parse(source)
    except (SyntaxError, UnicodeDecodeError):
        return None


def _collect_imports(tree: ast.Module) -> list[str]:
    """Return top-level module names imported in the script."""
    names: list[str] = []
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            for alias in node.names:
                names.append(alias.name.split(".")[0])
        elif isinstance(node, ast.ImportFrom):
            if node.module:
                names.append(node.module.split(".")[0])
    return names


def _has_name(tree: ast.Module, name: str) -> bool:
    """Return True if a function/class with ``name`` is defined at top level."""
    for node in tree.body:
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            if node.name == name:
                return True
    return False


def _get_function_node(tree: ast.Module, name: str) -> ast.FunctionDef | None:
    for node in tree.body:
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name:
            return node  # type: ignore[return-value]
    return None


def _ast_contains_call(tree: ast.AST, func_names: set[str]) -> list[str]:
    """Return which of func_names appear as calls anywhere in the AST."""
    found: list[str] = []
    for node in ast.walk(tree):
        if isinstance(node, ast.Call):
            fn = node.func
            if isinstance(fn, ast.Name) and fn.id in func_names:
                if fn.id not in found:
                    found.append(fn.id)
            elif isinstance(fn, ast.Attribute) and fn.attr in func_names:
                if fn.attr not in found:
                    found.append(fn.attr)
    return found


def _ast_has_attribute_chain(tree: ast.AST, chains: list[str]) -> list[str]:
    """Check for dotted attribute accesses like 'os.system'."""
    found: list[str] = []
    for node in ast.walk(tree):
        if isinstance(node, ast.Attribute):
            # Build chain from nested Attributes
            parts: list[str] = []
            cur: ast.expr = node
            while isinstance(cur, ast.Attribute):
                parts.append(cur.attr)
                cur = cur.value
            if isinstance(cur, ast.Name):
                parts.append(cur.id)
            parts.reverse()
            chain = ".".join(parts)
            for target in chains:
                if chain == target and target not in found:
                    found.append(target)
    return found


# ---------------------------------------------------------------------------
# Individual rule checks
# ---------------------------------------------------------------------------


def _check_rule1_lookahead(
    script_path: Path,
    data_dir: Path | None,
    weights_df: pd.DataFrame | None,
) -> CheckResult:
    """Rule 1: No look-ahead bias (truncated-vs-full comparison)."""
    if weights_df is None or data_dir is None:
        return CheckResult(
            1, "No look-ahead bias", "SKIP",
            "Skipped: weights_df or data_dir not provided. "
            "Run run_local() and pass weights_df to enable this check.",
        )

    from eapctf.ctf.interface import run_local

    try:
        weights_full = weights_df.copy()
        # Truncate to 80% of unique dates for comparison
        chars = pd.read_parquet(data_dir / "ctff_chars.parquet")
        if "eom" not in chars.columns:
            raise KeyError("eom column missing from ctff_chars")
        all_eom = sorted(chars["eom"].unique())
        cutoff_idx = int(len(all_eom) * 0.8)
        cutoff_date = all_eom[cutoff_idx]

        with tempfile.TemporaryDirectory() as tmp:
            tmp_path = Path(tmp)
            chars_trunc = chars[chars["eom"] <= cutoff_date]
            chars_trunc.to_parquet(tmp_path / "ctff_chars.parquet")
            # Copy other files unchanged (they don't have the truncation issue)
            import shutil
            for fname in ("ctff_features.parquet", "ctff_daily_ret.parquet"):
                src = data_dir / fname
                if src.exists():
                    shutil.copy(src, tmp_path / fname)
            weights_trunc = run_local(script_path, tmp_path)

        # Compare overlapping dates
        trunc_dates = set(weights_trunc["eom"].unique())
        full_dates = set(weights_full["eom"].unique())
        overlap = trunc_dates & full_dates

        merged = weights_full[weights_full["eom"].isin(overlap)].merge(
            weights_trunc[weights_trunc["eom"].isin(overlap)],
            on=["id", "eom"], suffixes=("_full", "_trunc"),
        )
        if merged.empty:
            return CheckResult(
                1, "No look-ahead bias", "WARNING",
                "No overlapping (id, eom) rows to compare between full and truncated runs.",
            )

        diff = (merged["w_full"] - merged["w_trunc"]).abs()
        max_rel = (diff / (merged["w_full"].abs() + 1e-10)).max()
        if max_rel > 1e-5:
            return CheckResult(1, "No look-ahead bias", "FAIL",
                               f"Max relative divergence {max_rel:.2e} > 1e-5 between full and "
                               f"truncated runs. Possible look-ahead leak.")
        return CheckResult(1, "No look-ahead bias", "PASS",
                           f"Full vs. truncated run divergence {max_rel:.2e} ≤ 1e-5.")
    except Exception as exc:
        return CheckResult(1, "No look-ahead bias", "SKIP",
                           f"Could not run truncation test: {type(exc).__name__}: {exc}")


def _check_rule4_seed(tree: ast.Module) -> CheckResult:
    """Rule 4: Reproducibility — np.random.seed or torch.manual_seed present."""
    seed_calls = {"seed", "manual_seed", "set_seed"}
    found = _ast_contains_call(tree, seed_calls)
    if found:
        return CheckResult(4, "Random seed set", "PASS", f"Found seed call(s): {found}")
    return CheckResult(4, "Random seed set", "WARNING",
                       "No np.random.seed / torch.manual_seed found. "
                       "Output may not be deterministic across runs.")


def _check_rule5_main_exists(tree: ast.Module) -> CheckResult:
    """Rule 5: main() function defined."""
    if _has_name(tree, "main"):
        return CheckResult(5, "main() defined", "PASS", "")
    return CheckResult(5, "main() defined", "FAIL",
                       "No function named 'main' found at module top level.")


def _check_rule6_monthly_rebalance(weights_df: pd.DataFrame | None) -> CheckResult:
    """Rule 6: Monthly rebalancing — eom column contains valid month-end dates."""
    if weights_df is None:
        return CheckResult(6, "Monthly rebalancing", "SKIP",
                           "weights_df not provided.")
    if "eom" not in weights_df.columns:
        return CheckResult(6, "Monthly rebalancing", "FAIL",
                           "Column 'eom' missing from weights DataFrame.")
    try:
        dates = pd.to_datetime(weights_df["eom"])
    except Exception as exc:
        return CheckResult(6, "Monthly rebalancing", "FAIL",
                           f"Cannot parse 'eom' as dates: {exc}")

    # Check all dates are month-ends (last day of their respective month)
    expected_eom = dates + pd.offsets.MonthEnd(0)
    bad = dates[dates != expected_eom]
    if len(bad) > 0:
        examples = bad.dt.strftime("%Y-%m-%d").unique()[:3].tolist()
        return CheckResult(6, "Monthly rebalancing", "WARNING",
                           f"{len(bad)} eom values are not month-end dates: {examples}")
    return CheckResult(6, "Monthly rebalancing", "PASS",
                       f"{dates.nunique()} unique month-end dates detected.")


def _check_rule8_deps(tree: ast.Module, requirements_path: Path | None) -> CheckResult:
    """Rule 8: Non-pre-installed packages must be in requirements.txt."""
    imported = set(_collect_imports(tree))
    non_preinstalled = imported - CTF_PREINSTALLED
    # Also remove any empty strings
    non_preinstalled.discard("")

    if not non_preinstalled:
        return CheckResult(8, "Dependency declarations", "PASS",
                           "All imports are pre-installed by CTF environment.")

    if requirements_path is None or not requirements_path.exists():
        return CheckResult(8, "Dependency declarations", "WARNING",
                           f"Non-pre-installed imports detected: {sorted(non_preinstalled)}. "
                           f"requirements.txt not found — add it to the submission bundle.")

    req_text = requirements_path.read_text(encoding="utf-8").lower()
    missing = [p for p in sorted(non_preinstalled) if p.lower() not in req_text]
    if missing:
        return CheckResult(8, "Dependency declarations", "WARNING",
                           f"Non-pre-installed imports not in requirements.txt: {missing}")
    return CheckResult(8, "Dependency declarations", "PASS",
                       f"Non-pre-installed imports declared: {sorted(non_preinstalled)}")


def _check_rule10_network(tree: ast.Module) -> CheckResult:
    """Rule 10: No network calls inside main() — AST scan for socket/urllib/requests."""
    network_modules = {"socket", "urllib", "requests", "http", "httpx", "aiohttp", "ftplib"}
    imported = set(_collect_imports(tree))
    found = sorted(imported & network_modules)
    if found:
        return CheckResult(10, "No network calls", "FAIL",
                           f"Network-related imports detected: {found}. "
                           f"Submitted code must not make network calls at runtime.")
    return CheckResult(10, "No network calls", "PASS", "No network imports detected.")


def _check_rule11_signature(tree: ast.Module) -> CheckResult:
    """Rule 11: main() signature is exactly (chars, features, daily_ret)."""
    main_node = _get_function_node(tree, "main")
    if main_node is None:
        return CheckResult(11, "main() signature", "SKIP",
                           "main() not found (checked by Rule 5).")
    args = main_node.args
    positional = [a.arg for a in args.args]
    if positional == ["chars", "features", "daily_ret"]:
        return CheckResult(11, "main() signature", "PASS",
                           "Signature: main(chars, features, daily_ret).")
    return CheckResult(11, "main() signature", "FAIL",
                       f"Expected (chars, features, daily_ret), got ({', '.join(positional)}).")


def _check_rule12_output_schema(weights_df: pd.DataFrame | None) -> CheckResult:
    """Rule 12: Output schema — id/eom/w, no NaN, no dup (id,eom), <50 MB."""
    if weights_df is None:
        return CheckResult(12, "Output schema", "SKIP", "weights_df not provided.")

    issues: list[str] = []

    # Required columns
    for col in ("id", "eom", "w"):
        if col not in weights_df.columns:
            issues.append(f"Missing column '{col}'")

    if issues:
        return CheckResult(12, "Output schema", "FAIL", "; ".join(issues))

    # Non-empty
    if weights_df.empty:
        return CheckResult(12, "Output schema", "FAIL", "weights DataFrame is empty.")

    # No NaN
    nan_cols = [c for c in ("id", "eom", "w") if weights_df[c].isna().any()]
    if nan_cols:
        issues.append(f"NaN values in columns: {nan_cols}")

    # w must be finite float
    if not np.isfinite(weights_df["w"].astype(float)).all():
        issues.append("Non-finite values in 'w' column")

    # No duplicate (id, eom)
    dup = weights_df.duplicated(subset=["id", "eom"]).sum()
    if dup > 0:
        issues.append(f"{dup} duplicate (id, eom) pairs")

    # eom format YYYY-MM-DD
    try:
        pd.to_datetime(weights_df["eom"], format="%Y-%m-%d")
    except Exception:
        issues.append("'eom' column not in YYYY-MM-DD format")

    # Size < 50 MB
    csv_buf = io.StringIO()
    weights_df.to_csv(csv_buf, index=False)
    size_mb = csv_buf.tell() / (1024 ** 2)
    if size_mb > 50:
        issues.append(f"Serialized size {size_mb:.1f} MB exceeds 50 MB limit")

    if issues:
        return CheckResult(12, "Output schema", "FAIL", "; ".join(issues))

    return CheckResult(12, "Output schema", "PASS",
                       f"{len(weights_df):,} rows, {size_mb:.2f} MB, "
                       f"{weights_df['eom'].nunique()} unique dates.")


def _check_rule13_file_constraints(script_path: Path) -> CheckResult:
    """Rule 13: Script ≤ 1 MB, UTF-8 encoding."""
    try:
        content = script_path.read_bytes()
    except OSError as exc:
        return CheckResult(13, "File constraints", "FAIL", str(exc))

    size_kb = len(content) / 1024
    if size_kb > 1024:
        return CheckResult(13, "File constraints", "FAIL",
                           f"Script size {size_kb:.0f} KB exceeds 1 MB limit.")
    try:
        content.decode("utf-8")
    except UnicodeDecodeError as exc:
        return CheckResult(13, "File constraints", "FAIL",
                           f"Script is not valid UTF-8: {exc}")
    return CheckResult(13, "File constraints", "PASS",
                       f"Script size {size_kb:.1f} KB, valid UTF-8.")


def _check_rule14_validation_mode(
    script_path: Path,
    data_dir: Path | None,
) -> CheckResult:
    """Rule 14: Validation mode — script runs on a 123-month synthetic subset."""
    if data_dir is None:
        return CheckResult(14, "Validation mode (123m)", "SKIP",
                           "data_dir not provided.")
    from eapctf.ctf.interface import run_local

    try:
        chars = pd.read_parquet(data_dir / "ctff_chars.parquet")
        features = pd.read_parquet(data_dir / "ctff_features.parquet")
        daily_ret = pd.read_parquet(data_dir / "ctff_daily_ret.parquet")

        if "eom" not in chars.columns:
            return CheckResult(14, "Validation mode (123m)", "SKIP",
                               "Cannot truncate: 'eom' column missing.")

        all_eom = sorted(chars["eom"].unique())
        subset_eom = all_eom[:123] if len(all_eom) >= 123 else all_eom
        chars_val = chars[chars["eom"].isin(subset_eom)]

        with tempfile.TemporaryDirectory() as tmp:
            tmp_path = Path(tmp)
            chars_val.to_parquet(tmp_path / "ctff_chars.parquet")
            features.to_parquet(tmp_path / "ctff_features.parquet")
            daily_ret.to_parquet(tmp_path / "ctff_daily_ret.parquet")
            weights = run_local(script_path, tmp_path)

        if weights is None or (hasattr(weights, "empty") and weights.empty):
            return CheckResult(14, "Validation mode (123m)", "WARNING",
                               "Script returned empty result on 123-month subset.")
        return CheckResult(14, "Validation mode (123m)", "PASS",
                           f"Script produced {len(weights):,} rows on "
                           f"{min(123, len(all_eom))}-month subset.")
    except Exception as exc:
        return CheckResult(14, "Validation mode (123m)", "FAIL",
                           f"Script crashed on 123-month validation subset: "
                           f"{type(exc).__name__}: {exc}")


def _check_rule15_prohibited_ops(tree: ast.Module) -> CheckResult:
    """Rule 15: No subprocess, os.system, os.popen, eval, exec, compile."""
    dangerous_names = {"eval", "exec", "compile"}
    dangerous_chains = {"os.system", "os.popen", "subprocess.run", "subprocess.Popen",
                        "subprocess.call", "subprocess.check_output"}

    # Check direct calls: eval(), exec(), compile()
    direct_found = _ast_contains_call(tree, dangerous_names)

    # Check attribute chains: os.system(), subprocess.Popen()
    chain_found = _ast_has_attribute_chain(tree, list(dangerous_chains))

    # Also check for subprocess import
    imports = set(_collect_imports(tree))
    if "subprocess" in imports:
        chain_found.append("subprocess (import)")

    all_found = sorted(set(direct_found + chain_found))
    if all_found:
        # Separate out eval/exec/subprocess calls (FAIL) from bare import (WARNING)
        blockers = [f for f in all_found if f not in {"subprocess (import)"}]
        if blockers:
            return CheckResult(15, "No prohibited operations", "FAIL",
                               f"Prohibited calls/imports: {blockers}")
        return CheckResult(15, "No prohibited operations", "WARNING",
                           f"Found: {all_found}. Review if subprocess is used safely.")

    # Check for /dev/shm writes (INFO only)
    shm_strs = [
        node.value for node in ast.walk(tree)
        if isinstance(node, ast.Constant) and isinstance(node.value, str)
        and "/dev/shm" in node.value
    ]
    if shm_strs:
        return CheckResult(15, "No prohibited operations", "INFO",
                           f"/dev/shm path literals found ({len(shm_strs)}). "
                           f"Permitted temp dir, but flag for review.")

    return CheckResult(15, "No prohibited operations", "PASS",
                       "No prohibited operations detected.")


def _check_rule16_deps_security(requirements_path: Path | None) -> CheckResult:
    """Rule 16: Dependency security — run pip-audit if available."""
    if requirements_path is None or not requirements_path.exists():
        return CheckResult(16, "Dependency security", "SKIP",
                           "requirements.txt not found.")
    try:
        pip_audit = subprocess.run(
            ["pip-audit", "--requirement", str(requirements_path), "--format", "json"],
            capture_output=True, text=True,
        )
    except FileNotFoundError:
        return CheckResult(16, "Dependency security", "WARNING",
                           "pip-audit not installed. Run: pip install pip-audit; "
                           "pip-audit -r requirements.txt")
    if pip_audit.returncode != 0:
        return CheckResult(16, "Dependency security", "WARNING",
                           f"pip-audit reported issues: {pip_audit.stdout[:300]}")
    return CheckResult(16, "Dependency security", "PASS",
                       "pip-audit found no known vulnerabilities.")


def _check_rule17_logging(tree: ast.Module) -> CheckResult:
    """Rule 17: Prefer print() over logging module."""
    imports = set(_collect_imports(tree))
    if "logging" in imports:
        return CheckResult(17, "Prefer print() over logging", "WARNING",
                           "'logging' module imported. CTF environment prefers print() "
                           "for output; logging may suppress messages silently.")
    return CheckResult(17, "Prefer print() over logging", "PASS", "")


def _check_rule18_determinism(
    script_path: Path,
    data_dir: Path | None,
) -> CheckResult:
    """Rule 18: Determinism — run main() twice, compare outputs within tolerance."""
    if data_dir is None:
        return CheckResult(18, "Determinism", "SKIP", "data_dir not provided.")
    from eapctf.ctf.interface import run_local

    try:
        w1 = run_local(script_path, data_dir)
        w2 = run_local(script_path, data_dir)
    except Exception as exc:
        return CheckResult(18, "Determinism", "SKIP",
                           f"Could not run determinism check: {type(exc).__name__}: {exc}")

    if w1.shape != w2.shape:
        return CheckResult(18, "Determinism", "FAIL",
                           f"Output shape differs between runs: {w1.shape} vs {w2.shape}")

    # Sort both by (id, eom) before comparing
    key = ["eom", "id"]
    try:
        w1s = w1.sort_values(key).reset_index(drop=True)
        w2s = w2.sort_values(key).reset_index(drop=True)
    except KeyError:
        return CheckResult(18, "Determinism", "SKIP",
                           "Cannot sort output (missing id/eom columns).")

    if not w1s[["id", "eom"]].equals(w2s[["id", "eom"]]):
        return CheckResult(18, "Determinism", "FAIL",
                           "Row identities differ between runs (different id/eom sets).")

    diff = (w1s["w"].astype(float) - w2s["w"].astype(float)).abs()
    max_abs = float(diff.max())
    ref = w1s["w"].astype(float).abs() + 1e-10
    max_rel = float((diff / ref).max())

    if max_abs > 1e-8 or max_rel > 1e-5:
        return CheckResult(18, "Determinism", "FAIL",
                           f"Outputs differ between two identical runs: "
                           f"max_abs={max_abs:.2e}, max_rel={max_rel:.2e}. "
                           f"Set np.random.seed() and avoid parallel non-determinism.")
    return CheckResult(18, "Determinism", "PASS",
                       f"Two runs agree to max_abs={max_abs:.2e}, max_rel={max_rel:.2e}.")


# ---------------------------------------------------------------------------
# Manual checklist
# ---------------------------------------------------------------------------

_MANUAL_CHECKS: list[tuple[int, str, str]] = [
    (2,  "Algorithmic feature selection",
     "No hardcoded variable lists derived from known winners (hindsight bias)."),
    (3,  "No external data",
     "No merges with data sources outside the three CTF files at runtime."),
    (7,  "Academic integrity",
     "Prior work cited where applicable; novel contributions clearly stated."),
    (9,  "Compute budget",
     "Estimated runtime < 24h on 32 cores / 300 GB RAM. Peak memory checked. "
     "joblib backend: prefer 'loky' over 'threading' to avoid NumPy deadlocks."),
    (19, "Admin adjustments accepted",
     "Submitter accepts that CTF admins may fix minor debugging artifacts."),
    (20, "Author identification",
     "Real first and last name provided; no pseudonyms."),
    (21, "Public leaderboard consent",
     "Submitter accepts: code, weights, and metrics will be publicly visible."),
    (22, "Withdrawal policy understood",
     "Submitter knows the process for requesting removal after publication."),
    (23, "Review process understood",
     "Submitter understands flagging criteria: unusual performance, "
     "suspected look-ahead, non-compliant features."),
]


# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------


def validate(
    script_path: str | Path,
    weights_df: pd.DataFrame | None = None,
    data_dir: str | Path | None = None,
    requirements_path: str | Path | None = None,
) -> ValidationResult:
    """Run all 23 CTF compliance checks against a submission script.

    Parameters
    ----------
    script_path : str | Path
        Path to the submitted Python script.
    weights_df : pd.DataFrame | None
        Output of ``main(chars, features, daily_ret)``.  Required for
        Rules 6, 12.  Also enables Rule 1 truncation test.
    data_dir : str | Path | None
        Directory with ctff_chars/features/daily_ret.parquet.  Required
        for Rules 1, 14, 18.
    requirements_path : str | Path | None
        Path to requirements.txt.  Used for Rules 8, 16.

    Returns
    -------
    ValidationResult
        Contains all CheckResult objects and a ``passed`` property.
    """
    script_path = Path(script_path).resolve()
    data_dir = Path(data_dir).resolve() if data_dir is not None else None
    requirements_path = (
        Path(requirements_path).resolve() if requirements_path is not None else None
    )

    result = ValidationResult()

    # Parse AST first; all AST-based checks depend on this
    tree = _parse_ast(script_path)
    if tree is None:
        result.checks.append(CheckResult(
            0, "Script parseable", "FAIL",
            f"Script {script_path.name} has syntax errors or encoding issues. "
            f"Fix before running further checks.",
        ))
        # Add manual checks even if AST fails
        for rule, name, detail in _MANUAL_CHECKS:
            result.checks.append(CheckResult(rule, name, "INFO", detail, auto=False))
        return result

    # AUTO checks
    result.checks.append(_check_rule1_lookahead(script_path, data_dir, weights_df))
    result.checks.append(_check_rule4_seed(tree))
    result.checks.append(_check_rule5_main_exists(tree))
    result.checks.append(_check_rule6_monthly_rebalance(weights_df))
    result.checks.append(_check_rule8_deps(tree, requirements_path))
    result.checks.append(_check_rule10_network(tree))
    result.checks.append(_check_rule11_signature(tree))
    result.checks.append(_check_rule12_output_schema(weights_df))
    result.checks.append(_check_rule13_file_constraints(script_path))
    result.checks.append(_check_rule14_validation_mode(script_path, data_dir))
    result.checks.append(_check_rule15_prohibited_ops(tree))
    result.checks.append(_check_rule16_deps_security(requirements_path))
    result.checks.append(_check_rule17_logging(tree))
    result.checks.append(_check_rule18_determinism(script_path, data_dir))

    # MANUAL checklist (always added, severity="INFO")
    for rule, name, detail in _MANUAL_CHECKS:
        result.checks.append(CheckResult(rule, name, "INFO", detail, auto=False))

    return result
