#!/usr/bin/env python3
"""Unit tests for view-conventions-check.

Run with:
    python3 .claude/hooks/view-conventions-check.test.py

Standard library only — no pytest. Each test prints a status line and the
script exits non-zero on any failure.
"""

from __future__ import annotations

import sys
sys.dont_write_bytecode = True

import importlib.util
import json
import subprocess
import tempfile
import textwrap
import time
from pathlib import Path

HOOK_DIR = Path(__file__).resolve().parent
HOOK_PATH = HOOK_DIR / "view-conventions-check.py"

# Load the hook as a module so we can call its helpers directly.
spec = importlib.util.spec_from_file_location("view_conventions_check", HOOK_PATH)
assert spec and spec.loader
hook = importlib.util.module_from_spec(spec)
spec.loader.exec_module(hook)


# ---------------------------------------------------------------------------
# Tiny test harness
# ---------------------------------------------------------------------------

_failures: list[str] = []


def check(label: str, ok: bool, detail: str = "") -> None:
    if ok:
        print(f"  ok   {label}")
    else:
        msg = f"  FAIL {label}" + (f" — {detail}" if detail else "")
        print(msg)
        _failures.append(msg)


# ---------------------------------------------------------------------------
# Fixture rules — mirror the real schema but isolated from project rules
# ---------------------------------------------------------------------------

FIXTURE_RULES = {
    "rules": [
        {
            "id": "use-printAttrValue",
            "title": "Output an attribute value with printAttrValue()",
            "severity": "error",
            "files": ["public/local/views/**/*.php"],
            "detect": [
                {"type": "regex", "pattern": r"htmlspecialchars\([^)]*->getAttr\("}
            ],
            "suggest": "Replace with $item->printAttrValue('attr', 'classes').",
            "reference": "docs/shared/view-development.md#printattrvalue",
        },
        {
            "id": "use-braces",
            "title": "Control structures use { }, never endif;",
            "severity": "error",
            "files": ["public/local/**/*.php"],
            "detect": [
                {"type": "regex", "pattern": r"(endif|endforeach|endwhile|endfor);"},
                {"type": "regex", "pattern": r"\b(if|elseif|else|foreach|while|for)\s*\([^)]*\)\s*:\s*(\?>)?"},
            ],
            "suggest": "Use { ... } syntax.",
            "reference": "docs/shared/code-style.md#control-structures",
        },
        {
            "id": "use-sieroty",
            "title": "Wrap user-visible strings in Strings::sieroty()",
            "severity": "advisory",
            "files": ["public/local/views/**/*.php"],
            "detect": [
                {
                    "type": "regex",
                    "pattern": r"<\?= *\$[a-zA-Z_][a-zA-Z0-9_]*\s*\?>",
                    "skipIfContext": ["sr-only", "linkText", "printAttrValue", "htmlspecialchars"],
                }
            ],
            "suggest": "Wrap in Strings::sieroty().",
            "reference": "docs/shared/view-development.md#sieroty",
        },
        {
            "id": "early-return-empty",
            "title": "Partial views early-return when content would be empty",
            "severity": "advisory",
            "files": ["public/local/views/**/*.php"],
            "detect": [{"type": "manual", "note": "AST-level"}],
            "suggest": "Return early.",
            "reference": "docs/shared/view-development.md#early-return-empty",
        },
    ]
}


def rule(rid: str) -> dict:
    return next(r for r in FIXTURE_RULES["rules"] if r["id"] == rid)


# ---------------------------------------------------------------------------
# glob_to_regex / matches_glob
# ---------------------------------------------------------------------------

def test_globbing() -> None:
    print("\n[glob matching]")
    cases = [
        ("public/local/views/modlocation/main.php", "public/local/views/**/*.php", True),
        ("public/local/views/foo.php", "public/local/views/**/*.php", True),
        ("public/local/views/a/b/c.php", "public/local/views/**/*.php", True),
        ("public/local/something.php", "public/local/views/**/*.php", False),
        ("public/local/views/x.txt", "public/local/views/**/*.php", False),
        ("public/local/models/foo.php", "public/local/**/*.php", True),
        ("src/app.ts", "public/local/**/*.php", False),
    ]
    for path, pattern, expect in cases:
        got = hook.matches_glob(path, pattern)
        check(f"{path!r} ~ {pattern!r} → {expect}", got == expect, f"got {got}")


# ---------------------------------------------------------------------------
# Skip directive parsing & stripping
# ---------------------------------------------------------------------------

def test_skip_directives() -> None:
    print("\n[skip directives]")
    lines = [
        "<?= $debugString ?> // lint-skip:use-sieroty",
        "<span><?= $foo ?></span> <!-- lint-skip:use-sieroty -->",
        "<?= $multi ?> // lint-skip:use-sieroty,use-braces",
        "<?= $bare ?> // lint-skip",            # ignored — no rule id
        "<?= $plain ?>",                        # no directive
    ]
    skips = hook.parse_skip_directives(lines)
    check("line 1 has use-sieroty", skips.get(1) == {"use-sieroty"}, str(skips))
    check("line 2 has use-sieroty (HTML form)", skips.get(2) == {"use-sieroty"}, str(skips))
    check("line 3 has both ids", skips.get(3) == {"use-sieroty", "use-braces"}, str(skips))
    check("line 4 ignored (bare directive)", 4 not in skips, str(skips))
    check("line 5 not present", 5 not in skips, str(skips))

    # Stripping must remove the comment so detection regex can't see it.
    stripped = hook.strip_skip_comment("<?= $x ?> // lint-skip:use-sieroty")
    check("strip drops trailing directive", stripped == "<?= $x ?>", repr(stripped))
    stripped_html = hook.strip_skip_comment("<x><?= $y ?></x> <!-- lint-skip:use-sieroty -->")
    check("strip drops HTML directive", stripped_html == "<x><?= $y ?></x>", repr(stripped_html))


# ---------------------------------------------------------------------------
# Per-rule detection: positive, negative, skip
# ---------------------------------------------------------------------------

def detect(rule_id: str, source: str) -> list[tuple[int, str]]:
    raw = source.splitlines()
    skips = hook.parse_skip_directives(raw)
    cleaned = [hook.strip_skip_comment(l) for l in raw]
    return hook.find_violations(rule(rule_id), cleaned, skips)


def test_use_printAttrValue() -> None:
    print("\n[use-printAttrValue]")
    pos = "<?= htmlspecialchars($item->getAttr('info'), ENT_QUOTES) ?>"
    neg = "<?= $item->printAttrValue('info', 'text-18') ?>"
    skip = "<?= htmlspecialchars($item->getAttr('info'), ENT_QUOTES) ?> // lint-skip:use-printAttrValue"
    check("positive matches", len(detect("use-printAttrValue", pos)) == 1)
    check("negative does not match", len(detect("use-printAttrValue", neg)) == 0)
    check("skip directive suppresses", len(detect("use-printAttrValue", skip)) == 0)


def test_use_braces() -> None:
    print("\n[use-braces]")
    pos_endif = "<?php endif; ?>"
    pos_alt = "<?php if ($x): ?>"
    neg_brace = "<?php if ($x) { ?>"
    skip = "<?php endif; ?> // lint-skip:use-braces"
    check("endif; matches", len(detect("use-braces", pos_endif)) >= 1)
    check("alternative if-syntax matches", len(detect("use-braces", pos_alt)) >= 1)
    check("brace form does not match", len(detect("use-braces", neg_brace)) == 0)
    check("skip directive suppresses", len(detect("use-braces", skip)) == 0)


def test_use_sieroty() -> None:
    print("\n[use-sieroty]")
    pos = "<div><?= $title ?></div>"
    neg_wrapped = "<div><?= Strings::sieroty($title) ?></div>"
    neg_method = "<?= $item->getID() ?>"
    neg_sr = "<span class=\"sr-only\"><?= $debug ?></span>"
    neg_link = "<?= $linkText ?> with linkText"
    skip = "<?= $debug ?> // lint-skip:use-sieroty"
    check("plain bare-var matches", len(detect("use-sieroty", pos)) == 1)
    check("Strings::sieroty wrapped does not match", len(detect("use-sieroty", neg_wrapped)) == 0)
    check("method call does not match (no $var match)", len(detect("use-sieroty", neg_method)) == 0)
    check("sr-only context skipped", len(detect("use-sieroty", neg_sr)) == 0)
    check("linkText context skipped", len(detect("use-sieroty", neg_link)) == 0)
    check("skip directive suppresses", len(detect("use-sieroty", skip)) == 0)


def test_early_return_manual() -> None:
    print("\n[early-return-empty (manual)]")
    pos_like = "<div class='x'><?php if ($item->getAttr('t')) { ?>...<?php } ?></div>"
    # The rule has only manual detect entries — find_violations must skip it.
    check("manual-detect rule emits nothing", len(detect("early-return-empty", pos_like)) == 0)


# ---------------------------------------------------------------------------
# Full hook invocation via subprocess (with fixture rule file)
# ---------------------------------------------------------------------------

def run_hook(payload: dict, rules_doc: dict, project_files: dict[str, str]) -> tuple[int, str, str]:
    """Run the hook in a temp project root and return (rc, stdout, stderr)."""
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        # Materialize project files
        for rel, content in project_files.items():
            p = root / rel
            p.parent.mkdir(parents=True, exist_ok=True)
            p.write_text(content, encoding="utf-8")
        # Write fixture rule file
        rules_path = root / ".claude" / "conventions" / "views.json"
        rules_path.parent.mkdir(parents=True, exist_ok=True)
        rules_path.write_text(json.dumps(rules_doc), encoding="utf-8")
        # Copy the hook into the temp tree so PROJECT_ROOT resolves there
        hook_dst_dir = root / ".claude" / "hooks"
        hook_dst_dir.mkdir(parents=True, exist_ok=True)
        hook_dst = hook_dst_dir / HOOK_PATH.name
        hook_dst.write_text(HOOK_PATH.read_text(encoding="utf-8"), encoding="utf-8")

        # Rewrite payload file_path to point inside the temp root
        proc = subprocess.run(
            ["python3", str(hook_dst)],
            input=json.dumps(payload),
            capture_output=True,
            text=True,
            timeout=10,
        )
        return proc.returncode, proc.stdout, proc.stderr


def test_subprocess_block_on_error() -> None:
    print("\n[subprocess: blocking error]")
    files = {
        "public/local/views/modfoo/modfoo_main.php": textwrap.dedent("""\
            <div>
              <?= htmlspecialchars($item->getAttr('info'), ENT_QUOTES) ?>
            </div>
            """),
    }
    payload = {
        "tool_name": "Edit",
        "tool_input": {"file_path": "public/local/views/modfoo/modfoo_main.php"},
    }
    rc, out, err = run_hook(payload, FIXTURE_RULES, files)
    check("exit 0 (uses JSON decision)", rc == 0, f"rc={rc} stderr={err!r}")
    try:
        body = json.loads(out)
    except json.JSONDecodeError:
        body = {}
    check("decision == block", body.get("decision") == "block", out)
    check("reason mentions rule id", "use-printAttrValue" in body.get("reason", ""), body.get("reason", ""))


def test_subprocess_advisory_only() -> None:
    print("\n[subprocess: advisory only]")
    files = {
        "public/local/views/modfoo/modfoo_box.php": "<div><?= $title ?></div>\n",
    }
    payload = {
        "tool_name": "Write",
        "tool_input": {"file_path": "public/local/views/modfoo/modfoo_box.php"},
    }
    rc, out, err = run_hook(payload, FIXTURE_RULES, files)
    check("exit 0", rc == 0, f"rc={rc} stderr={err!r}")
    try:
        body = json.loads(out)
    except json.JSONDecodeError:
        body = {}
    check("hookSpecificOutput present", "hookSpecificOutput" in body, out)
    ctx = body.get("hookSpecificOutput", {}).get("additionalContext", "")
    check("additionalContext mentions use-sieroty", "use-sieroty" in ctx, ctx)
    check("decision absent (non-blocking)", "decision" not in body, out)


def test_subprocess_skip_directive() -> None:
    print("\n[subprocess: skip directive]")
    files = {
        "public/local/views/modfoo/modfoo_box.php":
            "<div><?= $debug ?></div> <!-- lint-skip:use-sieroty -->\n",
    }
    payload = {
        "tool_name": "Edit",
        "tool_input": {"file_path": "public/local/views/modfoo/modfoo_box.php"},
    }
    rc, out, err = run_hook(payload, FIXTURE_RULES, files)
    check("exit 0", rc == 0, f"rc={rc} stderr={err!r}")
    body = json.loads(out) if out.strip() else {}
    ctx = body.get("hookSpecificOutput", {}).get("additionalContext", "")
    check("violation absent — directive suppressed it",
          "[advisory] use-sieroty" not in ctx,
          ctx)
    check("skip recorded for transparency",
          "[skip] line 1: rule use-sieroty suppressed" in ctx or ctx == "",
          ctx)


def test_subprocess_silent_outside_glob() -> None:
    print("\n[subprocess: file outside glob]")
    files = {
        "src/scripts/app.ts": "// not a view\n",
    }
    payload = {
        "tool_name": "Edit",
        "tool_input": {"file_path": "src/scripts/app.ts"},
    }
    rc, out, err = run_hook(payload, FIXTURE_RULES, files)
    check("exit 0", rc == 0, f"rc={rc} stderr={err!r}")
    check("no stdout", out == "", repr(out))


def test_subprocess_unrelated_tool() -> None:
    print("\n[subprocess: unrelated tool name]")
    payload = {"tool_name": "Read", "tool_input": {"file_path": "anything.php"}}
    rc, out, err = run_hook(payload, FIXTURE_RULES, {})
    check("exit 0", rc == 0, f"rc={rc} stderr={err!r}")
    check("no stdout", out == "", repr(out))


def test_subprocess_malformed_payload() -> None:
    print("\n[subprocess: malformed stdin]")
    with tempfile.TemporaryDirectory() as tmp:
        proc = subprocess.run(
            ["python3", str(HOOK_PATH)],
            input="not json at all",
            capture_output=True,
            text=True,
            cwd=tmp,
            timeout=10,
        )
    check("exit 0 on bad input", proc.returncode == 0, f"rc={proc.returncode}")


# ---------------------------------------------------------------------------
# CLI mode — --files / --staged, exit codes (commit-time / CI enforcement)
# ---------------------------------------------------------------------------

def _git_available() -> bool:
    try:
        subprocess.run(["git", "--version"], capture_output=True, timeout=5, check=True)
        return True
    except (OSError, subprocess.SubprocessError):
        return False


def run_cli_subprocess(cli_args, rules_doc, project_files, git_stage=False):
    """Run the hook in CLI mode inside a temp project root.

    Returns (rc, stdout, stderr). With git_stage, init a repo and stage all
    project files so --staged has an index to read from.
    """
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        for rel, content in project_files.items():
            p = root / rel
            p.parent.mkdir(parents=True, exist_ok=True)
            p.write_text(content, encoding="utf-8")
        rules_path = root / ".claude" / "conventions" / "views.json"
        rules_path.parent.mkdir(parents=True, exist_ok=True)
        rules_path.write_text(json.dumps(rules_doc), encoding="utf-8")
        # Copy the hook in so PROJECT_ROOT (and thus git cwd) resolves to root.
        hook_dst = root / ".claude" / "hooks" / HOOK_PATH.name
        hook_dst.parent.mkdir(parents=True, exist_ok=True)
        hook_dst.write_text(HOOK_PATH.read_text(encoding="utf-8"), encoding="utf-8")
        if git_stage:
            subprocess.run(["git", "init", "-q"], cwd=root, check=True,
                           capture_output=True)
            subprocess.run(["git", "add", "-A"], cwd=root, check=True,
                           capture_output=True)
        proc = subprocess.run(
            ["python3", str(hook_dst), *cli_args],
            capture_output=True, text=True, timeout=15,
        )
        return proc.returncode, proc.stdout, proc.stderr


def test_cli_files_block() -> None:
    print("\n[cli: --files blocking]")
    files = {"public/local/views/modfoo/modfoo_main.php":
             "<?php if ($x): ?>\nhi\n<?php endif; ?>\n"}
    rc, out, err = run_cli_subprocess(
        ["--files", "public/local/views/modfoo/modfoo_main.php"], FIXTURE_RULES, files)
    check("exit 1 on error-severity", rc == 1, f"rc={rc} err={err!r}")
    check("stderr names the rule", "use-braces" in err, err)
    check("stderr offers --no-verify escape", "no-verify" in err, err)


def test_cli_files_clean() -> None:
    print("\n[cli: --files clean]")
    files = {"public/local/views/modfoo/modfoo_main.php":
             "<?php if ($x) { ?>\nhi\n<?php } ?>\n"}
    rc, out, err = run_cli_subprocess(
        ["--files", "public/local/views/modfoo/modfoo_main.php"], FIXTURE_RULES, files)
    check("exit 0", rc == 0, f"rc={rc} err={err!r}")


def test_cli_files_advisory_only() -> None:
    print("\n[cli: --files advisory only → non-blocking]")
    files = {"public/local/views/modfoo/modfoo_box.php": "<div><?= $title ?></div>\n"}
    rc, out, err = run_cli_subprocess(
        ["--files", "public/local/views/modfoo/modfoo_box.php"], FIXTURE_RULES, files)
    check("exit 0 (advisory never blocks)", rc == 0, f"rc={rc} err={err!r}")
    check("advisory is reported", "use-sieroty" in err, err)


def test_cli_files_lint_skip() -> None:
    print("\n[cli: --files lint-skip suppresses]")
    files = {"public/local/views/modfoo/modfoo_main.php":
             "<?php if ($x): ?> // lint-skip:use-braces\nhi\n"
             "<?php endif; ?> <!-- lint-skip:use-braces -->\n"}
    rc, out, err = run_cli_subprocess(
        ["--files", "public/local/views/modfoo/modfoo_main.php"], FIXTURE_RULES, files)
    check("exit 0 (directives suppress)", rc == 0, f"rc={rc} err={err!r}")


def test_cli_staged_block() -> None:
    print("\n[cli: --staged blocking]")
    if not _git_available():
        check("git available (test skipped)", True, "git not found")
        return
    files = {"public/local/views/modfoo/modfoo_main.php":
             "<?php if ($x): ?>\nhi\n<?php endif; ?>\n"}
    rc, out, err = run_cli_subprocess(["--staged"], FIXTURE_RULES, files, git_stage=True)
    check("exit 1 on staged error", rc == 1, f"rc={rc} err={err!r}")
    check("stderr names the staged file", "modfoo_main.php" in err, err)
    check("stderr names the rule", "use-braces" in err, err)


def test_cli_staged_clean() -> None:
    print("\n[cli: --staged clean]")
    if not _git_available():
        check("git available (test skipped)", True, "git not found")
        return
    files = {"public/local/views/modfoo/modfoo_main.php":
             "<?php if ($x) { ?>\nhi\n<?php } ?>\n"}
    rc, out, err = run_cli_subprocess(["--staged"], FIXTURE_RULES, files, git_stage=True)
    check("exit 0", rc == 0, f"rc={rc} err={err!r}")


# ---------------------------------------------------------------------------
# Performance benchmark — must complete in under 200ms on a typical view
# ---------------------------------------------------------------------------

def test_performance() -> None:
    print("\n[performance benchmark]")
    typical_view = textwrap.dedent("""\
        <?php
        $bgColorSlug = $item->getBgColorSlugFullHeader();
        ?>
        <div class="modlocation-main relative overflow-hidden"
          data-as-view="webflow" data-as-id="mloc<?= $item->getID() ?>">
          <div class="<?= ltrim($bgColorClass) ?>">
            <?php if ($bgShapeSlug) { ?>
              <div class="absolute top-0">
                <?php __svg($bgShapeSlug, 'w-full'); ?>
              </div>
            <?php } ?>
            <div class="relative z-10">
              <?php $item->printMe('main_header'); ?>
              <?php $item->printMe('content'); ?>
            </div>
          </div>
        </div>
        """) * 5  # ~80 lines, ~2 KB

    files = {"public/local/views/modlocation/modlocation_main.php": typical_view}
    payload = {
        "tool_name": "Edit",
        "tool_input": {"file_path": "public/local/views/modlocation/modlocation_main.php"},
    }
    t0 = time.perf_counter()
    rc, out, err = run_hook(payload, FIXTURE_RULES, files)
    elapsed_ms = (time.perf_counter() - t0) * 1000
    print(f"  info elapsed={elapsed_ms:.1f} ms (subprocess incl. python startup)")
    check("rc == 0", rc == 0, err)
    check("under 200 ms", elapsed_ms < 200.0,
          f"elapsed={elapsed_ms:.1f} ms — note: includes python startup")


# ---------------------------------------------------------------------------
# Driver
# ---------------------------------------------------------------------------

def main() -> int:
    test_globbing()
    test_skip_directives()
    test_use_printAttrValue()
    test_use_braces()
    test_use_sieroty()
    test_early_return_manual()
    test_subprocess_block_on_error()
    test_subprocess_advisory_only()
    test_subprocess_skip_directive()
    test_subprocess_silent_outside_glob()
    test_subprocess_unrelated_tool()
    test_subprocess_malformed_payload()
    test_cli_files_block()
    test_cli_files_clean()
    test_cli_files_advisory_only()
    test_cli_files_lint_skip()
    test_cli_staged_block()
    test_cli_staged_clean()
    test_performance()

    print()
    if _failures:
        print(f"FAILED: {len(_failures)} check(s)")
        return 1
    print("All checks passed.")
    return 0


if __name__ == "__main__":
    sys.exit(main())
