"""Deterministic structural-contract lint over skills/dashboard/SKILL.md (REC-229
Phase 4). Asserts STRUCTURE + ORDER + command-shape — stronger than the grep gate.

This is the strongest gate a markdown skill admits: it proves the required
sections are present, ordered (local-axes < Linear-read < compose), and that the
required commands are command-shaped (not just words in prose). It does NOT prove
the agent FOLLOWS the instructions — that ceiling is acknowledged.
"""

from __future__ import annotations

import re
from pathlib import Path

import pytest

REPO_ROOT = Path(__file__).resolve().parents[4]
SKILL = REPO_ROOT / "skills" / "dashboard" / "SKILL.md"


@pytest.fixture(scope="module")
def text() -> str:
    return SKILL.read_text(encoding="utf-8")


def test_skill_exists():
    assert SKILL.is_file(), f"missing {SKILL}"


def test_yaml_frontmatter(text):
    lines = text.splitlines()
    assert lines[0].strip() == "---", "skill must open with a --- frontmatter fence"
    # find the closing fence
    close = None
    for i in range(1, len(lines)):
        if lines[i].strip() == "---":
            close = i
            break
    assert close is not None, "frontmatter must close with ---"
    fm = "\n".join(lines[1:close])
    assert re.search(r"(?m)^name:\s*dashboard\s*$", fm), "frontmatter must have name: dashboard"
    assert re.search(r"(?m)^description:\s*\S", fm), "frontmatter must have a non-empty description:"


def _index_of(text: str, *patterns: str) -> int:
    for pat in patterns:
        m = re.search(pat, text, re.IGNORECASE)
        if m:
            return m.start()
    raise AssertionError(f"none of {patterns!r} found in skill")


def test_required_sections_ordered(text):
    local_idx = _index_of(text, r"recoil_dashboard\.py")
    linear_idx = _index_of(text, r"mcp__linear__list_issues")
    # Anchor on the compose section heading (not the prose "unified pane" mention,
    # which appears earlier in the degraded-path text and would weaken the ordering).
    compose_idx = _index_of(text, r"(?mi)^#+\s*Step 4.*[Cc]ompose", r"Compose the unified pane")
    assert local_idx < linear_idx, "local-axes (recoil_dashboard.py) must come before the Linear read"
    assert linear_idx < compose_idx, "Linear read must come before the compose/unified-render step"
    assert re.search(r"(?mi)^#+\s*POST-MERGE OPERATOR STEPS", text), "missing ## POST-MERGE OPERATOR STEPS section"


def _is_command_shaped(text: str, needle: str) -> bool:
    """The needle appears inside a fenced code block OR as a backticked command,
    not merely as a word in a paragraph."""
    # fenced block: a ``` ... needle ... ``` run
    fenced = re.findall(r"```.*?```", text, re.DOTALL)
    for block in fenced:
        if needle in block:
            return True
    # inline backticked: `... needle ...`
    for m in re.findall(r"`[^`]*`", text):
        if needle in m:
            return True
    return False


def test_commands_are_command_shaped(text):
    assert _is_command_shaped(text, "recoil_dashboard.py"), "recoil_dashboard.py run must be command-shaped"
    assert _is_command_shaped(text, "gh pr list"), "the merged-PR gh query must be command-shaped"
    assert _is_command_shaped(text, "ln -sfn"), "the symlink must be command-shaped"
    assert _is_command_shaped(text, "deploy_dispatch_chassis.sh"), "the deploy command must be command-shaped"
    assert _is_command_shaped(text, "--check"), "the --check drift command must be command-shaped"


def test_merged_pr_query_repo_pinned_concrete_slug(text):
    # the merged-PR gh query carries an explicit repo context: inline -R slug OR a preceding cd
    merged_pinned = re.search(
        r"gh pr list\s+-R\s+joeturnerlin/recoil[^\n`]*--state\s+merged",
        text,
    )
    cd_form = re.search(
        r"cd /Users/joeturnerlin/CLAUDE_PROJECTS[\s\S]*?gh pr list[^\n`]*--state\s+merged",
        text,
    )
    assert merged_pinned or cd_form, "merged-PR gh query must be repo-pinned (-R joeturnerlin/recoil) or cd-pinned"
    assert "<owner/repo>" not in text, "must not leave the literal <owner/repo> placeholder"


def test_linear_read_criteria_present(text):
    assert re.search(r"Recoil team", text, re.IGNORECASE)
    assert re.search(r"all projects|no .*project.*filter|every project", text, re.IGNORECASE)
    assert re.search(r"24[ -]?h|24 hours|past day", text, re.IGNORECASE)
    assert re.search(r"priorit", text, re.IGNORECASE)


def test_stale_both_limbs_present(text):
    assert re.search(r"no (live|matching|non-terminal).*run", text, re.IGNORECASE), "STALE no-live-run limb"
    assert re.search(r"gh pr list[^\n`]*--state\s+merged", text), "STALE merged-PR limb"


def test_degraded_paths_present(text):
    assert re.search(r"SOURCE UNREACHABLE", text), "must surface local SOURCE UNREACHABLE banner"
    assert re.search(r"LINEAR (SOURCE )?UNREACHABLE|Linear.*(unreachable|unavailable|absent)", text, re.IGNORECASE)


def test_no_linear_write_tools(text):
    tokens = set(re.findall(r"mcp__linear__[a-z_]+", text))
    disallowed = {t for t in tokens if t not in {"mcp__linear__list_issues", "mcp__linear__get_issue"}}
    assert not disallowed, f"only Linear READ tools allowed; found write tools: {disallowed}"
    assert not re.search(r"linear[_-]?(token|api[_-]?url|api[_-]?key)|api\.linear\.app", text, re.IGNORECASE)
    assert not re.search(
        r"(update|create|mutate|move|transition|write)\s+(the\s+)?linear\s+(issue|status|state|ticket)",
        text, re.IGNORECASE,
    ), "no write-intent imperative over a Linear issue/status"
