#!/usr/bin/env python3
"""
verify_funnel_psychology.py — rubric linter for Funnel Psychology & Architecture deliverables.

Scores ONE deliverable file against the master rubric (see MANUAL.md section 8)
and exits 0 (meets standard) or 1 (fails), printing every failed check by name.

Usage:
    python3 verify_funnel_psychology.py <file.md> --type <deliverable-type>
    python3 verify_funnel_psychology.py <file.md>              # infers type from filename

Deliverable types:
    funnel-blueprint | landing-page-spec | offer-stack | funnel-audit | split-test-plan

stdlib only. No network, no imports beyond argparse/re/sys.
"""
import argparse
import re
import sys

HYPE_WORDS = ["guaranteed", "effortless", "secret loophole"]

PRINCIPLE_NAMES = [
    "schwartz", "hopkins", "cialdini", "kahneman", "tversky", "abraham", "fogg",
    "b=map", "thaler", "sunstein", "ariely", "iyengar", "lepper", "eyal",
    "zeigarnik", "freedman", "fraser", "festinger", "collier", "brunson",
    "walker", "kennedy", "unmotivated", "unable", "unprompted",
]

PROOF_TAG_RE = r'\[(testimonial|data|demo|authority)\]'
URGENCY_KEYWORDS = [
    "only ", "limited time", "closing soon", "today only", "act now",
    "spots left", "hurry", "expires",
]


# --------------------------------------------------------------------------
# Markdown section parsing helpers
# --------------------------------------------------------------------------

def split_top_sections(text):
    """Split on '## ' headers. Returns list of (title, body)."""
    pattern = re.compile(r'(?m)^##\s+(.*)$')
    matches = list(pattern.finditer(text))
    sections = []
    for i, m in enumerate(matches):
        title = m.group(1).strip()
        start = m.end()
        end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
        sections.append((title, text[start:end]))
    return sections


def split_sub_sections(text):
    """Split on '### ' headers. Returns list of (title, body)."""
    pattern = re.compile(r'(?m)^###\s+(.*)$')
    matches = list(pattern.finditer(text))
    subs = []
    for i, m in enumerate(matches):
        title = m.group(1).strip()
        start = m.end()
        end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
        subs.append((title, text[start:end]))
    return subs


def find_section(sections, *keywords):
    """Find first top-level section whose (normalized) title contains all keywords."""
    for title, body in sections:
        norm = re.sub(r'[^a-z0-9]', '', title.lower())
        if all(re.sub(r'[^a-z0-9]', '', k.lower()) in norm for k in keywords):
            return title, body
    return None, None


def bullet_lines(text):
    return [l.strip() for l in text.splitlines()
            if l.strip() and re.match(r'^(-|\*|\d+\.)', l.strip())]


def nonempty_lines(text):
    return [l.strip() for l in text.splitlines() if l.strip()]


# --------------------------------------------------------------------------
# funnel-blueprint
# --------------------------------------------------------------------------

def check_funnel_blueprint(text):
    fails = []
    sections = split_top_sections(text)
    lowered = text.lower()

    for hw in HYPE_WORDS:
        if hw in lowered:
            fails.append(f"hype word banned: found '{hw}'")

    # 1. Market & Awareness Diagnosis
    _, body = find_section(sections, "market", "awareness")
    if body is None:
        fails.append("missing section: Market & Awareness Diagnosis")
    else:
        stage_names = ["unaware", "problem-aware", "solution-aware", "product-aware", "most-aware"]
        if not any(s in body.lower() for s in stage_names):
            fails.append("Market & Awareness Diagnosis: no named Schwartz awareness stage (1-5) found")
        if not re.search(r'sophistication\s*(level)?\s*[:\-]?\s*(stage\s*)?[1-5]\b', body, re.I):
            fails.append("Market & Awareness Diagnosis: no named sophistication level (1-5) found")

    # 2. Offer Stack
    _, body = find_section(sections, "offer", "stack")
    if body is None:
        fails.append("missing section: Offer Stack")
    else:
        rungs = bullet_lines(body)
        if len(rungs) < 2:
            fails.append(f"Offer Stack: only {len(rungs)} rung(s) found — need at least 2")
        for r in rungs:
            if not re.search(r'price\s*:', r, re.I):
                fails.append(f"Offer Stack rung missing 'Price:' label — '{r[:60]}'")
            if not re.search(r'promise\s*:', r, re.I):
                fails.append(f"Offer Stack rung missing 'Promise:' label — '{r[:60]}'")

    # 3. Stage Map
    _, body = find_section(sections, "stage", "map")
    stages = []
    if body is None:
        fails.append("missing section: Stage Map")
    else:
        stages = split_sub_sections(body)
        if not stages:
            fails.append("Stage Map: no individual stages found (expected '### Stage N' headers)")
        for stitle, sbody in stages:
            if not re.search(r'traffic-?in.*?:\s*\S', sbody, re.I):
                fails.append(f"Stage Map [{stitle}]: missing traffic-in source")
            if not re.search(r'\bjob\s*:\s*\S', sbody, re.I):
                fails.append(f"Stage Map [{stitle}]: missing the page's ONE job")
            cta_matches = re.findall(r'(?im)^[\-\*]?\s*(?:primary\s+)?cta\s*:\s*(.+)$', sbody)
            if len(cta_matches) == 0:
                fails.append(f"Stage Map [{stitle}]: no primary CTA line found")
            elif len(cta_matches) > 1:
                fails.append(f"Stage Map [{stitle}]: {len(cta_matches)} CTA lines found — exactly 1 primary CTA allowed per stage")
            if not re.search(r'next\s*stage\s*:\s*\S', sbody, re.I):
                fails.append(f"Stage Map [{stitle}]: missing 'Next stage' — orphan stage")

    # 4. Conversion Math
    _, body = find_section(sections, "conversion", "math")
    if body is None:
        fails.append("missing section: Conversion Math")
    else:
        lines = bullet_lines(body)
        if not lines:
            fails.append("Conversion Math: no stage-transition lines found")
        for l in lines:
            if re.search(r'\d', l) and not re.search(r'\[(benchmark|historical|guess)\]', l, re.I):
                fails.append(f"Conversion Math: numeric assumption without a source tag — '{l[:70]}'")

    # 5. Instrumentation
    _, body = find_section(sections, "instrumentation")
    if body is None:
        fails.append("missing section: Instrumentation")
    else:
        metrics = len(re.findall(r'(?im)\bmetric\s*:', body))
        events = len(re.findall(r'(?im)\bevent\s*:', body))
        n_stages = len(stages)
        if metrics == 0 or events == 0:
            fails.append("Instrumentation: missing 'Metric:' or 'Event:' labels")
        elif n_stages and (metrics < n_stages or events < n_stages):
            fails.append(f"Instrumentation: only {metrics} metric(s)/{events} event(s) for {n_stages} stages — every stage needs both")

    # 6. Failure Exits
    _, body = find_section(sections, "failure", "exit")
    if body is None:
        fails.append("missing section: Failure Exits")
    else:
        entries = bullet_lines(body)
        n_stages = len(stages)
        if not entries:
            fails.append("Failure Exits: no entries found")
        elif n_stages and len(entries) < n_stages:
            fails.append(f"Failure Exits: only {len(entries)} entries for {n_stages} stages — every stage needs a failure exit")

    return fails


# --------------------------------------------------------------------------
# landing-page-spec
# --------------------------------------------------------------------------

def check_landing_page_spec(text):
    fails = []
    sections = split_top_sections(text)

    goals = re.findall(r'(?im)^\s*conversion goal\s*:\s*(.+)$', text)
    if not goals:
        fails.append("missing: Conversion Goal declaration (need exactly ONE)")
    elif len(goals) > 1:
        fails.append(f"more than one Conversion Goal declared ({len(goals)}) — must be exactly ONE")

    cta_targets = re.findall(r'(?im)^\s*cta target\s*:\s*(.+)$', text)
    if not cta_targets:
        fails.append("missing: CTA Target declaration(s)")
    else:
        distinct = set(t.strip().lower() for t in cta_targets)
        if len(distinct) > 1:
            fails.append(f"competing CTAs: {len(distinct)} distinct CTA targets found — only 1 allowed: {sorted(distinct)}")

    m = re.search(r'(?im)^\s*attention ratio\s*:\s*(\d+)\s*:\s*(\d+)', text)
    if not m:
        fails.append("missing: Attention Ratio declaration")
    else:
        links, goal_n = int(m.group(1)), max(int(m.group(2)), 1)
        if (links / goal_n) > 3:
            fails.append(f"Attention Ratio {links}:{goal_n} exceeds the <=3:1 ceiling (Oli Gardner)")

    _, body = find_section(sections, "above", "fold")
    if body is None:
        fails.append("missing section: Above-the-fold block")
    else:
        for label in ["headline", "subhead", "cta", "proof"]:
            if not re.search(rf'(?im)^\s*{label}\s*:\s*\S', body):
                fails.append(f"Above-the-fold block missing '{label.title()}:' line")
        stage_names = ["unaware", "problem-aware", "solution-aware", "product-aware", "most-aware"]
        if not any(s in body.lower() for s in stage_names):
            fails.append("Above-the-fold block: headline doesn't reference a named awareness stage")

    _, body = find_section(sections, "proof", "inventory")
    if body is None:
        fails.append("missing section: Proof Inventory")
    else:
        items = bullet_lines(body)
        if len(items) < 2:
            fails.append(f"Proof Inventory: only {len(items)} item(s) — minimum 2 required")
        for it in items:
            if not re.search(PROOF_TAG_RE, it, re.I):
                fails.append(f"Proof Inventory: unattributed proof claim (no [testimonial|data|demo|authority] tag) — '{it[:70]}'")

    _, body = find_section(sections, "objection")
    if body is None:
        _, body = find_section(sections, "faq")
    if body is None:
        fails.append("missing section: Objection/FAQ block")
    else:
        objs = re.findall(r'(?im)^\s*objection\s*:\s*(.+)$', body)
        resps = re.findall(r'(?im)^\s*response\s*:\s*(.+)$', body)
        if len(objs) < 3:
            fails.append(f"Objection/FAQ block: only {len(objs)} objection(s) — minimum 3 required")
        if len(resps) < len(objs):
            fails.append("Objection/FAQ block: not every objection has a response")

    if not re.search(r'(?im)^\s*mobile order\s*:\s*\S', text):
        fails.append("missing: Mobile Order note")

    return fails


# --------------------------------------------------------------------------
# offer-stack
# --------------------------------------------------------------------------

def check_offer_stack(text):
    fails = []
    sections = split_top_sections(text)

    def line_val(label):
        m = re.search(rf'(?im)^\s*{label}\s*:\s*(.+)$', text)
        return m.group(1).strip() if m else None

    if not line_val("dream outcome"):
        fails.append("missing: Dream Outcome statement")
    if not line_val("offer name"):
        fails.append("missing: Name of offer")

    _, body = find_section(sections, "perceived", "likelihood")
    if body is None:
        fails.append("missing section: Perceived Likelihood elements")
    else:
        items = bullet_lines(body)
        if not items:
            fails.append("Perceived Likelihood: no proof elements listed")
        for it in items:
            if not re.search(PROOF_TAG_RE, it, re.I):
                fails.append(f"Perceived Likelihood: unattributed proof (no tag) — '{it[:70]}'")

    _, body = find_section(sections, "time", "delay")
    if body is None or not bullet_lines(body):
        fails.append("missing/empty section: Time Delay reducers")

    _, body = find_section(sections, "effort")
    if body is None or not bullet_lines(body):
        fails.append("missing/empty section: Effort/Sacrifice reducers")

    _, body = find_section(sections, "stack", "list")
    if body is None:
        _, body = find_section(sections, "stack")
    if body is None:
        fails.append("missing section: Stack list")
    else:
        items = bullet_lines(body)
        if len(items) < 2:
            fails.append(f"Stack list: only {len(items)} element(s) — need at least 2")
        for it in items:
            if not re.search(r'(value|rationale)\s*:', it, re.I):
                fails.append(f"Stack list element missing standalone value rationale — '{it[:70]}'")

    if not line_val("price"):
        fails.append("missing: Price")
    if not line_val("anchor price"):
        fails.append("missing: price-anchor (Anchor Price)")

    _, body = find_section(sections, "risk", "reversal")
    if body is None or "guarantee" not in body.lower():
        fails.append("missing/empty: Risk Reversal guarantee text")

    m = re.search(r'(?im)^\s*scarcity(?:/urgency)?\s*:\s*(.+)$', text)
    if not m:
        fails.append("missing: Scarcity/Urgency declaration")
    else:
        scarcity_line = m.group(1)
        tag_m = re.search(r'\[(real|absent)\]', scarcity_line, re.I)
        if not tag_m:
            fails.append("Scarcity/Urgency: missing [real|absent] tag")
        else:
            tag = tag_m.group(1).lower()
            urgent = any(k in scarcity_line.lower() for k in URGENCY_KEYWORDS)
            if urgent and tag != "real":
                fails.append("Scarcity/Urgency: fabricated-scarcity language present with no [real] tag")

    return fails


# --------------------------------------------------------------------------
# funnel-audit
# --------------------------------------------------------------------------

def check_funnel_audit(text):
    fails = []
    sections = split_top_sections(text)

    _, body = find_section(sections, "current", "state")
    if body is None:
        _, body = find_section(sections, "stage", "map")
    if body is None:
        fails.append("missing section: Current-state stage map")
    else:
        lines = bullet_lines(body)
        if not lines:
            fails.append("Current-state stage map: no entries found")
        for l in lines:
            if re.search(r'\d', l) and not re.search(r'\[[^\]]+\]', l):
                fails.append(f"Current-state stage map: measured number without a data-source tag — '{l[:70]}'")

    _, body = find_section(sections, "drop", "off")
    if body is None:
        _, body = find_section(sections, "diagnosis")
    if body is None:
        fails.append("missing section: Top-3 Drop-off Diagnosis")
    else:
        entries = split_sub_sections(body)
        if entries:
            texts = [t for _, t in entries]
        else:
            chunks = re.split(r'(?m)^\s*\d+\.\s', body)[1:]
            texts = chunks if chunks else [body]
        if len(texts) < 3:
            fails.append(f"Drop-off Diagnosis: only {len(texts)} entry(ies) — top-3 required")
        for i, t in enumerate(texts):
            if not re.search(r'\d', t):
                fails.append(f"Drop-off Diagnosis entry {i + 1}: missing a number")
            if not re.search(r'\[[^\]]+\]|source\s*:', t, re.I):
                fails.append(f"Drop-off Diagnosis entry {i + 1}: number missing a source tag")
            if not any(p in t.lower() for p in PRINCIPLE_NAMES):
                fails.append(f"Drop-off Diagnosis entry {i + 1}: no named principle from MANUAL.md section 2")

    _, body = find_section(sections, "fix", "list")
    if body is None:
        _, body = find_section(sections, "prioritized")
    if body is None:
        fails.append("missing section: Prioritized Fix List")
    else:
        numbered = re.findall(r'(?m)^\s*(\d+)\.\s', body)
        if not numbered:
            fails.append("Fix List: not ranked (expected a numbered list: 1. 2. 3. ...)")
        else:
            nums = [int(n) for n in numbered]
            if nums != list(range(1, len(nums) + 1)):
                fails.append(f"Fix List: numbering not sequential/ranked — got {nums}")
        if not re.search(r'\[(low|medium|high)\]', body, re.I):
            fails.append("Fix List: missing an effort tag [low|medium|high] on at least one fix")

    _, body = find_section(sections, "test", "plan")
    if body is None:
        fails.append("missing section: Test Plan")
    else:
        if not re.findall(r'(?im)^\s*hypothesis\s*:\s*\S', body):
            fails.append("Test Plan: missing Hypothesis: line(s)")
        if not re.findall(r'(?im)^\s*metric\s*:\s*\S', body):
            fails.append("Test Plan: missing Metric: line(s)")
        if not re.findall(r'(?im)^\s*success threshold\s*:\s*\S', body):
            fails.append("Test Plan: missing Success Threshold: line(s)")

    return fails


# --------------------------------------------------------------------------
# split-test-plan
# --------------------------------------------------------------------------

def check_split_test_plan(text):
    fails = []
    tests = split_sub_sections(text)
    if not tests:
        tests = [("(single test)", text)]

    for ttitle, tbody in tests:
        hyp_m = re.search(r'(?im)^\s*hypothesis\s*:\s*(.+)$', tbody)
        if not hyp_m:
            fails.append(f"[{ttitle}] missing Hypothesis: line")
        else:
            hyp = hyp_m.group(1)
            if not re.search(r'(?i)because\s+.+,\s*changing\s+.+\bwill\b.+measured by\s+.+', hyp):
                fails.append(f"[{ttitle}] hypothesis not in required form "
                              f"'because X, changing Y will Z measured by M' — '{hyp[:80]}'")

        var_m = re.search(r'(?im)^\s*variable\s*:\s*(.+)$', tbody)
        if not var_m:
            fails.append(f"[{ttitle}] missing Variable: declaration")
        else:
            v = var_m.group(1)
            if re.search(r'(?i)\band\b|\+|,', v):
                fails.append(f"[{ttitle}] Variable line implies multiple variables (not single) — '{v}'")

        if not re.search(r'(?im)^\s*primary metric\s*:\s*\S', tbody):
            fails.append(f"[{ttitle}] missing Primary Metric")
        if not re.search(r'(?im)^\s*guardrail metric\s*:\s*\S', tbody):
            fails.append(f"[{ttitle}] missing Guardrail Metric")
        if not re.search(r'(?im)^\s*(sample size|duration)\s*:\s*\S', tbody):
            fails.append(f"[{ttitle}] missing Sample Size / Duration line")

        dr_m = re.search(r'(?im)^\s*decision rule\s*:\s*(.+)$', tbody)
        if not dr_m:
            fails.append(f"[{ttitle}] missing Decision Rule (ship/kill threshold, stated before the test)")
        else:
            dr = dr_m.group(1).lower()
            if "ship" not in dr or "kill" not in dr:
                fails.append(f"[{ttitle}] Decision Rule doesn't state both a ship and a kill threshold — '{dr[:80]}'")

    return fails


# --------------------------------------------------------------------------
# main
# --------------------------------------------------------------------------

TYPE_CHECKS = {
    "funnel-blueprint": check_funnel_blueprint,
    "landing-page-spec": check_landing_page_spec,
    "offer-stack": check_offer_stack,
    "funnel-audit": check_funnel_audit,
    "split-test-plan": check_split_test_plan,
}

FILENAME_HINTS = [
    ("split-test", "split-test-plan"),
    ("split_test", "split-test-plan"),
    ("landing-page", "landing-page-spec"),
    ("landing_page", "landing-page-spec"),
    ("offer-stack", "offer-stack"),
    ("offer_stack", "offer-stack"),
    ("audit", "funnel-audit"),
    ("blueprint", "funnel-blueprint"),
]


def infer_type(path):
    p = path.lower()
    for hint, t in FILENAME_HINTS:
        if hint in p:
            return t
    return None


def main():
    ap = argparse.ArgumentParser(description="Funnel Psychology & Architecture rubric linter")
    ap.add_argument("file")
    ap.add_argument("--type", choices=list(TYPE_CHECKS.keys()), default=None)
    args = ap.parse_args()

    dtype = args.type or infer_type(args.file)
    if dtype is None:
        print(f"FAIL: could not infer deliverable type for '{args.file}' — pass --type explicitly", file=sys.stderr)
        sys.exit(1)

    try:
        with open(args.file, "r", encoding="utf-8") as f:
            text = f.read()
    except OSError as e:
        print(f"FAIL: cannot read '{args.file}': {e}", file=sys.stderr)
        sys.exit(1)

    fails = TYPE_CHECKS[dtype](text)

    print(f"verify_funnel_psychology.py :: {args.file} :: type={dtype}")
    if fails:
        print(f"FAIL — {len(fails)} check(s) failed:")
        for f_ in fails:
            print(f"  - {f_}")
        sys.exit(1)
    else:
        print("PASS — all rubric checks satisfied.")
        sys.exit(0)


if __name__ == "__main__":
    main()
