#!/usr/bin/env python3
"""
verify_crm_automation.py — the crm-automator master's real linter.

Scores ONE deliverable file (a markdown artifact produced by the master)
against the discipline rubric defined in MANUAL.md §8.2. Exits 0 if the
deliverable meets the standard, 1 if it fails — printing every named
check that failed so the agent knows exactly what to fix.

python3 stdlib only. No network, no deps.

Usage:
    python3 verify_crm_automation.py <path/to/deliverable.md>
    python3 verify_crm_automation.py <path/to/deliverable.md> --type automation-spec

The file must declare its own type via a header line:
    Deliverable-Type: automation-spec
(one of: automation-spec, lead-scoring-model, segmentation-plan,
 journey-map, pipeline-audit, integration-runbook)

If --type is given it must match the declared type, or the check fails
hard (a deliverable that doesn't know what it is, is not done).
"""

import re
import sys
import argparse

VALID_TYPES = {
    "automation-spec",
    "lead-scoring-model",
    "segmentation-plan",
    "journey-map",
    "pipeline-audit",
    "integration-runbook",
}

# Patterns that look like an actual leaked secret value rather than an
# env-var / vault-name reference. Deliberately generous — a hard fail
# on a false positive is far cheaper than shipping a leaked key.
SECRET_PATTERNS = [
    re.compile(r"sk_live_[A-Za-z0-9]+"),
    re.compile(r"sk_test_[A-Za-z0-9]+"),
    re.compile(r"sk-[A-Za-z0-9]{16,}"),
    re.compile(r"pk_live_[A-Za-z0-9]+"),
    re.compile(r"AKIA[0-9A-Z]{16}"),
    re.compile(r"ghp_[A-Za-z0-9]{20,}"),
    re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"),
    re.compile(r"AIza[0-9A-Za-z\-_]{20,}"),
    re.compile(r"eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}"),  # JWT-shaped
    # generic: "key"/"token"/"secret"/"password" : <long opaque literal>
    # (not `${ENV_VAR}`, not `env:NAME`, not `vault:name/path`)
    re.compile(
        r"(?i)\b(api[_-]?key|secret|token|password|auth)\b\s*[:=]\s*"
        r"['\"]?(?!\$\{|env:|vault:|<|\{\{)[A-Za-z0-9/+_\-]{20,}['\"]?"
    ),
]


def fail_list():
    return []


def get_field(block, label):
    """Return the text after 'Label:' on its own line within block, or None."""
    m = re.search(
        rf"^[ \t\-\*]*{re.escape(label)}\s*:\s*(.+)$", block, re.IGNORECASE | re.MULTILINE
    )
    return m.group(1).strip() if m else None


def has_section(text, *keywords):
    """True if a heading-ish line contains all keywords (case-insensitive)."""
    for line in text.splitlines():
        if line.strip().startswith("#") or line.strip().startswith("**"):
            low = line.lower()
            if all(k.lower() in low for k in keywords):
                return True
    return False


def get_deliverable_type(text):
    m = re.search(r"^Deliverable-Type:\s*(\S+)", text, re.IGNORECASE | re.MULTILINE)
    return m.group(1).strip().lower() if m else None


# ---------------------------------------------------------------------------
# automation-spec
# ---------------------------------------------------------------------------
def check_automation_spec(text):
    failures = fail_list()
    blocks = re.split(r"^##\s*Automation\s*:", text, flags=re.IGNORECASE | re.MULTILINE)[1:]
    if not blocks:
        failures.append("no '## Automation: <name>' blocks found — need at least one automation defined")
        return failures

    required_fields = [
        "Trigger",
        "Entry conditions",
        "Exit condition",
        "Failure path",
        "Idempotency",
        "Owner",
        "Kill switch",
    ]

    for i, block in enumerate(blocks, start=1):
        tag = f"automation #{i}"
        for field in required_fields:
            val = get_field(block, field)
            if not val or val.strip().lower() in ("", "n/a", "none", "tbd"):
                failures.append(f"{tag}: missing or empty '{field}' — required by rubric")

        exit_val = get_field(block, "Exit condition")
        if exit_val and exit_val.strip().lower() in ("none", "n/a", "never", ""):
            failures.append(f"{tag}: EXIT/suppression condition is mandatory and cannot be 'none' — a spec with no exit fails")

        idem_val = get_field(block, "Idempotency")
        if idem_val and idem_val.strip().lower() in ("none", "n/a", ""):
            failures.append(f"{tag}: idempotency note cannot be empty — must state what prevents double-fire")

        # Action steps section: everything between "Action steps" and the next
        # known field label (or end of block).
        steps_match = re.search(
            r"Action steps\s*:?\s*(.*?)(?=^\s*[\-\*]?\s*(Exit condition|Failure path|Idempotency|Owner|Kill switch)\s*:|\Z)",
            block,
            re.IGNORECASE | re.DOTALL | re.MULTILINE,
        )
        steps_text = steps_match.group(1) if steps_match else ""
        if not steps_text.strip():
            failures.append(f"{tag}: missing 'Action steps' list")
        else:
            for line in steps_text.splitlines():
                if re.search(r"\bemail(s|ed|ing)?\b", line, re.IGNORECASE):
                    if not re.search(r"(template[_ -]?id|TPL-[A-Za-z0-9]+)", line, re.IGNORECASE):
                        # allow the reference to live anywhere in the same step block line group
                        if not re.search(r"(template[_ -]?id|TPL-[A-Za-z0-9]+)", steps_text, re.IGNORECASE):
                            failures.append(
                                f"{tag}: an action step emails a contact without referencing a compliance-checked template ID "
                                f"(offending line: {line.strip()!r})"
                            )
                            break

    return failures


# ---------------------------------------------------------------------------
# lead-scoring-model
# ---------------------------------------------------------------------------
def check_lead_scoring_model(text):
    failures = fail_list()

    if not has_section(text, "explicit") or not (has_section(text, "fit") or "explicit-fit" in text.lower()):
        failures.append("missing an explicit-fit criteria section/table")

    if not (has_section(text, "implicit") or has_section(text, "behavior")):
        failures.append("missing an implicit-behavior criteria section/table")

    if not re.search(r"(?i)\bdecay\b", text):
        failures.append("no DECAY rule found — a behavior score with no decay fails")

    mql_lines = [l for l in text.splitlines() if re.search(r"(?i)mql threshold", l)]
    if not mql_lines:
        failures.append("no MQL threshold line found")
    else:
        paired = any(
            re.search(r"(?i)(->|routes? to|routing|fires?|assign)", l) for l in mql_lines
        ) or any(
            re.search(r"(?i)(->|routes? to|routing|fires?|assign)", text[m.start():m.start() + 300])
            for m in re.finditer(r"(?i)mql threshold", text)
        )
        if not paired:
            failures.append("MQL threshold is not paired with the routing action it fires")

    # count negative point values in table rows: | ... | -N | or "-N pts" style
    neg_matches = re.findall(r"(?:\|\s*-\d+(?:\.\d+)?\s*\|)|(?:-\d+(?:\.\d+)?\s*(?:pts?|points))", text)
    if len(neg_matches) < 2:
        failures.append(
            f"fewer than 2 negative-scoring entries found ({len(neg_matches)}) — a model with no negatives fails"
        )

    if not re.search(r"(?i)review cadence", text):
        failures.append("missing a review-cadence line")

    if not re.search(r"(?i)goodhart", text):
        failures.append("missing a Goodhart note")
    elif not re.search(r"(?i)counter[- ]?metric", text):
        failures.append("Goodhart note found but no named counter-metric")

    return failures


# ---------------------------------------------------------------------------
# segmentation-plan
# ---------------------------------------------------------------------------
def check_segmentation_plan(text):
    failures = fail_list()
    segments = re.split(r"^###\s*Segment\s*:", text, flags=re.IGNORECASE | re.MULTILINE)[1:]

    if not segments:
        failures.append("no '### Segment: <name>' blocks found")
        return failures

    rule_op_re = re.compile(r"(>=|<=|==|!=|>|<|\bAND\b|\bOR\b|\bIN\s*\()", re.IGNORECASE)
    sunset_found = False

    # RFM base layer may live as its own top-level section (before/around the
    # segment blocks), not necessarily nested inside one — check the whole doc.
    rfm_section = re.search(r"(?i)RFM base layer(.*?)(?=^###\s*Segment|\Z)", text, re.DOTALL | re.MULTILINE)
    rfm_scope = rfm_section.group(1) if rfm_section else ""
    r_val = get_field(rfm_scope, "R")
    f_val = get_field(rfm_scope, "F")
    m_val = get_field(rfm_scope, "M")
    rfm_found = bool(
        r_val and f_val and m_val
        and re.search(r"\d", r_val) and re.search(r"\d", f_val) and re.search(r"\d", m_val)
    )

    for i, seg in enumerate(segments, start=1):
        name_line = seg.strip().splitlines()[0] if seg.strip() else ""
        tag = f"segment #{i} ({name_line[:40]})"

        rule = get_field(seg, "Rule")
        if not rule:
            failures.append(f"{tag}: missing 'Rule' line")
        elif not rule_op_re.search(rule):
            failures.append(f"{tag}: rule is prose, not machine-evaluable (no comparison operator/boolean found): {rule!r}")

        size = get_field(seg, "Size estimate")
        if not size or not re.search(r"(?i)source\s*:", seg):
            failures.append(f"{tag}: missing size estimate with a data source")

        if not get_field(seg, "Treatment"):
            failures.append(f"{tag}: missing intended 'Treatment'")

        if not re.search(r"(?i)(mutually exclusive|overlap)", seg):
            failures.append(f"{tag}: missing mutual-exclusivity/overlap declaration")

        if re.search(r"(?i)\b(sunset|dead)\b", name_line):
            sunset_found = True

    if not rfm_found:
        failures.append("no RFM base layer found with numeric R, F, M boundaries")

    if not sunset_found:
        failures.append("no sunset/dead segment found")

    return failures


# ---------------------------------------------------------------------------
# journey-map
# ---------------------------------------------------------------------------
def check_journey_map(text):
    failures = fail_list()
    stages = re.split(r"^###\s*Stage\s*:", text, flags=re.IGNORECASE | re.MULTILINE)[1:]

    if not stages:
        failures.append("no '### Stage: <name>' blocks found")
        return failures

    inbound_sla_ok = False
    for i, stage in enumerate(stages, start=1):
        name_line = stage.strip().splitlines()[0] if stage.strip() else ""
        tag = f"stage #{i} ({name_line[:40]})"
        entry = get_field(stage, "Entry criteria")
        if not entry:
            failures.append(f"{tag}: missing 'Entry criteria'")

        sla = get_field(stage, "SLA")
        is_inbound = bool(re.search(r"(?i)(inbound|lead[- ]?created|speed-to-lead)", name_line + stage[:200]))
        if is_inbound:
            if not sla or not re.search(r"\d+\s*min", sla, re.IGNORECASE):
                failures.append(f"{tag}: inbound/speed-to-lead stage must state an SLA in minutes")
            else:
                inbound_sla_ok = True

    if not re.search(r"(?i)trigger inventory", text):
        failures.append("missing a 'Trigger inventory' section")
    else:
        inv_match = re.search(r"(?i)trigger inventory(.*?)(?=^##|\Z)", text, re.DOTALL | re.MULTILINE)
        inv_text = inv_match.group(1) if inv_match else ""
        if not re.search(r"\.md", inv_text):
            failures.append("trigger inventory does not reference any automation-spec file (expected a .md reference)")

    if not re.search(r"(?i)conflict rule", text):
        failures.append("missing 'Conflict rules' section")

    if not re.search(r"(?i)metric\s*:", text):
        failures.append("missing per-transition instrumentation ('Metric:' lines)")

    if not inbound_sla_ok and not any(re.search(r"(?i)speed-to-lead", s) for s in stages):
        failures.append("no inbound stage with a stated minutes-SLA found anywhere in the journey")

    return failures


# ---------------------------------------------------------------------------
# pipeline-audit
# ---------------------------------------------------------------------------
def check_pipeline_audit(text):
    failures = fail_list()

    required_metrics = {
        "Stale-deal count": r"(?i)stale-deal count",
        "Next-step-empty count": r"(?i)next-step-empty count",
        "Stage-conversion rates": r"(?i)stage-conversion",
        "Pipeline coverage ratio": r"(?i)coverage ratio",
    }

    source_tag_re = re.compile(r"\[source\s*:[^\]]+\]|\(source\s*:[^)]+\)", re.IGNORECASE)

    for label, pattern in required_metrics.items():
        m = re.search(pattern, text)
        if not m:
            failures.append(f"missing required metric: {label}")
            continue
        line_start = text.rfind("\n", 0, m.start()) + 1
        line_end = text.find("\n", m.start())
        line_end = line_end if line_end != -1 else len(text)
        line = text[line_start:line_end]
        if not source_tag_re.search(line):
            failures.append(f"'{label}' line is missing a source tag, e.g. [source: SQL/report name]: {line.strip()!r}")

    if not re.search(r"(?i)stale-deal count.*?\d+\s*days?", text):
        failures.append("stale-deal count does not state its age threshold in days")

    at_risk = re.search(r"(?i)top-5 at-risk deals(.*?)(?=^##|\Z)", text, re.DOTALL | re.MULTILINE)
    if not at_risk:
        failures.append("missing 'Top-5 at-risk deals' section")
    else:
        reason_count = len(re.findall(r"(?i)reason\s*:", at_risk.group(1)))
        if reason_count < 5:
            failures.append(f"top-5 at-risk deals section has only {reason_count} reason codes (need 5)")

    action_section = re.search(r"(?i)ranked action(s)? list(.*?)(?=^##|\Z)", text, re.DOTALL | re.MULTILINE)
    if not action_section:
        failures.append("missing a ranked action list")
    else:
        owners = re.findall(r"(?i)owner\s*:\s*(funnel-closer|outreach-operator|human|[A-Za-z][\w .]+)", action_section.group(2))
        if not owners:
            failures.append("ranked action list has no named owner/agent per action")

    return failures


# ---------------------------------------------------------------------------
# integration-runbook
# ---------------------------------------------------------------------------
def check_integration_runbook(text):
    failures = fail_list()

    for pat in SECRET_PATTERNS:
        m = pat.search(text)
        if m:
            failures.append(f"HARD FAIL: inline secret-looking value found ({m.group(0)[:24]}...) — must reference an env var or vault name instead")
            break

    if not re.search(r"(?i)(direction\s*:|->|→)", text):
        failures.append("missing systems + direction of sync")

    if not re.search(r"\|.*(source field|src field).*\|.*(destination field|dst field)", text, re.IGNORECASE):
        failures.append("missing a field-mapping table (source field -> destination field)")

    auth_match = re.search(r"(?i)auth(entication)? method\s*:(.*)", text)
    if not auth_match:
        failures.append("missing 'Auth method' line")
    else:
        auth_val = auth_match.group(2)
        if not re.search(r"(?i)(env var|environment variable|vault)", auth_val):
            failures.append("auth method must reference an env var name or a vault name")

    if not re.search(r"(?i)sync frequency|sync trigger", text):
        failures.append("missing sync frequency/trigger")

    if not re.search(r"(?i)dedupe rule", text):
        failures.append("missing a dedupe rule")

    if not re.search(r"(?i)(failure|retry) behavior", text):
        failures.append("missing failure/retry behavior")

    if not re.search(r"(?i)rollback procedure", text):
        failures.append("missing a rollback procedure")

    return failures


CHECKERS = {
    "automation-spec": check_automation_spec,
    "lead-scoring-model": check_lead_scoring_model,
    "segmentation-plan": check_segmentation_plan,
    "journey-map": check_journey_map,
    "pipeline-audit": check_pipeline_audit,
    "integration-runbook": check_integration_runbook,
}


def main():
    parser = argparse.ArgumentParser(description="Lint a crm-automation deliverable against the master rubric.")
    parser.add_argument("path", help="path to the deliverable markdown file")
    parser.add_argument("--type", dest="type_override", default=None, choices=sorted(VALID_TYPES))
    args = parser.parse_args()

    try:
        with open(args.path, "r", encoding="utf-8") as f:
            raw_text = f.read()
    except OSError as e:
        print(f"FAIL: could not read {args.path}: {e}")
        sys.exit(1)

    # Strip HTML comments before linting — annotations/notes in a doc must
    # never count as the doc's actual content (a comment saying "no decay
    # rule here" must not itself satisfy a regex looking for the word).
    text = re.sub(r"<!--.*?-->", "", raw_text, flags=re.DOTALL)

    declared_type = get_deliverable_type(text)
    if not declared_type:
        print("FAIL: no 'Deliverable-Type: <type>' header found — a deliverable that doesn't declare its own type cannot be verified")
        sys.exit(1)

    if declared_type not in VALID_TYPES:
        print(f"FAIL: unknown Deliverable-Type '{declared_type}' — must be one of {sorted(VALID_TYPES)}")
        sys.exit(1)

    if args.type_override and args.type_override != declared_type:
        print(f"FAIL: --type {args.type_override} does not match declared Deliverable-Type: {declared_type}")
        sys.exit(1)

    checker = CHECKERS[declared_type]
    failures = checker(text)

    print(f"== verify_crm_automation :: {args.path} :: type={declared_type} ==")
    if failures:
        print(f"FAIL — {len(failures)} check(s) failed:")
        for f in failures:
            print(f"  - {f}")
        sys.exit(1)
    else:
        print("PASS — all rubric checks satisfied")
        sys.exit(0)


if __name__ == "__main__":
    main()
