use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;

/// One extracted rule as a subject–predicate–object triple, plus extraction
/// metadata (confidence, source location, parser layer, annotations,
/// provenance).  This is the parser's output shape and the store's input
/// shape — `store::upsert_file` persists a `Vec<Triple>` per source file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Triple {
    /// Rule subject — usually the tool or domain the rule is about (e.g. `git`).
    #[serde(rename = "s")]
    pub subject: String,
    /// Rule predicate — the normalised modality (`never`, `always`,
    /// `requires`, `prefers`, …).  Drives severity classification.
    #[serde(rename = "p")]
    pub predicate: String,
    /// Rule object — what the rule requires or forbids, with trailing
    /// annotations (`(expires …)`, `(noenrich)`) already stripped.
    #[serde(rename = "o")]
    pub object: String,
    /// Extraction confidence inherited from the source file's type
    /// (project CLAUDE.md ranks above global memory files).
    pub confidence: f64,
    /// Source-type namespace, e.g. `memory.claude_md_project`.
    pub domain: String,
    /// Path of the instruction file the rule came from.  May be empty at
    /// extraction time; callers fill it in before persisting.
    pub source_file: String,
    /// First line of the source list item, 1-based.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub line_start: Option<i64>,
    /// Last line of the source list item, 1-based.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub line_end: Option<i64>,
    /// Which of the seven `match_imperative` layers fired.  Lets `arai audit` /
    /// `arai why` surface "fired from CLAUDE.md:42 (layer-1 imperative)" so a
    /// user can trace *why* a rule exists without spelunking the parser.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub layer: Option<u8>,
    /// Optional expiry date (ISO `YYYY-MM-DD`).  Extracted from a trailing
    /// `(expires YYYY-MM-DD)` or `(until YYYY-MM-DD)` annotation in the rule
    /// text.  Expired rules are filtered out by `load_guardrails`, so a
    /// rule written "never skip tests (expires 2026-06-01)" self-prunes
    /// after that date without any manual cleanup.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub expires_at: Option<String>,
    /// Per-rule opt-out from LLM/API enrichment.  Extracted from a trailing
    /// `(noenrich)` annotation in the rule text.  When set, `enrich_via_llm`
    /// and `enrich_via_api` exclude the rule from the prompt — useful for
    /// rules whose object mentions an internal codename the user does not
    /// want to send to a third-party endpoint.
    #[serde(skip_serializing_if = "is_false", default)]
    pub noenrich: bool,
    /// Provenance: the tier of the upstream block this rule came from.
    /// `None` for purely local rules, which are treated as `Tier::Peer`.
    /// Set once at extraction time; never re-derived downstream.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub tier: Option<crate::extends::Tier>,
    /// Provenance: the URL of the upstream block this rule came from.
    /// `None` for purely local rules.
    /// Set once at extraction time; never re-derived downstream.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub source_label: Option<String>,
}

fn is_false(b: &bool) -> bool {
    !b
}

/// Known tool names for subject extraction, "use X" two-signal gate, and content sniffing.
pub const KNOWN_TOOLS: &[&str] = &[
    "alembic",
    "cargo",
    "npm",
    "yarn",
    "pnpm",
    "pip",
    "uv",
    "poetry",
    "docker",
    "git",
    "gh",
    "pytest",
    "jest",
    "vitest",
    "mocha",
    "rspec",
    "go",
    "rustc",
    "gcc",
    "make",
    "cmake",
    "webpack",
    "vite",
    "eslint",
    "prettier",
    "black",
    "ruff",
    "mypy",
    "pyright",
    "tsc",
    "terraform",
    "ansible",
    "kubectl",
    "helm",
    "gradle",
    "maven",
    "sbt",
    "mix",
    "bundle",
    "composer",
    "apt",
    "brew",
    "dnf",
    "yum",
    "pacman",
    "snap",
    "flatpak",
    "nix",
    "tmux",
    "sed",
    "awk",
    "grep",
    "curl",
    "wget",
    "ruby",
    "python",
    "node",
    "deno",
    "bun",
    "java",
    "dotnet",
    "swift",
    "clang",
    "bazel",
    "nx",
    "turbo",
    "pnpm",
    "pipenv",
    "conda",
];

/// Extract guardrail rules from markdown content.
///
/// `tier` and `source_label` are optional provenance fields from the
/// tier-provenance slice.  Pass `None` for both when extracting purely local
/// rules — absent provenance is treated as `Tier::Peer` throughout the
/// pipeline (backward-compat invariant AC1).
pub fn extract_rules(content: &str, source_type: &str, base_confidence: f64) -> Vec<Triple> {
    extract_rules_with_provenance(content, source_type, base_confidence, None, None)
}

/// Like [`extract_rules`] but stamps every extracted triple with the given
/// upstream-block provenance.  Called from the `arai:extends` resolve path
/// when inlining an admitted upstream block.
pub fn extract_rules_with_provenance(
    content: &str,
    source_type: &str,
    base_confidence: f64,
    tier: Option<crate::extends::Tier>,
    source_label: Option<String>,
) -> Vec<Triple> {
    let (_frontmatter, body) = crate::discovery::parse_frontmatter(content);
    let items = extract_list_items(&body);
    let mut triples = Vec::new();

    for item in items {
        if let Some((subject, predicate, object, layer)) =
            match_imperative(&item.text, &item.section_context)
        {
            let (object, expires_at, noenrich) = extract_annotations(&object);
            triples.push(Triple {
                subject,
                predicate,
                object,
                confidence: base_confidence,
                domain: format!("memory.{source_type}"),
                source_file: String::new(), // filled by caller
                line_start: Some(item.line_start as i64),
                line_end: Some(item.line_end as i64),
                layer: Some(layer),
                expires_at,
                noenrich,
                tier: tier.clone(),
                source_label: source_label.clone(),
            });
        }
    }

    triples
}

/// Extract rules from a resolved content string (one that may contain
/// `<!-- arai:extends-block ... -->` provenance markers emitted by
/// [`crate::extends::resolve`]).
///
/// Each `<!-- arai:extends-block url="..." tier="..." -->` followed by
/// `<!-- end arai:extends -->` segment is extracted with per-block provenance
/// (tier + source_label) stamped on each rule.  The local tail (everything
/// after the last `<!-- end arai:extends -->`, or the whole string when there
/// are no markers) is extracted with no provenance (Peer/no-label).
///
/// This is the provenance-aware variant of [`extract_rules`].  Callers that
/// use the flat `resolve()` output should call this function so rules from
/// upstream blocks are correctly stamped.
pub fn extract_rules_from_resolved(
    content: &str,
    source_type: &str,
    base_confidence: f64,
) -> Vec<Triple> {
    use crate::extends::Tier;

    // Split the resolved content into blocks at the extends-block markers.
    // We parse the content line by line, tracking whether we are inside an
    // upstream block and what its provenance is.
    let mut triples = Vec::new();
    let mut current_block_lines: Vec<&str> = Vec::new();
    let mut current_tier: Option<Tier> = None;
    let mut current_source_label: Option<String> = None;
    let mut in_upstream_block = false;

    for line in content.lines() {
        let trimmed = line.trim();

        // Detect block-start marker:
        // <!-- arai:extends-block url="<url>" tier="<tier>" -->
        if let Some(provenance) = parse_extends_block_marker(trimmed) {
            // Flush any accumulated lines (should be empty between blocks,
            // but flush defensively to avoid losing content).
            if !current_block_lines.is_empty() {
                let block = current_block_lines.join("\n");
                let mut block_triples = extract_rules_with_provenance(
                    &block,
                    source_type,
                    base_confidence,
                    current_tier.take(),
                    current_source_label.take(),
                );
                triples.append(&mut block_triples);
                current_block_lines.clear();
            }
            current_tier = provenance.0;
            current_source_label = provenance.1;
            in_upstream_block = true;
            continue;
        }

        // Detect block-end marker: <!-- end arai:extends -->
        if trimmed == "<!-- end arai:extends -->" {
            if in_upstream_block {
                let block = current_block_lines.join("\n");
                let mut block_triples = extract_rules_with_provenance(
                    &block,
                    source_type,
                    base_confidence,
                    current_tier.take(),
                    current_source_label.take(),
                );
                triples.append(&mut block_triples);
                current_block_lines.clear();
                in_upstream_block = false;
            }
            continue;
        }

        current_block_lines.push(line);
    }

    // Flush remaining content (the local tail, no provenance).
    if !current_block_lines.is_empty() {
        let block = current_block_lines.join("\n");
        let mut block_triples =
            extract_rules_with_provenance(&block, source_type, base_confidence, None, None);
        triples.append(&mut block_triples);
    }

    triples
}

/// Parse an `<!-- arai:extends-block url="..." tier="..." -->` marker line.
/// Returns `Some((tier, source_label))` on a successful parse, `None`
/// otherwise.  Both fields are optional — an unknown tier falls back to
/// Peer (never an error in the extraction path).
fn parse_extends_block_marker(
    line: &str,
) -> Option<(Option<crate::extends::Tier>, Option<String>)> {
    use crate::extends::Tier;

    // Strip HTML comment delimiters.
    let inner = line
        .strip_prefix("<!--")
        .and_then(|s| s.strip_suffix("-->"))?
        .trim();

    let rest = inner.strip_prefix("arai:extends-block")?.trim();

    // Parse key="value" pairs.  We only need url= and tier=.
    let url = extract_kv_attr(rest, "url")?;
    let tier_str = extract_kv_attr(rest, "tier");

    let tier = match tier_str.as_deref() {
        Some("strict") => Some(Tier::Strict),
        Some("advisory") => Some(Tier::Advisory),
        Some("override") => Some(Tier::Override),
        _ => None, // peer or absent → Peer default
    };

    Some((tier, Some(url)))
}

/// Extract the value of `key="value"` from an attribute string.  Returns
/// `None` when the key is absent.  Only handles double-quoted values.
fn extract_kv_attr(s: &str, key: &str) -> Option<String> {
    let needle = format!("{key}=\"");
    let start = s.find(&needle)? + needle.len();
    let end = s[start..].find('"')? + start;
    Some(s[start..end].to_string())
}

/// Strip a trailing `(expires YYYY-MM-DD)` or `(until YYYY-MM-DD)` annotation
/// from the rule object and return the cleaned object plus the parsed date.
/// Accepts case-insensitive `expires` / `expire` / `until`.  If nothing is
/// found, the object is returned unchanged with `None`.
pub(crate) fn extract_expiry(object: &str) -> (String, Option<String>) {
    static EXPIRY_RE: OnceLock<Regex> = OnceLock::new();
    let re = EXPIRY_RE.get_or_init(|| {
        Regex::new(r"(?i)\s*\((?:expires?|until)\s+(\d{4}-\d{2}-\d{2})\)\s*$").expect("valid regex")
    });
    if let Some(caps) = re.captures(object) {
        let date = caps.get(1).map(|m| m.as_str().to_string());
        let cleaned = re.replace(object, "").trim().to_string();
        (cleaned, date)
    } else {
        (object.to_string(), None)
    }
}

/// Strip a trailing `(noenrich)` annotation from the rule object and return
/// the cleaned object plus a flag indicating whether the marker was present.
/// Case-insensitive.  Used to opt a single rule out of LLM/API enrichment
/// without disabling the feature globally.
pub(crate) fn extract_noenrich(object: &str) -> (String, bool) {
    static NOENRICH_RE: OnceLock<Regex> = OnceLock::new();
    let re =
        NOENRICH_RE.get_or_init(|| Regex::new(r"(?i)\s*\(noenrich\)\s*$").expect("valid regex"));
    if re.is_match(object) {
        let cleaned = re.replace(object, "").trim().to_string();
        (cleaned, true)
    } else {
        (object.to_string(), false)
    }
}

/// Apply both annotation extractors regardless of the order they appear in.
/// Annotations always sit at the end of the object, so a single annotation
/// hides anything to its left from the other regex; we loop until neither
/// strips anything to handle `(expires ...) (noenrich)` and the reverse
/// uniformly.
pub(crate) fn extract_annotations(object: &str) -> (String, Option<String>, bool) {
    let mut current = object.to_string();
    let mut expires: Option<String> = None;
    let mut noenrich = false;

    loop {
        let (after_expiry, found_expiry) = extract_expiry(&current);
        if found_expiry.is_some() {
            expires = found_expiry;
        }
        let (after_noenrich, found_noenrich) = extract_noenrich(&after_expiry);
        if found_noenrich {
            noenrich = true;
        }
        if after_noenrich == current {
            return (after_noenrich, expires, noenrich);
        }
        current = after_noenrich;
    }
}

#[derive(Debug)]
struct ListItem {
    text: String,
    line_start: usize,
    line_end: usize,
    section_context: Option<String>,
}

fn extract_list_items(body: &str) -> Vec<ListItem> {
    let mut items = Vec::new();
    let mut current_item: Option<(String, usize)> = None;
    let mut current_section: Option<String> = None;
    let mut in_code_block = false;
    let mut in_table = false;
    // Section-scoped skip: an `<!-- arai:skip -->` comment causes every list
    // item from that point until we leave its section to be ignored.  "Leave
    // its section" = the next heading at the SAME OR SHALLOWER depth as the
    // marker's section.  A sub-heading (deeper) does NOT clear the skip,
    // because it's still inside the marked section conceptually.
    //
    // `current_section_depth` is the H-level of the heading we're currently
    // under (0 = before any heading).  `skip_until_depth` is the depth at
    // which the active marker sits; clear when a new heading arrives at
    // that depth or shallower.  `Some(0)` means "marker before any heading"
    // — clear on the first heading we see (any depth).
    let mut current_section_depth: usize = 0;
    let mut skip_until_depth: Option<usize> = None;

    for (line_idx, line) in body.lines().enumerate() {
        let line_num = line_idx + 1;
        let trimmed = line.trim();

        // Track code blocks
        if trimmed.starts_with("```") {
            in_code_block = !in_code_block;
            continue;
        }
        if in_code_block {
            continue;
        }

        // Track table rows
        if trimmed.starts_with('|') {
            in_table = true;
            continue;
        } else if in_table && !trimmed.is_empty() {
            in_table = false;
        }

        // Track section headers.  May or may not clear an active skip
        // marker depending on heading depth (see comment on
        // `skip_until_depth` above).
        if trimmed.starts_with('#') {
            let new_depth = trimmed.chars().take_while(|c| *c == '#').count();
            if let Some(d) = skip_until_depth {
                // d == 0 means the marker was outside any heading → first
                // heading of any depth clears it.  d > 0 means the marker
                // was inside an H<d> section → only equal-or-shallower
                // headings clear (sub-headings stay inside the skip).
                if d == 0 || new_depth <= d {
                    skip_until_depth = None;
                }
            }
            current_section_depth = new_depth;

            let header = trimmed.trim_start_matches('#').trim().to_string();
            // Strip leading numbered prefix like "4. " and trailing formatting like " (§3)"
            let header = strip_list_prefix(&header);
            let header = header
                .split('(')
                .next()
                .unwrap_or(&header)
                .trim()
                .to_string();
            current_section = if header.is_empty() {
                None
            } else {
                Some(header)
            };
            // Flush any pending item — emit only if we're not inside an
            // active skip after this heading-level adjustment.
            if let Some((text, start)) = current_item.take() {
                if skip_until_depth.is_none() {
                    items.push(ListItem {
                        text,
                        line_start: start,
                        line_end: line_num - 1,
                        section_context: current_section.clone(),
                    });
                }
            }
            continue;
        }

        // Skip-section opt-out: case-insensitive `<!-- arai:skip -->`, with
        // tolerant whitespace inside the comment.  Scopes to the current
        // heading depth; deeper headings stay skipped, equal/shallower
        // ones clear it.  Drop any pending item — it pre-dates the marker
        // on the same line, but the user's intent is "this section is
        // examples, ignore it".
        if line_contains_skip_marker(trimmed) {
            skip_until_depth = Some(current_section_depth);
            current_item = None;
            continue;
        }

        // Check for list item start
        let is_list_item = is_list_start(trimmed);

        if is_list_item {
            // Flush previous item (if any) — but skip emission while inside
            // a skip-marked section.
            if let Some((text, start)) = current_item.take() {
                if skip_until_depth.is_none() {
                    items.push(ListItem {
                        text,
                        line_start: start,
                        line_end: line_num - 1,
                        section_context: current_section.clone(),
                    });
                }
            }
            if skip_until_depth.is_some() {
                continue;
            }
            let item_text = strip_list_prefix(trimmed);
            current_item = Some((item_text, line_num));
        } else if !trimmed.is_empty() && current_item.is_some() {
            // Continuation line — check if indented
            if line.starts_with("  ") || line.starts_with('\t') {
                if let Some((ref mut text, _)) = current_item {
                    text.push(' ');
                    text.push_str(trimmed);
                }
            } else {
                // Not indented, flush
                if let Some((text, start)) = current_item.take() {
                    items.push(ListItem {
                        text,
                        line_start: start,
                        line_end: line_num - 1,
                        section_context: current_section.clone(),
                    });
                }
            }
        } else if trimmed.is_empty() {
            // Blank line flushes
            if let Some((text, start)) = current_item.take() {
                items.push(ListItem {
                    text,
                    line_start: start,
                    line_end: line_num - 1,
                    section_context: current_section.clone(),
                });
            }
        }
    }

    // Flush last item — unless we're inside an active skip.
    if let Some((text, start)) = current_item.take() {
        if skip_until_depth.is_none() {
            let line_end = body.lines().count();
            items.push(ListItem {
                text,
                line_start: start,
                line_end,
                section_context: current_section,
            });
        }
    }

    items
}

/// Detect a `<!-- arai:skip -->` marker anywhere in a line.  Case-insensitive
/// and whitespace-tolerant: `<!--arai:skip-->`, `<!-- ARAI:SKIP -->`, etc.
fn line_contains_skip_marker(line: &str) -> bool {
    static MARKER: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
    let re = MARKER
        .get_or_init(|| Regex::new(r"(?i)<!--\s*arai\s*:\s*skip\s*-->").expect("valid regex"));
    re.is_match(line)
}

fn is_list_start(trimmed: &str) -> bool {
    if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
        return true;
    }
    // Numbered list: "1. ", "2. ", etc.
    static NUMBERED_LIST_RE: OnceLock<Regex> = OnceLock::new();
    let re = NUMBERED_LIST_RE.get_or_init(|| Regex::new(r"^\d+\.\s").expect("valid regex"));
    re.is_match(trimmed)
}

fn strip_list_prefix(trimmed: &str) -> String {
    if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
        return trimmed[2..].to_string();
    }
    static NUMBERED_PREFIX_RE: OnceLock<Regex> = OnceLock::new();
    let re = NUMBERED_PREFIX_RE.get_or_init(|| Regex::new(r"^\d+\.\s+").expect("valid regex"));
    re.replace(trimmed, "").to_string()
}

/// Strip markdown formatting: bold, italic, backticks, links.
fn strip_markdown(text: &str) -> String {
    static BOLD_RE: OnceLock<Regex> = OnceLock::new();
    static CODE_RE: OnceLock<Regex> = OnceLock::new();
    static LINK_RE: OnceLock<Regex> = OnceLock::new();
    let re_bold =
        BOLD_RE.get_or_init(|| Regex::new(r"\*\*(.+?)\*\*|__(.+?)__").expect("valid regex"));
    let re_code = CODE_RE.get_or_init(|| Regex::new(r"`([^`]+)`").expect("valid regex"));
    let re_link = LINK_RE.get_or_init(|| Regex::new(r"\[([^]]+)]\([^)]+\)").expect("valid regex"));
    // Strip bold **text** and __text__
    let s = re_bold.replace_all(text, "$1$2").to_string();
    // Strip inline code `text`
    let s = re_code.replace_all(&s, "$1").to_string();
    // Strip markdown links [text](url) → text
    let s = re_link.replace_all(&s, "$1").to_string();
    // Strip em-dash separators for cleaner matching
    s.replace(" — ", " - ")
}

/// Match a list item against imperative patterns.
/// Returns (subject, predicate, object, layer) or None.  `layer` is the 1-based
/// index of the pattern family that fired, surfaced in the audit log + hook
/// output so a reviewer can trace the derivation without reading this file.
fn match_imperative(
    text: &str,
    section_context: &Option<String>,
) -> Option<(String, String, String, u8)> {
    // Skip items that look like pure code references
    if is_code_reference(text) {
        return None;
    }

    // Bold-label discriminator — `**No build process** - this is a zero-
    // build extension.` and `**Consider constraints:** What are the goals?`
    // are feature-absence DESCRIPTIONS or section headings, not rules.
    //
    // Heuristic: a *multi-word* bold prefix is a label; a *single-word*
    // bold prefix is emphasis on a leader word.  This handles three
    // shapes the corpus actually contains:
    //   `**No build process** - this is a zero-build extension.` (label)
    //   `**Consider constraints:** What are the goals?`           (label)
    //   `**Always** run tests before push`                         (emphasis — keep)
    // Catches the colon-inside-bold case the previous trailing-separator
    // regex missed.
    static BOLD_LABEL_RE: OnceLock<Regex> = OnceLock::new();
    let bold_label_re =
        BOLD_LABEL_RE.get_or_init(|| Regex::new(r"^\*\*[^*]+\s[^*]*\*\*").expect("valid regex"));
    let is_bold_label = bold_label_re.is_match(text.trim_start());

    // Strip markdown formatting before matching
    let cleaned = strip_markdown(text);
    let lower = cleaned.to_lowercase();

    // Layer 1: Start-of-sentence imperative patterns (highest confidence).
    //
    // Order matters within the list — more specific patterns precede
    // generic ones so e.g. `^should not` matches before `^should`.  The
    // predicate column maps to severity via `intent::Severity::from_predicate`:
    //   - never / forbids / must_not       → Block
    //   - always / requires / enforces     → Warn
    //   - prefers / learned_from           → Inform
    //
    // Severity rationale for the v0.2.11 additions:
    //   should not / shouldn't / cannot    → must_not (Block) — explicit
    //                                        prohibition by the writer
    //   refuse to                          → forbids  (Block) — same
    //   should                             → prefers  (Inform) — softer
    //                                        than must/always; honour that
    //   make sure / be sure                → enforces (Warn)  — synonym of
    //                                        ensure
    //   consider / recommend               → prefers  (Inform) — soft
    //                                        preference
    //   enforce                            → enforces (Warn)  — verb form
    //                                        of the predicate
    static START_PATTERNS: OnceLock<Vec<(Regex, &'static str, bool)>> = OnceLock::new();
    let start_patterns = START_PATTERNS.get_or_init(|| {
        // (regex, predicate, is_consider) — `is_consider` flags the
        // `^consider` rule so the bold-label gate below can short-circuit
        // without a substring search on the pattern string.
        let raw: &[(&str, &str)] = &[
            (r"(?i)^never\s+(.+)", "never"),
            (r"(?i)^always\s+(.+)", "always"),
            (r"(?i)^don'?t\s+(.+)", "forbids"),
            (r"(?i)^do\s+not\s+(.+)", "forbids"),
            (r"(?i)^must\s+not\s+(.+)", "must_not"),
            (r"(?i)^must\s+(.+)", "requires"),
            (r"(?i)^should\s+not\s+(.+)", "must_not"),
            (r"(?i)^shouldn'?t\s+(.+)", "must_not"),
            (r"(?i)^should\s+(.+)", "prefers"),
            (r"(?i)^cannot\s+(.+)", "must_not"),
            (r"(?i)^refuse\s+to\s+(.+)", "forbids"),
            (r"(?i)^avoid\s+(.+)", "forbids"),
            (r"(?i)^ensure\s+(.+)", "enforces"),
            (r"(?i)^enforce\s+(.+)", "enforces"),
            (r"(?i)^make\s+sure\s+(?:that\s+)?(.+)", "enforces"),
            (r"(?i)^be\s+sure\s+(?:to\s+)?(.+)", "enforces"),
            (r"(?i)^requires?\s+(.+)", "requires"),
            (r"(?i)^prefer\s+(.+?)(?:\s+over\s+.+)?$", "prefers"),
            (r"(?i)^consider\s+(.+)", "prefers"),
            (r"(?i)^recommend(?:ed)?\s+(.+)", "prefers"),
            (r"(?i)^stop\s+(.+)", "forbids"),
            (r"(?i)^skip\s+(.+)", "forbids"),
            (r"(?i)^only\s+(.+)", "requires"),
        ];
        raw.iter()
            .map(|(p, pred)| {
                (
                    Regex::new(p).expect("valid regex"),
                    *pred,
                    p.contains("consider"),
                )
            })
            .collect()
    });

    for (re, predicate, is_consider) in start_patterns {
        if let Some(caps) = re.captures(&cleaned) {
            // Skip `consider` when the bullet is a labelled description
            // (e.g. `**Consider constraints:** What are the goals?` is a
            // section heading inside a list, not a soft preference rule).
            if is_bold_label && *is_consider {
                continue;
            }
            if let Some(obj_match) = caps.get(1) {
                let object = obj_match.as_str().trim().to_string();
                let subject = extract_subject(&lower, section_context);
                return Some((subject, predicate.to_string(), clean_object(&object), 1));
            }
        }
    }

    // Layer 1b: bare `^no <noun>` prohibitions — "No AI attribution in commit
    // messages" / "No emojis in commit messages".  Gated against bold-label
    // form (`**No build process** - this is a zero-build extension.`) which
    // is feature-absence description, not a rule.
    if !is_bold_label {
        static NO_RE: OnceLock<Regex> = OnceLock::new();
        let no_re = NO_RE.get_or_init(|| Regex::new(r"(?i)^no\s+(.+)").expect("valid regex"));
        if let Some(caps) = no_re.captures(&cleaned) {
            if let Some(obj_match) = caps.get(1) {
                let object = obj_match.as_str().trim().to_string();
                let subject = extract_subject(&lower, section_context);
                return Some((subject, "must_not".to_string(), clean_object(&object), 1));
            }
        }
    }

    // Layer 2: "X is forbidden/required" patterns
    static PASSIVE_PATTERNS: OnceLock<Vec<(Regex, &'static str)>> = OnceLock::new();
    let passive_patterns = PASSIVE_PATTERNS.get_or_init(|| {
        let raw: &[(&str, &str)] = &[
            (r"(?i)(.+?)\s+is\s+forbidden", "forbids"),
            (r"(?i)(.+?)\s+is\s+required", "requires"),
            (r"(?i)(.+?)\s+is\s+not\s+allowed", "forbids"),
        ];
        raw.iter()
            .map(|(p, pred)| (Regex::new(p).expect("valid regex"), *pred))
            .collect()
    });

    for (re, predicate) in passive_patterns {
        if let Some(caps) = re.captures(&cleaned) {
            if let Some(obj_match) = caps.get(1) {
                let object = obj_match.as_str().trim().to_string();
                let subject = extract_subject(&lower, section_context);
                return Some((subject, predicate.to_string(), clean_object(&object), 2));
            }
        }
    }

    // Layer 3: Colon-separated rules — "Label: imperative instruction"
    // e.g. "Simplicity First: Make every change as simple as possible"
    if let Some(colon_pos) = cleaned.find(':') {
        let after_colon = cleaned[colon_pos + 1..].trim();
        if !after_colon.is_empty() {
            let after_lower = after_colon.to_lowercase();
            // Check if the part after colon starts with an imperative
            let colon_imperatives = [
                "never",
                "always",
                "don't",
                "do not",
                "must",
                "avoid",
                "ensure",
                "make",
                "find",
                "keep",
                "use",
                "run",
                "check",
                "add",
                "write",
                "update",
                "mark",
                "set",
                "get",
                "put",
                "send",
                "stop",
                "start",
                "enter",
                "exit",
                "verify",
                "test",
                "review",
                "demonstrate",
                "offload",
                "throw",
                "challenge",
                "pause",
                "ask",
                "diff",
                "ruthlessly",
                "zero",
                "no ",
                "only",
            ];
            if colon_imperatives
                .iter()
                .any(|imp| after_lower.starts_with(imp))
            {
                let label = cleaned[..colon_pos].trim();
                let subject = if !label.is_empty() {
                    // Use the label as subject if it's meaningful
                    let label_lower = label.to_lowercase();
                    if let Some(tool) = KNOWN_TOOLS.iter().find(|t| contains_word(&label_lower, t))
                    {
                        capitalize(tool)
                    } else {
                        label.to_string()
                    }
                } else {
                    extract_subject(&after_lower, section_context)
                };
                return Some((
                    subject,
                    "requires".to_string(),
                    clean_object(after_colon),
                    3,
                ));
            }
        }
    }

    // Layer 7: Conditional imperatives — evaluated *before* Layer 4 because
    // a conditional shape is more specific than a generic mid-sentence
    // imperative ("When deploying to production, never skip smoke tests"
    // wants the conditional reading, not just the comma-imperative).
    // Layer 4 still fires for non-conditional comma-imperatives that don't
    // start with a trigger word.
    //
    // Shape: ^(Before|After|When|Whenever|If|For)\s+<condition>(,|:|→|—)\s+<verb>\s+<rest>
    // The verb must be in the union of recognised imperatives so we don't
    // accidentally extract a rule from "When X, see Y" prose continuations.
    static CONDITIONAL_RE: OnceLock<Regex> = OnceLock::new();
    let conditional_re = CONDITIONAL_RE.get_or_init(|| {
        Regex::new(
            r"(?ix)
            ^\s*(?:before|after|when|whenever|if|for)\s+   # trigger word
            (.+?)                                          # condition phrase
            \s*[,:\u{2192}\u{2014}]\s+                     # `,`, `:`, `→`, or `—`
            (\w+)                                          # imperative verb
            \s+(.+)                                        # rule body
            ",
        )
        .expect("valid regex")
    });
    if let Some(caps) = conditional_re.captures(&cleaned) {
        let verb = caps.get(2)?.as_str().to_lowercase();
        let body = caps.get(3)?.as_str().trim();
        let condition = caps.get(1)?.as_str().trim().to_lowercase();
        let allowed_verbs = [
            // From Layer 6
            "enter",
            "exit",
            "run",
            "check",
            "test",
            "write",
            "read",
            "review",
            "update",
            "delete",
            "add",
            "remove",
            "set",
            "get",
            "keep",
            "find",
            "fix",
            "verify",
            "demonstrate",
            "prove",
            "show",
            "deploy",
            "build",
            "install",
            "configure",
            "enable",
            "disable",
            "start",
            "stop",
            "offload",
            "throw",
            "challenge",
            "pause",
            "diff",
            "ask",
            "point",
            "create",
            "implement",
            "document",
            "define",
            "store",
            "state",
            "share",
            "explain",
            "describe",
            "connect",
            "apply",
            // Layer 1 leaders
            "never",
            "always",
            "don't",
            "dont",
            "do",
            "must",
            "should",
            "shouldn't",
            "shouldnt",
            "avoid",
            "ensure",
            "use",
            "prefer",
            "consider",
            "recommend",
            "make",
            "be",
            "no",
            "only",
        ];
        if allowed_verbs.contains(&verb.as_str()) {
            // Decide predicate based on the inner verb.  Bias toward
            // `requires` because most conditionals are positive imperatives;
            // map clear prohibitives explicitly.
            let predicate = match verb.as_str() {
                "never" | "don't" | "dont" | "avoid" | "stop" => "never",
                "shouldn't" | "shouldnt" => "must_not",
                "always" | "must" | "ensure" => "always",
                "should" | "prefer" | "consider" | "recommend" => "prefers",
                _ => "requires",
            };
            let object = format!("{verb} {body}");
            // Subject: prefer a known tool name in the condition phrase, else
            // fall through to the standard subject-extraction logic.
            let subject = if let Some(tool) = find_earliest_tool(&condition) {
                capitalize(tool)
            } else {
                extract_subject(&lower, section_context)
            };
            return Some((subject, predicate.to_string(), clean_object(&object), 7));
        }
        // Conditional shape but verb not in whitelist — fall through to
        // Layer 4 / Layer 6 / etc.  Don't return None here; one of the
        // later layers might still extract from prose like "When uncertain,
        // see ..." (in which case we explicitly want them to skip).
    }

    // Layer 4: Mid-sentence imperatives — "If X, STOP/never/don't Y" without
    // the trigger-word prefix Layer 7 expects (covers ", never X" and
    // "-- don't X" cases that don't lead with Before/After/When/If/For).
    static MID_PATTERNS: OnceLock<Vec<(Regex, &'static str)>> = OnceLock::new();
    let mid_patterns = MID_PATTERNS.get_or_init(|| {
        let raw: &[(&str, &str)] = &[
            (r"(?i),\s*never\s+(.+)", "never"),
            (r"(?i),\s*always\s+(.+)", "always"),
            (r"(?i),\s*don'?t\s+(.+)", "forbids"),
            (r"(?i),\s*do\s+not\s+(.+)", "forbids"),
            (r"(?i),\s*stop\s+(.+)", "forbids"),
            (r"(?i)--\s*don'?t\s+(.+)", "forbids"),
            (r"(?i)—\s*don'?t\s+(.+)", "forbids"),
        ];
        raw.iter()
            .map(|(p, pred)| (Regex::new(p).expect("valid regex"), *pred))
            .collect()
    });

    for (re, predicate) in mid_patterns {
        if let Some(caps) = re.captures(&cleaned) {
            if let Some(obj_match) = caps.get(1) {
                let object = obj_match.as_str().trim().to_string();
                let subject = extract_subject(&lower, section_context);
                return Some((subject, predicate.to_string(), clean_object(&object), 4));
            }
        }
    }

    // Layer 5: "use X" with three-signal gate.  The first two (known tool
    // present, co-imperative word in the line) keep the original
    // conservative behaviour.  The third (section context names a
    // conventions-shaped header) handles the very common "Use X" pattern
    // inside a `## Conventions` / `## Style` / `## Best Practices` section
    // where the writer means the section's framing, not a generic verb
    // call-out.
    static USE_RE: OnceLock<Regex> = OnceLock::new();
    let use_re = USE_RE.get_or_init(|| Regex::new(r"(?i)^use\s+(.+)").expect("valid regex"));
    if let Some(caps) = use_re.captures(&cleaned) {
        let object = caps.get(1)?.as_str().trim();
        let has_known_tool = contains_known_tool(&lower);
        let has_co_imperative =
            lower.contains("always") || lower.contains("must") || lower.contains("never");
        let in_rules_section = is_rules_section(section_context);

        if has_known_tool || has_co_imperative || in_rules_section {
            let subject = extract_subject(&lower, section_context);
            return Some((subject, "requires".to_string(), clean_object(object), 5));
        }
    }

    // Layer 6: Catch-all — any list item with a verb-like start is a candidate rule
    // These get lower confidence (handled by the caller via source_type)
    let verb_starts = [
        "enter",
        "exit",
        "run",
        "check",
        "test",
        "write",
        "read",
        "review",
        "update",
        "delete",
        "add",
        "remove",
        "set",
        "get",
        "keep",
        "find",
        "fix",
        "verify",
        "demonstrate",
        "prove",
        "show",
        "deploy",
        "build",
        "install",
        "configure",
        "enable",
        "disable",
        "start",
        "stop",
        "offload",
        "throw",
        "challenge",
        "pause",
        "diff",
        "ask",
        "point",
        // v0.2.11 additions — measured against the broadened public corpus
        // (~80 additional rule extractions; see CHANGELOG).
        "create",
        "implement",
        "document",
        "define",
        "store",
        // Common imperatives observed in instruction prose (kraken,
        // arai, public corpora).  Each unambiguous in list-item-leading
        // position; low false-positive risk.  Also referenced by Layer 7's
        // allowed_verbs whitelist.
        "state",
        "share",
        "explain",
        "describe",
        "connect",
        "apply",
    ];

    let first_word = lower.split_whitespace().next().unwrap_or("");
    if verb_starts.contains(&first_word) {
        let subject = extract_subject(&lower, section_context);
        return Some((subject, "requires".to_string(), clean_object(&cleaned), 6));
    }

    None
}

/// Section-context test for the Layer 5 third-signal gate.  Only fires on
/// canonical "rules-shaped" section names so an unrelated `## Use Cases`
/// section doesn't accidentally promote every `use X` bullet.
fn is_rules_section(section_context: &Option<String>) -> bool {
    let Some(section) = section_context else {
        return false;
    };
    let lower = section.to_lowercase();
    [
        "convention",
        "conventions",
        "rule",
        "rules",
        "style",
        "guideline",
        "guidelines",
        "best practice",
        "best practices",
        "coding standard",
        "coding standards",
        "policy",
        "policies",
    ]
    .iter()
    .any(|needle| lower.contains(needle))
}

fn is_code_reference(text: &str) -> bool {
    let trimmed = text.trim();
    // Pure backtick-wrapped path
    if trimmed.starts_with('`') && trimmed.ends_with('`') && !trimmed.contains(' ') {
        return true;
    }
    // file:line reference
    static FILE_LINE_RE: OnceLock<Regex> = OnceLock::new();
    let re = FILE_LINE_RE.get_or_init(|| Regex::new(r"^[\w./\\-]+:\d+$").expect("valid regex"));
    if re.is_match(trimmed) {
        return true;
    }
    false
}

fn extract_subject(lower_text: &str, section_context: &Option<String>) -> String {
    // First: find the earliest known tool name in the text (closest to the verb)
    if let Some(tool) = find_earliest_tool(lower_text) {
        return capitalize(tool);
    }

    // Second: inherit from section header
    if let Some(section) = section_context {
        let section_lower = section.to_lowercase();
        for tool in KNOWN_TOOLS {
            if section_lower.contains(tool) {
                return capitalize(tool);
            }
        }
        // Use section header as subject
        return section.clone();
    }

    // Third: extract first significant noun phrase after the verb
    extract_first_noun(lower_text)
}

fn extract_first_noun(text: &str) -> String {
    let skip_words = [
        "the", "a", "an", "all", "any", "every", "each", "this", "that", "your", "my", "our",
        "their", "its", "for", "to", "in", "on", "with", "from", "of", "and", "or", "not", "no",
        "is", "are", "was", "were", "be", "been", "being",
    ];

    let words: Vec<&str> = text.split_whitespace().collect();

    // Skip the first word (it's the verb/imperative)
    for word in words.iter().skip(1) {
        let clean = word.trim_matches(|c: char| !c.is_alphanumeric());
        if clean.len() >= 3 && !skip_words.contains(&clean) {
            return capitalize(clean);
        }
    }

    "General".to_string()
}

/// Find the earliest known tool name in text (by position).
/// Returns the tool closest to the start — typically the primary subject.
/// Ambiguous tool names that need context to confirm they mean the tool.
/// "go" can mean "go ahead" or the Go language — only match if followed by
/// a subcommand like "test", "build", "run", "fmt", "mod", "get".
const AMBIGUOUS_TOOLS: &[(&str, &[&str])] = &[(
    "go",
    &[
        "test", "build", "run", "fmt", "mod", "get", "install", "vet", "generate",
    ],
)];

fn find_earliest_tool(text: &str) -> Option<&'static str> {
    let words: Vec<&str> = text
        .split(|c: char| !c.is_alphanumeric())
        .filter(|w| !w.is_empty())
        .collect();
    for (i, word) in words.iter().enumerate() {
        for tool in KNOWN_TOOLS {
            if *word == *tool {
                // Check if this is an ambiguous tool
                if let Some((_, subcommands)) = AMBIGUOUS_TOOLS.iter().find(|(t, _)| t == tool) {
                    // Only match if followed by a known subcommand
                    let next_word = words.get(i + 1).unwrap_or(&"");
                    if subcommands.contains(next_word) {
                        return Some(tool);
                    }
                    // Otherwise skip — "go fix" means "go ahead and fix"
                    continue;
                }
                return Some(tool);
            }
        }
    }
    None
}

/// Check if text contains a word as a whole word (not substring).
fn contains_word(text: &str, word: &str) -> bool {
    text.split(|c: char| !c.is_alphanumeric())
        .any(|w| w == word)
}

fn contains_known_tool(lower_text: &str) -> bool {
    KNOWN_TOOLS
        .iter()
        .any(|tool| contains_word(lower_text, tool))
}

fn capitalize(s: &str) -> String {
    let mut chars = s.chars();
    match chars.next() {
        None => String::new(),
        Some(c) => c.to_uppercase().to_string() + chars.as_str(),
    }
}

fn clean_object(s: &str) -> String {
    // Remove trailing punctuation and clean up
    let s = s.trim_end_matches(['.', ',', ';']);
    s.trim().to_string()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_never_pattern() {
        let rules = extract_rules("- Never hand-write migration files", "test", 0.9);
        assert_eq!(rules.len(), 1);
        assert_eq!(rules[0].predicate, "never");
        assert!(rules[0].object.contains("hand-write migration files"));
    }

    #[test]
    fn skip_marker_drops_subsequent_bullets_until_next_heading() {
        let md = "\
## Anti-patterns
<!-- arai:skip -->
- Never use console.log in production
- Always commit secrets to git

## Real Rules
- Never force-push to main
";
        let rules = extract_rules(md, "test", 0.9);
        let objects: Vec<&str> = rules.iter().map(|r| r.object.as_str()).collect();
        assert!(
            objects.iter().any(|o| o.contains("force-push to main")),
            "real rule should still extract: {objects:?}"
        );
        assert!(
            !objects.iter().any(|o| o.contains("console.log")),
            "skipped section should drop console.log: {objects:?}"
        );
        assert!(
            !objects.iter().any(|o| o.contains("commit secrets")),
            "skipped section should drop commit-secrets: {objects:?}"
        );
    }

    #[test]
    fn skip_marker_is_case_and_whitespace_tolerant() {
        let md = "\
## Examples
<!--ARAI:SKIP-->
- Never X

## Rules
<!-- arai : skip -->
- Never Y

## Real
- Never Z
";
        let rules = extract_rules(md, "test", 0.9);
        let objects: Vec<&str> = rules.iter().map(|r| r.object.as_str()).collect();
        assert!(
            objects.iter().any(|o| o.contains("Z")),
            "Z should fire: {objects:?}"
        );
        assert!(
            !objects.iter().any(|o| o.contains("X")),
            "X should be skipped: {objects:?}"
        );
        assert!(
            !objects.iter().any(|o| o.contains("Y")),
            "Y should be skipped: {objects:?}"
        );
    }

    #[test]
    fn skip_marker_resets_at_next_heading() {
        // The marker scopes only to the heading it's under — once we cross
        // a heading boundary, normal extraction resumes.
        let md = "\
## A
<!-- arai:skip -->
- Never one

## B
- Never two
";
        let rules = extract_rules(md, "test", 0.9);
        assert_eq!(rules.len(), 1, "exactly one rule should remain: {rules:?}");
        assert!(rules[0].object.contains("two"));
    }

    #[test]
    fn skip_marker_extends_into_subsections_only_clears_at_same_depth() {
        // Sub-headings (deeper) are still inside the marked H2; only an H2
        // or H1 should clear the skip.  Pre-fix behaviour reset on every
        // heading regardless of depth, leaking the H2 marker as soon as an
        // H3 appeared.
        let md = "\
## Anti-patterns
<!-- arai:skip -->
- Never one

### A subsection of anti-patterns
- Never two

## Real Rules
- Never three
";
        let rules = extract_rules(md, "test", 0.9);
        let objects: Vec<&str> = rules.iter().map(|r| r.object.as_str()).collect();
        assert!(
            objects.iter().any(|o| o.contains("three")),
            "real rule survives: {objects:?}"
        );
        assert!(
            !objects.iter().any(|o| o.contains("one")),
            "H2-anchored skip drops H2 bullets: {objects:?}"
        );
        assert!(
            !objects.iter().any(|o| o.contains("two")),
            "H3 sub-heading must NOT clear the H2 skip: {objects:?}"
        );
    }

    #[test]
    fn skip_marker_at_top_of_document_clears_on_first_heading() {
        // Marker before any heading scopes to "until next heading at any
        // depth".  current_section_depth is 0 there, and the clear rule
        // for `Some(0)` is "any heading clears it".
        let md = "\
<!-- arai:skip -->
- Never preamble

# Real
- Never real
";
        let rules = extract_rules(md, "test", 0.9);
        assert_eq!(
            rules.len(),
            1,
            "only the post-heading rule survives: {rules:?}"
        );
        assert!(rules[0].object.contains("real"));
    }

    #[test]
    fn test_layer_is_tagged() {
        // "Never X" → layer 1 (start-of-sentence imperative)
        let rules = extract_rules("- Never hand-write migration files", "test", 0.9);
        assert_eq!(rules[0].layer, Some(1));

        // "X is forbidden" → layer 2 (passive)
        let rules = extract_rules("- Force-push to main is forbidden", "test", 0.9);
        assert_eq!(rules[0].layer, Some(2));

        // "Label: imperative" → layer 3 (colon-separated)
        let rules = extract_rules("- Simplicity First: Make every change minimal", "test", 0.9);
        assert_eq!(rules[0].layer, Some(3));
    }

    #[test]
    fn test_expiry_is_extracted_and_stripped() {
        let rules = extract_rules("- Never skip tests (expires 2026-12-31)", "test", 0.9);
        assert_eq!(rules.len(), 1);
        assert_eq!(rules[0].expires_at.as_deref(), Some("2026-12-31"));
        // Object should no longer contain the annotation.
        assert!(
            !rules[0].object.contains("expires"),
            "object still carries annotation: {}",
            rules[0].object
        );
        assert!(
            rules[0].object.contains("skip tests"),
            "object missing rule body: {}",
            rules[0].object
        );
    }

    #[test]
    fn test_expiry_variants() {
        // "until" variant
        let rules = extract_rules("- Always use autogenerate (until 2027-01-15)", "test", 0.9);
        assert_eq!(rules[0].expires_at.as_deref(), Some("2027-01-15"));

        // "expire" (no s) also accepted
        let rules = extract_rules("- Avoid direct sql writes (expire 2026-06-01)", "test", 0.9);
        assert_eq!(rules[0].expires_at.as_deref(), Some("2026-06-01"));

        // No annotation → None
        let rules = extract_rules("- Never force-push to main", "test", 0.9);
        assert_eq!(rules[0].expires_at, None);
    }

    #[test]
    fn test_noenrich_is_extracted_and_stripped() {
        let rules = extract_rules(
            "- Never deploy to internal-codename-cluster (noenrich)",
            "test",
            0.9,
        );
        assert_eq!(rules.len(), 1);
        assert!(rules[0].noenrich, "noenrich flag should be set");
        assert!(
            !rules[0].object.contains("noenrich"),
            "object still carries annotation: {}",
            rules[0].object
        );
        assert!(
            rules[0].object.contains("internal-codename-cluster"),
            "object missing rule body: {}",
            rules[0].object
        );

        // No annotation → flag stays false
        let rules = extract_rules("- Never force-push to main", "test", 0.9);
        assert!(!rules[0].noenrich);
    }

    #[test]
    fn test_noenrich_combined_with_expiry_either_order() {
        // (expires ...) (noenrich)
        let rules = extract_rules(
            "- Never use legacy-shim (expires 2026-12-31) (noenrich)",
            "test",
            0.9,
        );
        assert_eq!(rules.len(), 1);
        assert!(rules[0].noenrich);
        assert_eq!(rules[0].expires_at.as_deref(), Some("2026-12-31"));
        assert!(!rules[0].object.contains("noenrich"));
        assert!(!rules[0].object.contains("expires"));

        // (noenrich) (expires ...)
        let rules = extract_rules(
            "- Never use legacy-shim (noenrich) (expires 2026-12-31)",
            "test",
            0.9,
        );
        assert_eq!(rules.len(), 1);
        assert!(rules[0].noenrich);
        assert_eq!(rules[0].expires_at.as_deref(), Some("2026-12-31"));
        assert!(!rules[0].object.contains("noenrich"));
        assert!(!rules[0].object.contains("expires"));
    }

    #[test]
    fn test_always_pattern() {
        let rules = extract_rules("- Always use autogenerate for alembic", "test", 0.9);
        assert_eq!(rules.len(), 1);
        assert_eq!(rules[0].predicate, "always");
        assert_eq!(rules[0].subject, "Alembic");
    }

    #[test]
    fn test_dont_pattern() {
        let rules = extract_rules("- Don't mock the database in tests", "test", 0.9);
        assert_eq!(rules.len(), 1);
        assert_eq!(rules[0].predicate, "forbids");
    }

    #[test]
    fn test_must_pattern() {
        let rules = extract_rules("- Must run tests before merging", "test", 0.9);
        assert_eq!(rules.len(), 1);
        assert_eq!(rules[0].predicate, "requires");
    }

    #[test]
    fn test_must_not_pattern() {
        let rules = extract_rules("- Must not skip pre-commit hooks", "test", 0.9);
        assert_eq!(rules.len(), 1);
        assert_eq!(rules[0].predicate, "must_not");
    }

    #[test]
    fn test_avoid_pattern() {
        let rules = extract_rules("- Avoid using --no-verify", "test", 0.9);
        assert_eq!(rules.len(), 1);
        assert_eq!(rules[0].predicate, "forbids");
    }

    #[test]
    fn test_prefer_pattern() {
        let rules = extract_rules("- Prefer cargo test over manual testing", "test", 0.9);
        assert_eq!(rules.len(), 1);
        assert_eq!(rules[0].predicate, "prefers");
        assert_eq!(rules[0].subject, "Cargo");
    }

    #[test]
    fn test_is_forbidden_pattern() {
        let rules = extract_rules("- Force-push to main is forbidden", "test", 0.9);
        assert_eq!(rules.len(), 1);
        assert_eq!(rules[0].predicate, "forbids");
    }

    #[test]
    fn test_use_with_known_tool() {
        let rules = extract_rules("- Use uv run for all Python commands", "test", 0.9);
        assert_eq!(rules.len(), 1);
        assert_eq!(rules[0].predicate, "requires");
        assert_eq!(rules[0].subject, "Uv");
    }

    #[test]
    fn test_use_without_signal_skipped() {
        let rules = extract_rules("- Use the Read tool to read files", "test", 0.9);
        assert_eq!(rules.len(), 0);
    }

    #[test]
    fn test_section_context_inheritance() {
        let content = "## Alembic\n\n- Never hand-write migration files\n- Always use autogenerate";
        let rules = extract_rules(content, "test", 0.9);
        assert_eq!(rules.len(), 2);
        assert_eq!(rules[0].subject, "Alembic");
        assert_eq!(rules[1].subject, "Alembic");
    }

    #[test]
    fn test_code_block_skipped() {
        let content = "```\n- Never do this\n```\n\n- Always do that";
        let rules = extract_rules(content, "test", 0.9);
        assert_eq!(rules.len(), 1);
        assert_eq!(rules[0].predicate, "always");
    }

    #[test]
    fn test_multiple_rules() {
        let content = "\
- Never force-push to main
- Always run cargo test before merging
- Don't skip code review
- Must use feature branches";
        let rules = extract_rules(content, "test", 0.9);
        assert_eq!(rules.len(), 4);
    }

    // ── v0.2.11 parser-coverage broadening ─────────────────────────────

    fn extract_one(line: &str) -> Triple {
        let rules = extract_rules(line, "test", 0.9);
        assert_eq!(
            rules.len(),
            1,
            "expected exactly 1 rule from {line:?}, got {rules:#?}"
        );
        rules.into_iter().next().unwrap()
    }

    fn extract_none(line: &str) {
        let rules = extract_rules(line, "test", 0.9);
        assert!(
            rules.is_empty(),
            "expected NO rule from {line:?}, got {rules:#?}",
        );
    }

    // Layer 1 additions: should / shouldn't / cannot / refuse to / enforce /
    // make sure / be sure / consider / recommend.

    #[test]
    fn test_should_not_is_block_severity() {
        // `should not` is grammatically a prohibition; the writer chose to
        // call out a specific thing to avoid — treat as Block-severity.
        let r = extract_one("- Should not commit `.env` files");
        assert_eq!(r.predicate, "must_not");
        assert_eq!(r.layer, Some(1));
    }

    #[test]
    fn test_shouldnt_contraction() {
        let r = extract_one("- Shouldn't push without running tests");
        assert_eq!(r.predicate, "must_not");
    }

    #[test]
    fn test_should_is_inform_severity() {
        // Bare `should` is grammatically softer than `must`/`always`;
        // routes to `prefers` (Inform) so we honour the softness instead
        // of escalating to Block on a recommendation.
        let r = extract_one("- Should run linter before commits");
        assert_eq!(r.predicate, "prefers");
    }

    #[test]
    fn test_should_not_takes_priority_over_should() {
        // Pattern order matters: the more specific `^should not` must
        // match before the generic `^should` so we don't capture
        // "not commit" as the object of a `should` rule.
        let r = extract_one("- Should not skip tests");
        assert_eq!(r.predicate, "must_not");
        // Object should be the action, not "not skip" — must_not pattern
        // strips the "not" prefix.
        assert!(!r.object.starts_with("not"), "object: {}", r.object);
    }

    #[test]
    fn test_cannot_is_block_severity() {
        let r = extract_one("- Cannot commit binary blobs");
        assert_eq!(r.predicate, "must_not");
    }

    #[test]
    fn test_refuse_to_is_block_severity() {
        let r = extract_one("- Refuse to merge without review");
        assert_eq!(r.predicate, "forbids");
    }

    #[test]
    fn test_enforce_verb_form() {
        let r = extract_one("- Enforce strict typing in Python");
        assert_eq!(r.predicate, "enforces");
    }

    #[test]
    fn test_make_sure_is_warn_severity() {
        let r = extract_one("- Make sure tests pass before pushing");
        assert_eq!(r.predicate, "enforces");
    }

    #[test]
    fn test_make_sure_that_variant() {
        let r = extract_one("- Make sure that imports are sorted");
        assert_eq!(r.predicate, "enforces");
        assert!(!r.object.starts_with("that"), "object: {}", r.object);
    }

    #[test]
    fn test_be_sure_to_variant() {
        let r = extract_one("- Be sure to run prettier before commit");
        assert_eq!(r.predicate, "enforces");
    }

    #[test]
    fn test_consider_is_inform_severity() {
        let r = extract_one("- Consider compression for distribution");
        assert_eq!(r.predicate, "prefers");
    }

    #[test]
    fn test_recommend_variant() {
        let r = extract_one("- Recommend using uv over pip");
        assert_eq!(r.predicate, "prefers");
    }

    #[test]
    fn test_recommended_variant() {
        let r = extract_one("- Recommended pattern is dependency injection");
        assert_eq!(r.predicate, "prefers");
    }

    // Layer 1b: bare `^no <noun>` prohibition with bold-label guard.

    #[test]
    fn test_bare_no_prohibition() {
        let r = extract_one("- No AI attribution in commit messages");
        assert_eq!(r.predicate, "must_not");
        assert_eq!(r.layer, Some(1));
    }

    #[test]
    fn test_bare_no_emojis() {
        let r = extract_one("- No emojis in commit messages");
        assert_eq!(r.predicate, "must_not");
    }

    #[test]
    fn test_bold_no_label_is_descriptive_not_rule() {
        // "**No build process** - this is a zero-build extension." is a
        // feature-absence DESCRIPTION and must NOT extract as a rule.
        // This is the most important negative test in the v0.2.11 batch.
        extract_none("- **No build process** - this is a zero-build extension.");
    }

    #[test]
    fn test_bold_no_emdash_descriptive() {
        extract_none("- **No CORS handling** — Traefik manages all cross-origin handling.");
    }

    // Bold-label guard for `consider`.

    #[test]
    fn test_bold_consider_label_is_section_not_rule() {
        // "**Consider constraints:** What are the goals?" is a heading-
        // shaped bullet, not a soft-preference rule.
        extract_none("- **Consider constraints:** What are the goals and limitations?");
    }

    #[test]
    fn test_bold_emphasis_on_always_still_extracts() {
        // Bold emphasis on a non-label imperative is still a rule —
        // the guard only applies to bold + colon/dash labels.
        let rules = extract_rules("- **Always** run tests before push", "test", 0.9);
        assert_eq!(rules.len(), 1);
        assert_eq!(rules[0].predicate, "always");
    }

    // Layer 5 — section-context gate loosening.

    #[test]
    fn test_use_inside_conventions_section_extracts() {
        // Inside a `## Conventions` section, "Use X" should fire even
        // without a known tool name in the line.
        let content = "\
## Conventions
- Use the `cn()` utility from $lib/utils for class merging
";
        let rules = extract_rules(content, "test", 0.9);
        assert_eq!(rules.len(), 1, "expected one rule, got {rules:#?}");
        assert_eq!(rules[0].layer, Some(5));
    }

    #[test]
    fn test_use_outside_rules_section_still_gated() {
        // Outside a rules-shaped section, the tightened gate still applies —
        // "Use X" with no known tool and no co-imperative does NOT fire.
        let content = "\
## Architecture
- Use the diagram below to follow the flow
";
        let rules = extract_rules(content, "test", 0.9);
        assert!(
            rules.is_empty(),
            "expected no rule outside rules-section, got {rules:#?}",
        );
    }

    // Layer 6 — verb expansion.

    #[test]
    fn test_create_imperative() {
        let r = extract_one("- Create lookup functions for quick queries");
        assert_eq!(r.layer, Some(6));
    }

    #[test]
    fn test_implement_imperative() {
        let r = extract_one("- Implement try_from for type-specific parsing");
        assert_eq!(r.layer, Some(6));
    }

    #[test]
    fn test_document_imperative() {
        let r = extract_one("- Document decision-making processes");
        assert_eq!(r.layer, Some(6));
    }

    #[test]
    fn test_define_imperative() {
        let r = extract_one("- Define color variables in `_sass/`");
        assert_eq!(r.layer, Some(6));
    }

    #[test]
    fn test_store_imperative() {
        let r = extract_one("- Store results for each socket separately");
        assert_eq!(r.layer, Some(6));
    }

    // Layer 7 — conditional imperatives.

    #[test]
    fn test_conditional_when_comma_run() {
        let r = extract_one("- When working in parallel, run tests in isolation");
        assert_eq!(r.layer, Some(7));
        assert_eq!(r.predicate, "requires");
    }

    #[test]
    fn test_conditional_before_run() {
        let r = extract_one("- Before completing work, run the full test suite");
        assert_eq!(r.layer, Some(7));
    }

    #[test]
    fn test_conditional_after_run() {
        let r = extract_one("- After every code change, run the linter");
        assert_eq!(r.layer, Some(7));
    }

    #[test]
    fn test_conditional_if_use() {
        let r = extract_one("- If the test suite is slow, use `--release` for benchmarks");
        assert_eq!(r.layer, Some(7));
    }

    #[test]
    fn test_conditional_for_use() {
        let r =
            extract_one("- For tasks that need more compute, use subagents to work in parallel");
        assert_eq!(r.layer, Some(7));
    }

    #[test]
    fn test_conditional_when_colon() {
        let r = extract_one("- When suggesting changes: state impact on the broader system");
        assert_eq!(r.layer, Some(7));
    }

    #[test]
    fn test_conditional_arrow_separator() {
        let r = extract_one("- If missing → show \"Data Download Required\" dialog");
        assert_eq!(r.layer, Some(7));
    }

    #[test]
    fn test_conditional_with_never_predicate() {
        // Conditional with a Layer-1-leader on the right side — verb
        // resolves to a prohibitive predicate, not generic `requires`.
        let r = extract_one("- When deploying to production, never skip smoke tests");
        assert_eq!(r.layer, Some(7));
        assert_eq!(r.predicate, "never");
    }

    #[test]
    fn test_conditional_unrecognised_verb_skipped() {
        // "When X, see Y" — `see` is not in the imperative whitelist,
        // so the line is prose continuation, not a rule.  Skipped to
        // avoid extracting bullet headers that just happen to start
        // with a trigger word.
        extract_none("- When uncertain, see the troubleshooting guide for guidance");
    }

    #[test]
    fn test_conditional_subject_uses_known_tool_in_condition() {
        // "When using cargo, ..." — subject should be `cargo` (the tool
        // mentioned in the trigger phrase), not a fallback extraction.
        let r = extract_one("- When using cargo, run cargo test before commit");
        assert_eq!(r.layer, Some(7));
        assert_eq!(r.subject.to_lowercase(), "cargo");
    }
}
