#!/usr/bin/env python3
"""
verify_copywriting.py — the anti-theater gate for the master-copywriter harness.

A REAL linter (python3 stdlib only) that scores ONE deliverable file against
the Conversion Copywriting rubric in MANUAL.md §8. Exits 0 if the deliverable
meets the standard, 1 if it fails — always printing which named checks failed.

Usage:
    python3 verify_copywriting.py <type> <file>

<type> is one of: sales-page | vsl-script | ad-set | email-subject-bank | landing-hero-variants

This is deliberately a linter, not a vibe check: every rule below is
mechanically checkable and traces to a named §8 rubric line in MANUAL.md.
"""
from __future__ import annotations

import re
import sys

# ---------------------------------------------------------------------------
# Shared vocabulary gates (stand-in for the `brand-bible` skill's live list —
# sync these against the skill's forbidden-vocabulary list when it is
# available in the running environment; kept local here so this linter has
# zero runtime dependencies).
# ---------------------------------------------------------------------------

HYPE_WORDS = [
    "guaranteed riches", "effortless", "overnight", "get rich quick",
    "life-changing money", "instant wealth", "no work required",
    "risk-free money", "secret loophole", "magic formula",
    "revolutionary", "insane results", "too good to be true",
]

FORBIDDEN_VOCAB = [
    "delve into", "in today's fast-paced world", "unlock the power",
    "game-changer", "game changing", "supercharge", "unleash your potential",
    "take it to the next level", "in this day and age", "at the end of the day",
    "synergy", "leverage synergies", "circle back",
]

PROOF_TAGS = ("testimonial", "data", "demo", "authority", "story")
CURIOSITY_MECHANISMS = ("open loop", "specificity", "contrast", "self-interest", "story-cue")
LEAD_TYPES = ("offer", "promise", "problem-solution", "big-secret", "proclamation", "story")
AWARENESS_STAGES = ("unaware", "problem-aware", "solution-aware", "product-aware", "most-aware")

SPAM_TRIGGER_PATTERNS = [
    re.compile(r"free!!!+", re.I),
    re.compile(r"\bact now\b", re.I),
    re.compile(r"\${2,}", re.I),
]

EMOJI_PATTERN = re.compile(
    "["
    "\U0001F300-\U0001FAFF"
    "\U00002600-\U000027BF"
    "\U0001F1E6-\U0001F1FF"
    "]"
)


class Report:
    """Collects named check results; exit code reflects whether any failed."""

    def __init__(self, label: str):
        self.label = label
        self.failures: list[str] = []
        self.passes: list[str] = []

    def check(self, name: str, ok: bool, detail: str = ""):
        line = f"{name}" + (f" — {detail}" if detail else "")
        if ok:
            self.passes.append(line)
        else:
            self.failures.append(line)

    def render_and_exit(self) -> int:
        print(f"== verify_copywriting :: {self.label} ==")
        for p in self.passes:
            print(f"  PASS: {p}")
        for f in self.failures:
            print(f"  FAIL: {f}")
        if self.failures:
            print(f"RESULT: FAIL ({len(self.failures)} check(s) failed, {len(self.passes)} passed)")
            return 1
        print(f"RESULT: PASS ({len(self.passes)} check(s) passed)")
        return 0


# ---------------------------------------------------------------------------
# Text-level primitives shared across deliverable types
# ---------------------------------------------------------------------------

def word_count(text: str) -> int:
    return len(re.findall(r"[A-Za-z0-9'’]+", text))


def count_syllables(word: str) -> int:
    word = word.lower().strip(".,!?;:\"'()[]")
    if not word:
        return 0
    word = re.sub(r"[^a-z]", "", word)
    if not word:
        return 1
    groups = re.findall(r"[aeiouy]+", word)
    count = len(groups)
    if word.endswith("e") and not word.endswith("le") and count > 1:
        count -= 1
    return max(count, 1)


def flesch_reading_ease(text: str) -> float:
    """Rudolf Flesch's Reading Ease formula, computed with stdlib only."""
    sentences = [s for s in re.split(r"[.!?]+", text) if s.strip()]
    words = re.findall(r"[A-Za-z']+", text)
    if not sentences or not words:
        return 0.0
    syllables = sum(count_syllables(w) for w in words)
    n_words = len(words)
    n_sentences = len(sentences)
    return 206.835 - 1.015 * (n_words / n_sentences) - 84.6 * (syllables / n_words)


def paragraphs(text: str) -> list[str]:
    """Split into prose 'paragraphs' for the 90-word gate.

    Blank-line-separated blocks are the unit, EXCEPT a block that is made up
    entirely of list-marker lines (`- ...` / `1. ...`) is a list, not a
    paragraph — each list item is checked on its own so a 4-item bullet list
    doesn't get merged into one giant "paragraph" and unfairly fail the gate.
    """
    out: list[str] = []
    for block in re.split(r"\n\s*\n", text):
        block = block.strip()
        if not block:
            continue
        lines = [l for l in block.splitlines() if l.strip()]
        is_list_block = bool(lines) and all(re.match(r"^\s*(?:[-*]|\d+[.)])\s+", l) for l in lines)
        if is_list_block:
            out.extend(lines)
        else:
            out.append(block)
    return out


def em_dash_density_per_200(text: str) -> float:
    dashes = text.count("—") + text.count("--")
    words = word_count(text)
    if words == 0:
        return 0.0
    return dashes / (words / 200.0)


def scan_banned(text: str, wordlist: list[str]) -> list[str]:
    hits = []
    low = text.lower()
    for w in wordlist:
        if w.lower() in low:
            hits.append(w)
    return hits


def find_section(text: str, name: str):
    """Return (start_index, header_match_end) for a `## Name` header, or None."""
    m = re.search(rf"^##\s+{re.escape(name)}\b.*$", text, re.I | re.M)
    return m


def section_body(text: str, headers_in_order: list[str], name: str) -> str:
    """Slice the body of section `name` up to the next known header (or EOF)."""
    idx = headers_in_order.index(name)
    start_m = find_section(text, name)
    if not start_m:
        return ""
    start = start_m.end()
    end = len(text)
    for later in headers_in_order[idx + 1:]:
        m = find_section(text, later)
        if m:
            end = min(end, m.start())
    return text[start:end]


# ---------------------------------------------------------------------------
# sales-page  (MANUAL.md §8.1)
# ---------------------------------------------------------------------------

def verify_sales_page(text: str, r: Report):
    order = ["Headline", "Lede", "Mechanism", "Proof", "Offer", "Objections", "CTA", "PS"]
    positions = {}
    for name in order:
        m = find_section(text, name)
        positions[name] = m.start() if m else None

    missing = [n for n in order if positions[n] is None]
    r.check("all 8 required sections present (Headline/Lede/Mechanism/Proof/Offer/Objections/CTA/PS)",
            not missing, f"missing: {', '.join(missing)}" if missing else "")

    if not missing:
        seq = [positions[n] for n in order]
        in_order = all(seq[i] < seq[i + 1] for i in range(len(seq) - 1))
        r.check("sections appear in required order", in_order,
                "" if in_order else "headers are present but out of order")
    else:
        in_order = False

    # Headline block: lead-type tag + awareness tag
    headline_body = section_body(text, order, "Headline") if positions["Headline"] is not None else ""
    lead_m = re.search(r"\[LEAD:\s*([^\]]+)\]", headline_body, re.I)
    aware_m = re.search(r"\[AWARENESS:\s*([^\]]+)\]", headline_body, re.I)
    lead_ok = bool(lead_m) and lead_m.group(1).strip().lower() in LEAD_TYPES
    aware_ok = bool(aware_m) and aware_m.group(1).strip().lower() in AWARENESS_STAGES
    r.check("headline block declares a valid lead-type tag (one of the six Great Leads)",
            lead_ok, f"found: {lead_m.group(1) if lead_m else 'none'}")
    r.check("headline block declares a valid awareness-stage tag",
            aware_ok, f"found: {aware_m.group(1) if aware_m else 'none'}")

    # Mechanism: a named mechanism string
    mech_body = section_body(text, order, "Mechanism") if positions["Mechanism"] is not None else ""
    mech_m = re.search(r"Mechanism:\s*(.+)", mech_body, re.I)
    mech_ok = bool(mech_m) and len(mech_m.group(1).strip()) >= 3
    r.check("mechanism section names a mechanism string (`Mechanism: <name>`)",
            mech_ok, "" if mech_ok else "no `Mechanism:` line with a real name found")

    # Proof block: >=3 tagged elements
    proof_body = section_body(text, order, "Proof") if positions["Proof"] is not None else ""
    proof_lines = [l for l in proof_body.splitlines() if l.strip().startswith("-")]
    tag_re = re.compile(r"\[(" + "|".join(PROOF_TAGS) + r")\]", re.I)
    tagged = [l for l in proof_lines if tag_re.search(l)]
    r.check("proof block has >=3 tagged proof elements", len(tagged) >= 3,
            f"found {len(tagged)} tagged proof line(s)")
    untagged_with_numbers = [l for l in proof_lines if not tag_re.search(l) and re.search(r"\d", l)]
    r.check("no untagged proof-block claims carrying numbers", not untagged_with_numbers,
            f"{len(untagged_with_numbers)} untagged numeric claim(s) in proof block")

    # Untagged numeric claims elsewhere (lede / mechanism / objections) must not
    # smuggle in bare stats without a proof tag on the same line.
    other_sections_text = "\n".join(
        section_body(text, order, n) for n in ("Lede", "Mechanism", "Objections") if positions.get(n) is not None
    )
    stat_line_re = re.compile(r"^[^\n]*\d[^\n]*%[^\n]*$|^[^\n]*\$\d[^\n]*$", re.M)
    smuggled = [l for l in stat_line_re.findall(other_sections_text) if not tag_re.search(l)]
    r.check("no untagged numeric/statistical claims smuggled outside the proof block",
            not smuggled, f"{len(smuggled)} untagged stat line(s) found outside Proof")

    # Offer: price, stack, guarantee
    offer_body = section_body(text, order, "Offer") if positions["Offer"] is not None else ""
    has_price = bool(re.search(r"Price:\s*\$?\d", offer_body, re.I))
    has_stack = bool(re.search(r"Stack:", offer_body, re.I)) and len(
        [l for l in offer_body.splitlines() if l.strip().startswith("-")]) >= 1
    has_guarantee = bool(re.search(r"Guarantee:\s*\S+", offer_body, re.I))
    r.check("offer section has a Price:", has_price)
    r.check("offer section has a Stack: with items", has_stack)
    r.check("offer section has Guarantee: text", has_guarantee)

    # Objections: >=3
    obj_body = section_body(text, order, "Objections") if positions["Objections"] is not None else ""
    obj_items = re.findall(r"^\s*(?:\d+[.)]|-)\s+\S", obj_body, re.M)
    r.check("objection handling has >=3 objections", len(obj_items) >= 3,
            f"found {len(obj_items)}")

    # CTA: exactly one distinct target
    cta_body = section_body(text, order, "CTA") if positions["CTA"] is not None else ""
    cta_matches = re.findall(r"CTA:\s*\[[^\]]*->\s*([^\]]+)\]", cta_body, re.I)
    distinct_targets = {t.strip() for t in cta_matches}
    r.check("exactly one distinct CTA target declared", len(distinct_targets) == 1,
            f"found {len(distinct_targets)} distinct target(s): {sorted(distinct_targets)}")

    # PS block
    ps_body = section_body(text, order, "PS") if positions["PS"] is not None else ""
    r.check("PS block has content", bool(ps_body.strip()))

    # Banned patterns (hard fails, whole document)
    hype_hits = scan_banned(text, HYPE_WORDS)
    r.check("no hype-list words (guaranteed riches / effortless / overnight / ...)",
            not hype_hits, f"hit(s): {hype_hits}" if hype_hits else "")
    vocab_hits = scan_banned(text, FORBIDDEN_VOCAB)
    r.check("no brand-bible forbidden vocabulary", not vocab_hits,
            f"hit(s): {vocab_hits}" if vocab_hits else "")

    long_paragraphs = [p for p in paragraphs(text) if word_count(p) > 90]
    r.check("no paragraph exceeds 90 words", not long_paragraphs,
            f"{len(long_paragraphs)} paragraph(s) over 90 words")

    dash_density = em_dash_density_per_200(text)
    r.check("em-dash density <=1 per 200 words", dash_density <= 1.0,
            f"{dash_density:.2f} per 200 words")

    fre = flesch_reading_ease(text)
    r.check("Flesch Reading Ease >= 50", fre >= 50, f"scored {fre:.1f}")


# ---------------------------------------------------------------------------
# vsl-script  (MANUAL.md §8.2)
# ---------------------------------------------------------------------------

def verify_vsl_script(text: str, r: Report):
    order = ["Hook", "Lead", "BigIdea", "Proof", "Transition", "Stack", "PriceReveal", "Guarantee", "CTA"]
    aliases = {
        "Hook": "Hook", "Lead": "Lead", "BigIdea": "Big Idea",
        "Proof": "Proof", "Transition": "Transition", "Stack": "Stack",
        "PriceReveal": "Price Reveal", "Guarantee": "Guarantee", "CTA": "CTA",
    }
    positions = {}
    for key, display in aliases.items():
        m = find_section(text, display)
        positions[key] = m.start() if m else None
    missing = [aliases[k] for k in order if positions[k] is None]
    r.check("all 9 required beats present (Hook/Lead/Big Idea/Proof/Transition/Stack/Price Reveal/Guarantee/CTA)",
            not missing, f"missing: {', '.join(missing)}" if missing else "")

    display_order = [aliases[k] for k in order]

    def body_of(key):
        return section_body(text, display_order, aliases[key]) if positions[key] is not None else ""

    hook_body = body_of("Hook")
    ts_ok = bool(re.search(r"\[00:0[0-9]\s*-\s*00:1[0-5]\]|\[0:00\s*-\s*0:15\]|\[first 15s\]", hook_body, re.I))
    r.check("hook has a first-15s timestamped block", ts_ok,
            "" if ts_ok else "no [0:00-0:15]-style timestamp found")
    hook_text_m = re.search(r"Hook:\s*\"([^\"]+)\"", hook_body, re.I)
    hook_words = word_count(hook_text_m.group(1)) if hook_text_m else 999
    r.check("hook line is <=40 words", bool(hook_text_m) and hook_words <= 40,
            f"{hook_words} words" if hook_text_m else "no `Hook: \"...\"` line found")

    lead_body = body_of("Lead")
    lead_m = re.search(r"\[LEAD:\s*([^\]]+)\]", lead_body, re.I)
    r.check("lead beat declares a valid lead-type tag",
            bool(lead_m) and lead_m.group(1).strip().lower() in LEAD_TYPES,
            f"found: {lead_m.group(1) if lead_m else 'none'}")

    bigidea_body = body_of("BigIdea")
    r.check("big-idea/mechanism beat names a mechanism",
            bool(re.search(r"Mechanism:\s*\S+", bigidea_body, re.I)))

    proof_body = body_of("Proof")
    proof_lines = [l for l in proof_body.splitlines() if l.strip().startswith("-")]
    tag_re = re.compile(r"\[(" + "|".join(PROOF_TAGS) + r")\]", re.I)
    tagged = [l for l in proof_lines if tag_re.search(l)]
    r.check("proof segments >=3, all tagged", len(tagged) >= 3 and len(tagged) == len(proof_lines),
            f"{len(tagged)}/{len(proof_lines)} tagged")

    r.check("transition-to-pitch marker present", positions["Transition"] is not None)
    r.check("stack beat present", positions["Stack"] is not None and bool(body_of("Stack").strip()))

    price_body = body_of("PriceReveal")
    anchor_ok = bool(re.search(r"(anchor|value)\D*\$\d", price_body, re.I)) and bool(re.search(r"Price:\s*\$?\d", price_body, re.I))
    r.check("price reveal includes an anchor value and the price", anchor_ok)

    r.check("guarantee beat present with text", bool(body_of("Guarantee").strip()))

    cta_body = body_of("CTA")
    cta_matches = re.findall(r"CTA:\s*\[[^\]]*->\s*([^\]]+)\]", cta_body, re.I)
    distinct_targets = {t.strip() for t in cta_matches}
    r.check("single CTA action (repetition of same target allowed)", len(distinct_targets) == 1,
            f"found {len(distinct_targets)} distinct target(s)")

    loops = re.findall(r"\[LOOP-OPEN:[^\]]+\]", text, re.I)
    closes = re.findall(r"\[LOOP-CLOSE:[^\]]+\]", text, re.I)

    def loop_id(tag):
        m = re.search(r"\[LOOP-OPEN:\s*([^\]]+)\]|\[LOOP-CLOSE:\s*([^\]]+)\]", tag, re.I)
        return (m.group(1) or m.group(2)).strip() if m else None

    open_ids = {loop_id(t) for t in loops}
    close_ids = {loop_id(t) for t in closes}
    unclosed = open_ids - close_ids
    r.check("open-loop inventory has >=2 loops, each with a declared close point",
            len(open_ids) >= 2 and not unclosed,
            f"{len(open_ids)} opened, unclosed: {sorted(unclosed)}" if (len(open_ids) < 2 or unclosed) else "")


# ---------------------------------------------------------------------------
# ad-set  (MANUAL.md §8.3)
# ---------------------------------------------------------------------------

def verify_ad_set(text: str, r: Report):
    variants = re.split(r"^##\s+Variant\s+\d+.*$", text, flags=re.M | re.I)[1:]
    r.check(">=5 ad variants present", len(variants) >= 5, f"found {len(variants)}")

    hooks = []
    angle_counts: dict[str, int] = {}
    problems = []
    for i, v in enumerate(variants, start=1):
        aware_m = re.search(r"\[AWARENESS:\s*([^\]]+)\]", v, re.I)
        hook_m = re.search(r"Hook:\s*\"([^\"]+)\"", v, re.I)
        cta_m = re.findall(r"CTA:\s*\[[^\]]*->\s*([^\]]+)\]", v, re.I)
        angle_m = re.search(r"\[ANGLE:\s*([^\]]+)\]", v, re.I)

        if not aware_m or aware_m.group(1).strip().lower() not in AWARENESS_STAGES:
            problems.append(f"variant {i}: missing/invalid awareness tag")
        if not hook_m:
            problems.append(f"variant {i}: missing Hook: line")
        else:
            hooks.append(hook_m.group(1).strip().lower())
            if word_count(hook_m.group(1)) > 12:
                problems.append(f"variant {i}: hook line over 12 words")
        if len({t.strip() for t in cta_m}) != 1:
            problems.append(f"variant {i}: does not declare exactly one CTA target")
        if not angle_m:
            problems.append(f"variant {i}: missing [ANGLE: ...] tag")
        else:
            angle = angle_m.group(1).strip().lower()
            angle_counts[angle] = angle_counts.get(angle, 0) + 1

        income_claim = re.search(r"\b(before|after)\b.*\$", v, re.I)
        disclaimer = re.search(r"disclaimer", v, re.I)
        if income_claim and not disclaimer:
            problems.append(f"variant {i}: before/after income claim without a disclaimer line")

    dup_hooks = {h for h in hooks if hooks.count(h) > 1}
    r.check("no duplicate hook lines across variants", not dup_hooks,
            f"duplicated: {sorted(dup_hooks)}" if dup_hooks else "")

    overused_angles = {a: c for a, c in angle_counts.items() if c > 2}
    r.check("each angle tag used at most twice across the set", not overused_angles,
            f"overused: {overused_angles}" if overused_angles else "")

    r.check("every variant declares awareness tag, hook (<=12 words), single CTA, and angle tag",
            not problems, "; ".join(problems) if problems else "")


# ---------------------------------------------------------------------------
# email-subject-bank  (MANUAL.md §8.4)
# ---------------------------------------------------------------------------

def verify_email_subject_bank(text: str, r: Report):
    groups = re.split(r"^##\s+(.+)$", text, flags=re.M)
    # re.split with a capturing group interleaves headers and bodies
    parsed = []
    for i in range(1, len(groups), 2):
        header, body = groups[i], groups[i + 1] if i + 1 < len(groups) else ""
        parsed.append((header.strip(), body))

    mechanism_hits = set()
    all_subjects = []
    problems = []

    for header, body in parsed:
        header_low = header.lower()
        matched_mechanism = next((m for m in CURIOSITY_MECHANISMS if m in header_low), None)
        cite_m = re.search(r"Mechanism:\s*(.+)", body, re.I)
        if matched_mechanism:
            mechanism_hits.add(matched_mechanism)
        if not cite_m:
            problems.append(f"group '{header}': missing `Mechanism:` citation line")

        subj_lines = [l.strip()[2:].strip() for l in body.splitlines() if l.strip().startswith("-")]
        for s in subj_lines:
            all_subjects.append(s)
            if word_count(s) > 9:
                problems.append(f"subject over 9 words: '{s}'")
            caps_words = [w for w in re.findall(r"[A-Za-z]+", s) if len(w) > 1 and w.isupper()]
            if caps_words:
                problems.append(f"ALL-CAPS word in subject: '{s}'")
            emoji_count = len(EMOJI_PATTERN.findall(s))
            if emoji_count > 1:
                problems.append(f"more than 1 emoji in subject: '{s}'")
            for pat in SPAM_TRIGGER_PATTERNS:
                if pat.search(s):
                    problems.append(f"spam-trigger hit in subject: '{s}'")

    r.check(">=20 subjects total", len(all_subjects) >= 20, f"found {len(all_subjects)}")
    r.check(">=4 named curiosity mechanisms represented "
            "(open loop/specificity/contrast/self-interest/story-cue)",
            len(mechanism_hits) >= 4, f"found: {sorted(mechanism_hits)}")
    r.check("every group cites its mechanism, and no subject fails length/caps/emoji/spam checks",
            not problems, "; ".join(problems[:8]) + (" ..." if len(problems) > 8 else "") if problems else "")


# ---------------------------------------------------------------------------
# landing-hero-variants  (MANUAL.md §8.5)
# ---------------------------------------------------------------------------

def verify_landing_hero_variants(text: str, r: Report):
    variants = re.split(r"^##\s+Variant\s+\d+.*$", text, flags=re.M | re.I)[1:]
    r.check(">=3 hero variants present", len(variants) >= 3, f"found {len(variants)}")

    lead_types_seen = []
    problems = []
    for i, v in enumerate(variants, start=1):
        headline_m = re.search(r"Headline:\s*(.+)", v, re.I)
        subhead_m = re.search(r"Subhead:\s*(.+)", v, re.I)
        cta_m = re.search(r"CTA:\s*(.+)", v, re.I)
        lead_m = re.search(r"\[LEAD:\s*([^\]]+)\]", v, re.I)
        aware_m = re.search(r"\[AWARENESS:\s*([^\]]+)\]", v, re.I)
        because_m = re.search(r"Because:\s*(.+)", v, re.I)

        if not headline_m:
            problems.append(f"variant {i}: missing Headline:")
        elif word_count(headline_m.group(1)) > 14:
            problems.append(f"variant {i}: headline over 14 words")

        if not subhead_m:
            problems.append(f"variant {i}: missing Subhead:")
        elif word_count(subhead_m.group(1)) > 25:
            problems.append(f"variant {i}: subhead over 25 words")

        if not cta_m:
            problems.append(f"variant {i}: missing CTA:")
        else:
            cta_text = cta_m.group(1).strip()
            cta_words = cta_text.split()
            first_word = re.sub(r"[^A-Za-z]", "", cta_words[0]) if cta_words else ""
            verb_first = first_word.lower().endswith(("t", "n", "m", "k", "d", "w", "y", "e", "p", "g", "r", "s", "l", "h", "b", "c", "f", "v", "x", "z", "a", "i", "u", "o", "q", "j"))
            if len(cta_words) > 4:
                problems.append(f"variant {i}: CTA label over 4 words")
            if not cta_words or not first_word:
                problems.append(f"variant {i}: CTA label unparsable")

        if not lead_m or lead_m.group(1).strip().lower() not in LEAD_TYPES:
            problems.append(f"variant {i}: missing/invalid lead-type tag")
        else:
            lead_types_seen.append(lead_m.group(1).strip().lower())

        if not aware_m or aware_m.group(1).strip().lower() not in AWARENESS_STAGES:
            problems.append(f"variant {i}: missing/invalid awareness tag")

        if not because_m or not because_m.group(1).strip():
            problems.append(f"variant {i}: missing 'Because:' rationale")

    r.check("every variant has headline<=14w, subhead<=25w, verb-first CTA<=4w, "
            "lead+awareness tags, and a Because: rationale", not problems,
            "; ".join(problems[:8]) + (" ..." if len(problems) > 8 else "") if problems else "")

    diversity_ok = len(set(lead_types_seen)) >= 2 if lead_types_seen else False
    r.check("not all variants share the same lead type (diversity requirement)",
            diversity_ok, f"lead types used: {lead_types_seen}")


# ---------------------------------------------------------------------------
# entry point
# ---------------------------------------------------------------------------

DISPATCH = {
    "sales-page": verify_sales_page,
    "vsl-script": verify_vsl_script,
    "ad-set": verify_ad_set,
    "email-subject-bank": verify_email_subject_bank,
    "landing-hero-variants": verify_landing_hero_variants,
}


def main(argv: list[str]) -> int:
    if len(argv) != 3:
        print(f"usage: {argv[0]} <{'|'.join(DISPATCH)}> <file>", file=sys.stderr)
        return 2
    dtype, path = argv[1], argv[2]
    if dtype not in DISPATCH:
        print(f"FAIL: unknown deliverable type '{dtype}'. Valid: {', '.join(DISPATCH)}", file=sys.stderr)
        return 2
    try:
        with open(path, "r", encoding="utf-8") as f:
            text = f.read()
    except OSError as e:
        print(f"FAIL: cannot read {path}: {e}", file=sys.stderr)
        return 1

    r = Report(f"{dtype} :: {path}")
    DISPATCH[dtype](text, r)
    return r.render_and_exit()


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