"""Strategic Spine V2 models for TrueTraceShorts.

Side-effect-free editorial spine: typed candidate artifacts, hard gates,
calibrated 100-point scoring, deterministic ReviewPackageV2, and small demo
fixtures. No rendering, platform APIs, Telegram sends, or file writes live here.
"""

from __future__ import annotations

from dataclasses import dataclass
from enum import StrEnum
import hashlib
import json
import re
from typing import Any, Mapping

from autoshorts.ideas.candidate import ValidationResult

SUPPORTED_PLATFORMS_V2 = ("youtube_shorts", "tiktok", "instagram_reels", "linkedin_manual")
LEGACY_PLATFORM_MAP = {"youtube": "youtube_shorts", "instagram": "instagram_reels", "tiktok": "tiktok"}
ANTI_EXAMPLE_REQUIRED_SERIES = ("ai_output_autopsy", "workflow_teardown", "digital_red_flags", "decision_design")


class AudienceNeed(StrEnum):
    BOREDOM = "boredom"
    URGENCY = "urgency"
    FEAR = "fear"
    COMPETENCE = "competence"
    CLARITY = "clarity"
    STATUS = "status"
    SAFETY = "safety"
    CURIOSITY = "curiosity"


class ViewerEmotion(StrEnum):
    CONFUSED = "confused"
    ANNOYED = "annoyed"
    OVERWHELMED = "overwhelmed"
    SKEPTICAL = "skeptical"
    CURIOUS = "curious"
    EMBARRASSED = "embarrassed"
    ANXIOUS = "anxious"
    RELIEVED = "relieved"
    SMARTER = "smarter"
    IN_CONTROL = "in_control"
    WARNED = "warned"
    MOTIVATED = "motivated"
    EQUIPPED = "equipped"


class SaturationRisk(StrEnum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"


class HumanTexture(StrEnum):
    NONE = "none"
    REAL_EXAMPLE = "real_example"
    MINI_CASE = "mini_case"
    TESTED_PROMPT = "tested_prompt"
    SCREEN_RECORDING = "screen_recording"
    PERSONAL_OBSERVATION = "personal_observation"
    SOURCE_BASED_CLAIM = "source_based_claim"


class ClaimRisk(StrEnum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"


class AIDisclosureDecision(StrEnum):
    YES = "yes"
    NO = "no"
    UNCLEAR = "unclear"


class RenderBackendCandidate(StrEnum):
    PYTHON_MOTION = "python_motion"
    REMOTION_SPIKE_CANDIDATE = "remotion_spike_candidate"


@dataclass(frozen=True)
class ExperimentHypothesis:
    statement: str
    expected_winning_metric: str

    def to_dict(self) -> dict[str, str]:
        return {"statement": self.statement, "expected_winning_metric": self.expected_winning_metric}

    @classmethod
    def from_dict(cls, data: Mapping[str, Any]) -> "ExperimentHypothesis":
        return cls(statement=str(data["statement"]), expected_winning_metric=str(data["expected_winning_metric"]))


@dataclass(frozen=True)
class ContentBriefV2:
    brief_id: str
    series_id: str
    topic: str
    audience_need: AudienceNeed
    job_to_be_done: str
    viewer_identity: str
    emotion_before: ViewerEmotion
    emotion_after: ViewerEmotion
    audience_problem: str
    payoff: str
    search_intent_phrase: str
    saturation_risk: SaturationRisk
    human_texture: HumanTexture
    claim_risk: ClaimRisk
    ai_disclosure_required: AIDisclosureDecision
    experiment_hypothesis: ExperimentHypothesis
    platforms: tuple[str, ...]
    target_duration_seconds: int


@dataclass(frozen=True)
class SeriesDefinition:
    series_id: str
    name: str
    promise: str
    allowed_topic_clusters: tuple[str, ...]
    default_hook_pattern: str


@dataclass(frozen=True)
class SeriesRegistry:
    series: tuple[SeriesDefinition, ...]

    @classmethod
    def default(cls) -> "SeriesRegistry":
        return cls(
            series=(
                SeriesDefinition("ai_output_autopsy", "AI Output Autopsy", "Bad AI output -> hidden failure -> one rule.", ("ai", "workflow", "decision"), "This AI answer sounds right. That's the problem."),
                SeriesDefinition("digital_red_flags", "Digital Red Flags", "Normal-looking digital moment -> one warning signal.", ("scam", "security", "digital_safety"), "This looks safe. The trap is in line three."),
                SeriesDefinition("decision_design", "Decision Design", "Turn vague tool questions into decision evidence.", ("decision", "worklife", "ai"), "Don't ask what to do. Ask what would change your decision."),
                SeriesDefinition("workflow_teardown", "Workflow Teardown", "Broken workflow -> missing boundary/owner/exception path.", ("workflow", "automation", "worklife"), "Your workflow didn't fail. You forgot the exception path."),
                SeriesDefinition("attention_traps", "Attention Traps", "Everyday digital behavior -> attention mechanism -> control rule.", ("attention", "productivity", "digital_habits"), "You didn't lose focus. The app moved your next thought."),
                SeriesDefinition("search_smarter", "Search Smarter", "Platform search mistake -> better intent phrase.", ("search", "algorithm", "literacy"), "TikTok search is not Google. Stop typing like it is."),
                SeriesDefinition("worklife_systems", "Worklife Systems", "Worklife friction -> small system rule.", ("meetings", "email", "calendar", "todos"), "Your to-do list is not a plan. It's a guilt archive."),
            )
        )

    def get(self, series_id: str) -> SeriesDefinition:
        for series in self.series:
            if series.series_id == series_id:
                return series
        raise KeyError(series_id)

    def validate_series_id(self, series_id: str) -> ValidationResult:
        if not _is_blank(series_id) and any(series.series_id == series_id for series in self.series):
            return ValidationResult(is_valid=True)
        return ValidationResult(is_valid=False, errors=(f"unknown series_id: {series_id}",))


@dataclass(frozen=True)
class VideoCandidateV2:
    candidate_id: str
    version: str
    title: str
    series_id: str
    platforms: tuple[str, ...]
    duration_seconds: int
    audience_need: AudienceNeed
    job_to_be_done: str
    viewer_identity: str
    emotion_before: ViewerEmotion
    emotion_after: ViewerEmotion
    hook: str
    first_frame: str
    visible_fail: str
    mechanism: str
    fix: str
    before_after: str
    takeaway: str
    save_reason: str
    share_reason: str
    follow_reason: str
    search_intent_phrase: str
    saturation_risk: SaturationRisk
    human_texture: HumanTexture
    claim_risk: ClaimRisk
    claims: tuple[Any, ...]
    ai_disclosure_required: AIDisclosureDecision
    experiment_hypothesis: ExperimentHypothesis
    expected_winning_metric: str
    platform_fit: dict[str, str]
    risks: tuple[str, ...]
    visual_structure: str
    anti_example: str = ""
    plausible_nonsense_example: str = ""
    concrete_scene: str = ""
    human_texture_note: str = ""
    linkedin_title: str = ""
    linkedin_caption: str = ""
    render_backend_candidate: RenderBackendCandidate | str = RenderBackendCandidate.PYTHON_MOTION
    media_hash: str | None = None


GENERIC_AI_SLOP_PATTERNS = (
    r"\btop\s*5\s+ai\s+tools\b",
    r"\bmake\s+money\s+with\s+ai\b",
    r"\bthis\s+ai\s+tool\s+will\s+change\s+your\s+life\b",
    r"\bnobody\s+(talks|is\s+talking)\s+about\s+this\b",
    r"\bunlock\s+your\s+(full\s+)?potential\b",
    r"\byou\s+won'?t\s+believe\b",
    r"\bbest\s+ai\s+tools\b",
)
SLIDESHOW_PATTERNS = ("pure slideshow", "text cards only", "only text cards", "generic ai background", "b-roll plus captions")
FIRST_FRAME_CONFLICT_TERMS = ("split-screen", "vs.", " vs ", "stamp", "stamped", "warning", "red flag", "trap", "useless", "broken", "breaks", "fails", "conflict", "pulled")


def _is_blank(value: str | None) -> bool:
    return not (value or "").strip()


def _enum_value(value: object) -> str:
    return value.value if isinstance(value, StrEnum) else str(value)


def _platform_id(platform: str) -> str:
    return LEGACY_PLATFORM_MAP.get(platform, platform)


def _claim_text(claim: Any) -> str:
    return str(getattr(claim, "text", claim))


def _combined_text(candidate: VideoCandidateV2) -> str:
    return " ".join((candidate.title, candidate.hook, candidate.first_frame, candidate.visible_fail, candidate.anti_example, candidate.plausible_nonsense_example, candidate.concrete_scene, candidate.mechanism, candidate.fix, candidate.before_after, candidate.takeaway, candidate.visual_structure)).casefold()


def _has_visible_first_frame_conflict(first_frame: str) -> bool:
    text = (first_frame or "").casefold()
    if _is_blank(text):
        return False
    return any(term in text for term in FIRST_FRAME_CONFLICT_TERMS) and "plain title card" not in text


def _has_generic_ai_slop(text: str) -> bool:
    return any(re.search(pattern, text) for pattern in GENERIC_AI_SLOP_PATTERNS)


def _is_slideshow_structure(visual_structure: str) -> bool:
    text = (visual_structure or "").casefold()
    return any(pattern in text for pattern in SLIDESHOW_PATTERNS) and "motion-led" not in text


def validate_candidate_v2(candidate: VideoCandidateV2, *, registry: SeriesRegistry | None = None) -> ValidationResult:
    registry = registry or SeriesRegistry.default()
    errors: list[str] = []

    required_fields = {
        "candidate_id": candidate.candidate_id,
        "version": candidate.version,
        "title": candidate.title,
        "series_id": candidate.series_id,
        "job_to_be_done": candidate.job_to_be_done,
        "viewer_identity": candidate.viewer_identity,
        "hook": candidate.hook,
        "first_frame": candidate.first_frame,
        "visible_fail": candidate.visible_fail,
        "mechanism": candidate.mechanism,
        "fix": candidate.fix,
        "before_after": candidate.before_after,
        "takeaway": candidate.takeaway,
        "save_reason": candidate.save_reason,
        "share_reason": candidate.share_reason,
        "follow_reason": candidate.follow_reason,
        "search_intent_phrase": candidate.search_intent_phrase,
        "expected_winning_metric": candidate.expected_winning_metric,
        "visual_structure": candidate.visual_structure,
    }
    for field_name, value in required_fields.items():
        if _is_blank(value):
            errors.append(f"{field_name} is required")

    if candidate.series_id in ANTI_EXAMPLE_REQUIRED_SERIES and _is_blank(candidate.anti_example) and _is_blank(candidate.plausible_nonsense_example):
        errors.append("anti_example is required for this series")
    if candidate.series_id == "attention_traps" and _is_blank(candidate.concrete_scene) and _is_blank(candidate.anti_example):
        errors.append("concrete_scene is required for attention_traps")

    if candidate.human_texture != HumanTexture.NONE and _is_blank(candidate.human_texture_note):
        errors.append("human_texture_note is required")

    if not candidate.platforms:
        errors.append("at least one platform is required")
    for platform in candidate.platforms:
        platform_id = _platform_id(platform)
        if platform_id not in SUPPORTED_PLATFORMS_V2:
            errors.append(f"unsupported platform: {platform}")
        if platform_id not in candidate.platform_fit and platform not in candidate.platform_fit:
            errors.append(f"platform_fit missing for {platform_id}")
    if "linkedin_manual" in tuple(_platform_id(p) for p in candidate.platforms):
        if _is_blank(candidate.linkedin_caption):
            errors.append("linkedin_caption is required for linkedin_manual")
        if _is_blank(candidate.linkedin_title):
            errors.append("linkedin_title is required for linkedin_manual")

    if not 10 <= candidate.duration_seconds <= 180:
        errors.append("duration_seconds must be between 10 and 180")

    series_validation = registry.validate_series_id(candidate.series_id)
    if not series_validation.is_valid:
        errors.extend(series_validation.errors)

    if not _has_visible_first_frame_conflict(candidate.first_frame):
        errors.append("visible first-frame conflict is required")
    if candidate.human_texture == HumanTexture.NONE:
        errors.append("videos without concrete human texture are blocked")
    if candidate.ai_disclosure_required == AIDisclosureDecision.UNCLEAR:
        errors.append("AI disclosure decision cannot be unclear before rendering")
    if candidate.claim_risk == ClaimRisk.HIGH and not candidate.claims:
        errors.append("high claim risk requires documented claims")
    if not candidate.claims:
        errors.append("at least one claim or claim note is required")

    text = _combined_text(candidate)
    if _has_generic_ai_slop(text):
        errors.append("generic AI-slop hook/title detected")
    if _is_slideshow_structure(candidate.visual_structure):
        errors.append("pure slideshow/text-card structure is blocked")

    hard_gate_failed = any(
        _is_blank(value)
        for value in (candidate.visible_fail, candidate.first_frame, candidate.mechanism, candidate.fix, candidate.before_after, candidate.takeaway, candidate.save_reason, candidate.share_reason, candidate.follow_reason)
    )
    hard_gate_failed = hard_gate_failed or not _has_visible_first_frame_conflict(candidate.first_frame)
    hard_gate_failed = hard_gate_failed or not series_validation.is_valid
    hard_gate_failed = hard_gate_failed or candidate.ai_disclosure_required == AIDisclosureDecision.UNCLEAR
    hard_gate_failed = hard_gate_failed or not candidate.claim_risk
    if hard_gate_failed:
        errors.append("candidate is not renderable until all hard gates pass")

    return ValidationResult(is_valid=not errors, errors=tuple(dict.fromkeys(errors)))


@dataclass(frozen=True)
class ScoreComponent:
    points: int
    max_points: int
    rationale: str

    def to_dict(self) -> dict[str, int | str]:
        return {"points": self.points, "max_points": self.max_points, "rationale": self.rationale}


@dataclass(frozen=True)
class StrategyScoreV2:
    problem_urgency: int
    first_frame_clarity: int
    hook_curiosity: int
    mechanism_value: int
    practical_payoff: int
    retention_path: int
    share_save_potential: int
    trust_human_texture: int
    series_fit: int
    saturation_defense: int
    breakdown: dict[str, ScoreComponent]
    reject_reasons: tuple[str, ...] = ()

    @property
    def total(self) -> int:
        return sum(component.points for component in self.breakdown.values())

    @property
    def render_allowed(self) -> bool:
        return self.total >= 75 and not self.reject_reasons

    @classmethod
    def from_candidate(cls, candidate: VideoCandidateV2) -> "StrategyScoreV2":
        validation = validate_candidate_v2(candidate)
        text = _combined_text(candidate)
        reject_reasons: list[str] = []

        problem = 13 if candidate.visible_fail and candidate.audience_need else 0
        first_frame = 10 if _has_visible_first_frame_conflict(candidate.first_frame) else 0
        hook = 10 if ("because" in candidate.hook.casefold() or "?" in candidate.hook or "trap" in text) else 6
        if _has_generic_ai_slop(text):
            hook = min(hook, 3)
        mechanism = 11 if candidate.mechanism and (candidate.anti_example or candidate.concrete_scene or candidate.plausible_nonsense_example) else 8 if candidate.mechanism else 0
        payoff = 13 if candidate.fix and candidate.takeaway else 0
        has_conversion = bool(candidate.save_reason and candidate.share_reason and candidate.follow_reason)
        retention = 8 if candidate.before_after and "motion-led" in candidate.visual_structure.casefold() and has_conversion else 4 if candidate.before_after else 0
        share_save = 9 if has_conversion else 0
        texture_map = {HumanTexture.REAL_EXAMPLE: 8, HumanTexture.MINI_CASE: 7, HumanTexture.TESTED_PROMPT: 8, HumanTexture.SCREEN_RECORDING: 8, HumanTexture.PERSONAL_OBSERVATION: 6, HumanTexture.SOURCE_BASED_CLAIM: 4, HumanTexture.NONE: 0}
        texture = texture_map[candidate.human_texture]
        if _is_blank(candidate.human_texture_note):
            texture = min(texture, 2)
        series_fit = 4 if SeriesRegistry.default().validate_series_id(candidate.series_id).is_valid else 0
        saturation = {SaturationRisk.LOW: 2, SaturationRisk.MEDIUM: 1, SaturationRisk.HIGH: 0}[candidate.saturation_risk]

        if share_save < 9:
            reject_reasons.append("share/save/follow potential below threshold")
        if not validation.is_valid:
            reject_reasons.extend(validation.errors)
        if _has_generic_ai_slop(text):
            reject_reasons.append("generic AI-slop detected")

        breakdown = {
            "problem_urgency": ScoreComponent(problem, 15, "Visible everyday fail with recognizable audience need."),
            "first_frame_clarity": ScoreComponent(first_frame, 12, "First frame shows a conflict without relying on audio."),
            "hook_curiosity": ScoreComponent(hook, 12, "Hook opens a mechanism loop; generic hooks lose points."),
            "mechanism_value": ScoreComponent(mechanism, 12, "Mechanism is tied to anti-example/concrete scene."),
            "practical_payoff": ScoreComponent(payoff, 15, "Fix and takeaway are actionable."),
            "retention_path": ScoreComponent(retention, 10, "Before/after plus motion-led path."),
            "share_save_potential": ScoreComponent(share_save, 10, "Save, share, and follow reasons are explicit."),
            "human_texture": ScoreComponent(texture, 8, "Concrete human texture, not just an enum."),
            "series_fit": ScoreComponent(series_fit, 4, "Known TrueTrace series."),
            "saturation_defense": ScoreComponent(saturation, 2, "Saturation risk penalty applied."),
        }
        return cls(problem, first_frame, hook, mechanism, payoff, retention, share_save, texture, series_fit, saturation, breakdown, tuple(dict.fromkeys(reject_reasons)))

    def to_dict(self) -> dict[str, Any]:
        return {"total": self.total, "render_allowed": self.render_allowed, "reject_reasons": list(self.reject_reasons), "breakdown": {key: value.to_dict() for key, value in self.breakdown.items()}}


@dataclass(frozen=True)
class ReviewPackageV2:
    candidate_id: str
    version: str
    script_hash: str
    media_hash: str | None
    series: str
    audience_need: AudienceNeed
    emotion_before: ViewerEmotion
    emotion_after: ViewerEmotion
    hook: str
    first_frame: str
    visible_fail: str
    mechanism: str
    fix: str
    before_after: str
    takeaway: str
    save_reason: str
    share_reason: str
    follow_reason: str
    claims: tuple[Any, ...]
    claim_risk: ClaimRisk
    ai_disclosure: AIDisclosureDecision
    platform_fit: dict[str, str]
    risks: tuple[str, ...]
    experiment_hypothesis: ExperimentHypothesis
    expected_winning_metric: str
    approval_command: str
    score: StrategyScoreV2
    anti_example: str = ""
    concrete_scene: str = ""
    human_texture_note: str = ""
    linkedin_title: str = ""
    linkedin_caption: str = ""
    render_backend_candidate: str = "python_motion"

    def to_dict(self) -> dict[str, Any]:
        return {
            "candidate_id": self.candidate_id,
            "version": self.version,
            "script_hash": self.script_hash,
            "media_hash": self.media_hash,
            "series": self.series,
            "audience_need": _enum_value(self.audience_need),
            "emotion_before": _enum_value(self.emotion_before),
            "emotion_after": _enum_value(self.emotion_after),
            "hook": self.hook,
            "first_frame": self.first_frame,
            "visible_fail": self.visible_fail,
            "anti_example": self.anti_example,
            "concrete_scene": self.concrete_scene,
            "mechanism": self.mechanism,
            "fix": self.fix,
            "before_after": self.before_after,
            "takeaway": self.takeaway,
            "save_reason": self.save_reason,
            "share_reason": self.share_reason,
            "follow_reason": self.follow_reason,
            "claims": [_claim_text(claim) for claim in self.claims],
            "claim_risk": _enum_value(self.claim_risk),
            "ai_disclosure": _enum_value(self.ai_disclosure),
            "platform_fit": dict(self.platform_fit),
            "linkedin_title": self.linkedin_title,
            "linkedin_caption": self.linkedin_caption,
            "risks": list(self.risks),
            "experiment_hypothesis": self.experiment_hypothesis.to_dict(),
            "expected_winning_metric": self.expected_winning_metric,
            "human_texture_note": self.human_texture_note,
            "render_backend_candidate": self.render_backend_candidate,
            "score": self.score.to_dict(),
            "approval_command": self.approval_command,
        }

    def to_json(self) -> str:
        return json.dumps(self.to_dict(), ensure_ascii=False, separators=(",", ":"))

    @classmethod
    def from_dict(cls, data: Mapping[str, Any]) -> "ReviewPackageV2":
        score_data = data["score"]
        bd = score_data["breakdown"]
        breakdown = {key: ScoreComponent(int(value["points"]), int(value["max_points"]), str(value["rationale"])) for key, value in bd.items()}
        score = StrategyScoreV2(
            problem_urgency=breakdown["problem_urgency"].points,
            first_frame_clarity=breakdown["first_frame_clarity"].points,
            hook_curiosity=breakdown["hook_curiosity"].points,
            mechanism_value=breakdown["mechanism_value"].points,
            practical_payoff=breakdown["practical_payoff"].points,
            retention_path=breakdown["retention_path"].points,
            share_save_potential=breakdown["share_save_potential"].points,
            trust_human_texture=breakdown["human_texture"].points,
            series_fit=breakdown["series_fit"].points,
            saturation_defense=breakdown["saturation_defense"].points,
            breakdown=breakdown,
            reject_reasons=tuple(score_data.get("reject_reasons", ())),
        )
        return cls(
            candidate_id=str(data["candidate_id"]), version=str(data["version"]), script_hash=str(data["script_hash"]), media_hash=data.get("media_hash"), series=str(data["series"]), audience_need=AudienceNeed(data["audience_need"]), emotion_before=ViewerEmotion(data["emotion_before"]), emotion_after=ViewerEmotion(data["emotion_after"]), hook=str(data["hook"]), first_frame=str(data["first_frame"]), visible_fail=str(data["visible_fail"]), mechanism=str(data["mechanism"]), fix=str(data["fix"]), before_after=str(data["before_after"]), takeaway=str(data["takeaway"]), save_reason=str(data["save_reason"]), share_reason=str(data["share_reason"]), follow_reason=str(data["follow_reason"]), claims=tuple(data.get("claims", ())), claim_risk=ClaimRisk(data["claim_risk"]), ai_disclosure=AIDisclosureDecision(data["ai_disclosure"]), platform_fit=dict(data["platform_fit"]), risks=tuple(data.get("risks", ())), experiment_hypothesis=ExperimentHypothesis.from_dict(data["experiment_hypothesis"]), expected_winning_metric=str(data["expected_winning_metric"]), approval_command=str(data["approval_command"]), score=score, anti_example=str(data.get("anti_example", "")), concrete_scene=str(data.get("concrete_scene", "")), human_texture_note=str(data.get("human_texture_note", "")), linkedin_title=str(data.get("linkedin_title", "")), linkedin_caption=str(data.get("linkedin_caption", "")), render_backend_candidate=str(data.get("render_backend_candidate", "python_motion"))
        )


def _script_hash(candidate: VideoCandidateV2) -> str:
    payload = "\n".join((candidate.title, candidate.hook, candidate.first_frame, candidate.visible_fail, candidate.anti_example, candidate.plausible_nonsense_example, candidate.concrete_scene, candidate.mechanism, candidate.fix, candidate.before_after, candidate.takeaway, candidate.save_reason, candidate.share_reason, candidate.follow_reason))
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]


def build_review_package_v2(candidate: VideoCandidateV2) -> ReviewPackageV2:
    script_hash = _script_hash(candidate)
    command = f"APPROVE {candidate.candidate_id} {candidate.version} {script_hash}"
    return ReviewPackageV2(
        candidate_id=candidate.candidate_id,
        version=candidate.version,
        script_hash=script_hash,
        media_hash=candidate.media_hash,
        series=SeriesRegistry.default().get(candidate.series_id).name,
        audience_need=candidate.audience_need,
        emotion_before=candidate.emotion_before,
        emotion_after=candidate.emotion_after,
        hook=candidate.hook,
        first_frame=candidate.first_frame,
        visible_fail=candidate.visible_fail,
        mechanism=candidate.mechanism,
        fix=candidate.fix,
        before_after=candidate.before_after,
        takeaway=candidate.takeaway,
        save_reason=candidate.save_reason,
        share_reason=candidate.share_reason,
        follow_reason=candidate.follow_reason,
        claims=tuple(_claim_text(claim) for claim in candidate.claims),
        claim_risk=candidate.claim_risk,
        ai_disclosure=candidate.ai_disclosure_required,
        platform_fit=candidate.platform_fit,
        risks=candidate.risks,
        experiment_hypothesis=candidate.experiment_hypothesis,
        expected_winning_metric=candidate.expected_winning_metric,
        approval_command=command,
        score=StrategyScoreV2.from_candidate(candidate),
        anti_example=candidate.anti_example or candidate.plausible_nonsense_example,
        concrete_scene=candidate.concrete_scene,
        human_texture_note=candidate.human_texture_note,
        linkedin_title=candidate.linkedin_title,
        linkedin_caption=candidate.linkedin_caption,
        render_backend_candidate=_enum_value(candidate.render_backend_candidate),
    )


def validate_review_package_v2(package: ReviewPackageV2) -> ValidationResult:
    errors: list[str] = []
    for field_name, value in {"candidate_id": package.candidate_id, "version": package.version, "script_hash": package.script_hash, "series": package.series, "hook": package.hook, "first_frame": package.first_frame, "visible_fail": package.visible_fail, "mechanism": package.mechanism, "fix": package.fix, "before_after": package.before_after, "takeaway": package.takeaway, "save_reason": package.save_reason, "share_reason": package.share_reason, "follow_reason": package.follow_reason, "expected_winning_metric": package.expected_winning_metric, "approval_command": package.approval_command}.items():
        if _is_blank(value):
            errors.append(f"{field_name} is required")
    if not package.claims:
        errors.append("claims are required")
    if package.ai_disclosure == AIDisclosureDecision.UNCLEAR:
        errors.append("AI disclosure cannot be unclear")
    if package.script_hash not in package.approval_command:
        errors.append("approval command must include script hash")
    return ValidationResult(is_valid=not errors, errors=tuple(errors))


def render_review_package_v2(package: ReviewPackageV2) -> str:
    claims = "; ".join(_claim_text(claim) for claim in package.claims) if package.claims else "-"
    risks = "; ".join(package.risks) if package.risks else "-"
    platform_fit = "; ".join(f"{platform}: {fit}" for platform, fit in package.platform_fit.items())
    media_hash = package.media_hash or "-"
    breakdown = "\n".join(f"- {name}: {component.points}/{component.max_points} — {component.rationale}" for name, component in package.score.breakdown.items())
    return "\n".join([
        f"## ReviewPackageV2: {package.candidate_id}",
        f"Candidate ID: {package.candidate_id}", f"Version: {package.version}", f"Script Hash: {package.script_hash}", f"Media Hash: {media_hash}", f"Series: {package.series}", f"Audience Need: {_enum_value(package.audience_need)}", f"Viewer Emotion Before: {_enum_value(package.emotion_before)}", f"Viewer Emotion After: {_enum_value(package.emotion_after)}", "",
        f"Hook: {package.hook}", f"First Frame: {package.first_frame}", f"Visible Fail: {package.visible_fail}", f"Anti Example: {package.anti_example or '-'}", f"Concrete Scene: {package.concrete_scene or '-'}", f"Mechanism: {package.mechanism}", f"Fix: {package.fix}", f"Before/After: {package.before_after}", f"Takeaway: {package.takeaway}", "",
        f"Save Reason: {package.save_reason}", f"Share Reason: {package.share_reason}", f"Follow Reason: {package.follow_reason}", f"Claims: {claims}", f"Claim Risk: {_enum_value(package.claim_risk)}", f"AI Disclosure: {_enum_value(package.ai_disclosure)}", f"Platform Fit: {platform_fit}", f"LinkedIn Title: {package.linkedin_title or '-'}", f"LinkedIn Caption: {package.linkedin_caption or '-'}", f"Risks: {risks}", f"Experiment Hypothesis: {package.experiment_hypothesis.statement}", f"Expected Winning Metric: {package.expected_winning_metric}", f"ScoreV2: {package.score.total}/100", "Score Breakdown:", breakdown, "", f"Approval Command: `{package.approval_command}`", f"Reject Command: `REJECT {package.candidate_id} <reason>`", f"Revise Command: `REVISE {package.candidate_id} <field> <requested change>`"])


def _hypothesis(statement: str, metric: str = "saves_per_view") -> ExperimentHypothesis:
    return ExperimentHypothesis(statement=statement, expected_winning_metric=metric)


def _base_platform_fit() -> dict[str, str]:
    return {"youtube_shorts": "strong", "tiktok": "strong", "instagram_reels": "medium", "linkedin_manual": "manual B2B caption; check silent-autoplay readability"}


def build_demo_candidates_v2(limit: int | None = None) -> tuple[VideoCandidateV2, ...]:
    common = dict(version="v2.0.0", platforms=("youtube_shorts", "tiktok", "instagram_reels", "linkedin_manual"), duration_seconds=38, viewer_identity="office_worker", saturation_risk=SaturationRisk.MEDIUM, claim_risk=ClaimRisk.LOW, ai_disclosure_required=AIDisclosureDecision.NO, expected_winning_metric="saves_per_view", platform_fit=_base_platform_fit(), render_backend_candidate=RenderBackendCandidate.PYTHON_MOTION)
    candidates = (
        VideoCandidateV2(**common, candidate_id="ai-output-autopsy-decision-summary-38s", title="Your AI summary has no decision target", series_id="ai_output_autopsy", audience_need=AudienceNeed.CLARITY, job_to_be_done="When I summarize a meeting, I want decision evidence, so I can act without rereading everything.", emotion_before=ViewerEmotion.CONFUSED, emotion_after=ViewerEmotion.IN_CONTROL, hook="Your AI summary is useless because it has no decision target.", first_frame="Split-screen: 47-minute transcript vs. useless AI summary stamped USELESS.", visible_fail="The summary says 'great discussion' but misses the launch decision.", anti_example="A useless AI summary saying great discussion with no owner, blocker, or decision.", mechanism="Without a decision target, AI compresses every sentence equally.", fix="Ask for blockers, owners, risks, and next steps tied to the decision.", before_after="Before: generic summary. After: decision evidence with owner and risk.", takeaway="Don't ask for a summary. Ask for decision evidence.", save_reason="Copyable sentence for the next meeting summary.", share_reason="Colleagues recognize the useless-summary problem.", follow_reason="Follow for small systems that make AI output useful.", search_intent_phrase="better meeting summary prompt", human_texture=HumanTexture.MINI_CASE, human_texture_note="messy meeting transcript with launch decision", claims=("Decision-targeted summaries preserve relevance better than generic summaries.",), experiment_hypothesis=_hypothesis("Tests whether decision-target AI examples drive saves."), risks=("medium AI-tip saturation",), visual_structure="motion-led compression funnel with transcript cards and decision target", linkedin_title="A better way to brief AI meeting summaries", linkedin_caption="Most meeting summaries fail because they protect no decision. Name the decision first, then ask for blockers, owners, risks and next steps."),
        VideoCandidateV2(**common, candidate_id="digital-red-flags-fake-invoice-35s", title="This fake invoice looks normal", series_id="digital_red_flags", audience_need=AudienceNeed.SAFETY, job_to_be_done="When I receive an invoice, I want to spot pressure cues, so I do not pay a fake bill.", emotion_before=ViewerEmotion.ANXIOUS, emotion_after=ViewerEmotion.WARNED, hook="This invoice looks safe. The trap is in line three.", first_frame="Split-screen: normal invoice vs. red flag line with urgent payment warning.", visible_fail="The recipient trusts the logo but ignores the changed payment route.", anti_example="A seemingly normal fake invoice where the payment route changed under urgency.", mechanism="Scam messages borrow familiar context and add urgency before verification.", fix="Check sender domain, urgency, and payment route before acting.", before_after="Before: logo trust. After: domain, urgency, and bank route check.", takeaway="Urgency is not proof. It is the red flag.", save_reason="Viewer can reuse the three-check invoice rule.", share_reason="Anyone handling invoices can use the warning.", follow_reason="Follow for digital red flags before they cost you.", search_intent_phrase="how to spot fake invoice", human_texture=HumanTexture.REAL_EXAMPLE, human_texture_note="fake invoice with changed payment route", claims=("Urgency and payment-route changes are common scam warning signs.",), experiment_hypothesis=_hypothesis("Tests whether red-flag safety content produces shares."), risks=("avoid naming real brands",), visual_structure="motion-led red flag scan with timer line and before/after checks", linkedin_title="A quick invoice red-flag check for teams", linkedin_caption="A fake invoice does not need to be perfect. It only needs to rush you. Check domain, urgency and payment route before acting."),
        VideoCandidateV2(**common, candidate_id="workflow-teardown-exception-path-40s", title="Your automation only knows the happy path", series_id="workflow_teardown", audience_need=AudienceNeed.COMPETENCE, job_to_be_done="When I automate a task, I want exceptions handled, so the workflow survives real inputs.", emotion_before=ViewerEmotion.ANNOYED, emotion_after=ViewerEmotion.EQUIPPED, hook="Your automation didn't fail. You forgot the exception path.", first_frame="Workflow line breaks at an unexpected email, with a red warning route.", visible_fail="The automation works on perfect inputs but stalls on one missing field.", anti_example="A client email missing the required address makes the automation stop silently.", mechanism="Happy-path automation assumes clean inputs and has no recovery route.", fix="Add an exception path: detect missing fields, ask for clarification, then continue.", before_after="Before: broken workflow. After: missing-field route and recovery step.", takeaway="If there is no exception path, it is not automation. It is hope.", save_reason="Builder can apply the exception-path checklist.", share_reason="Every automation builder has seen happy-path failures.", follow_reason="Follow for workflow teardowns before you automate the wrong thing.", search_intent_phrase="automation exception path example", human_texture=HumanTexture.TESTED_PROMPT, human_texture_note="automation input missing required client email", claims=("Automation reliability depends on handling exceptions, not only happy paths.",), experiment_hypothesis=_hypothesis("Tests whether workflow teardown examples attract builders."), risks=("avoid exposing private workflows",), visual_structure="motion-led broken workflow line with exception route appearing", linkedin_title="The automation check most teams skip", linkedin_caption="If your workflow only handles the happy path, it is not reliable automation. Add an exception path before scaling it."),
        VideoCandidateV2(**common, candidate_id="decision-design-hidden-decision-38s", title="Your question hides the decision", series_id="decision_design", audience_need=AudienceNeed.CLARITY, job_to_be_done="When I ask a tool for advice, I want decision criteria, so I can choose confidently.", emotion_before=ViewerEmotion.CONFUSED, emotion_after=ViewerEmotion.IN_CONTROL, hook="Your question is bad because it hides the decision.", first_frame="Split-screen: vague question vs. highlighted missing decision route.", visible_fail="The tool gives advice but cannot know what decision is being protected.", anti_example="A vague 'what should I do?' prompt produces confident advice with no decision criteria.", mechanism="A vague question asks for an opinion instead of decision evidence.", fix="Name the decision, criteria, and what would change your mind.", before_after="Before: 'What should I do?' After: decision criteria and disconfirming evidence.", takeaway="Don't ask for an opinion. Ask what would change the decision.", save_reason="Viewer can reuse the decision-criteria template.", share_reason="Teams often ask vague questions before decisions.", follow_reason="Follow for decision systems that reduce digital noise.", search_intent_phrase="how to make better decisions with AI", human_texture=HumanTexture.MINI_CASE, human_texture_note="vague decision request from a planning meeting", claims=("Decision criteria make advice more actionable than vague opinion requests.",), experiment_hypothesis=_hypothesis("Tests whether decision design content converts followers."), risks=("abstract topic needs concrete example",), visual_structure="motion-led decision route with one path lighting up", linkedin_title="Stop asking tools for opinions", linkedin_caption="Before asking for advice, define the decision and what evidence would change it."),
        VideoCandidateV2(**common, candidate_id="attention-traps-next-thought-35s", title="Your phone chooses the next question", series_id="attention_traps", audience_need=AudienceNeed.CLARITY, job_to_be_done="When I open my phone, I want to keep my intent, so I do not lose the next 20 minutes.", emotion_before=ViewerEmotion.OVERWHELMED, emotion_after=ViewerEmotion.IN_CONTROL, hook="You didn't lose focus. The app moved your next thought.", first_frame="Cursor thought bubble pulled from work note toward app tiles with red attention trap warning.", visible_fail="The viewer opens the phone for one task and follows the feed's next question.", concrete_scene="phone opened for one task, feed redirects attention", mechanism="Feeds win by offering the next question before you restate your own intent.", fix="Before opening the app, say the one action and the exit condition.", before_after="Before: open app, follow feed. After: intent card, one action, exit.", takeaway="Set the next question before the app sets it for you.", save_reason="Viewer can reuse the one-action exit rule.", share_reason="The lost-focus moment is widely relatable.", follow_reason="Follow for attention systems that give control back.", search_intent_phrase="how to stop getting distracted by phone", human_texture=HumanTexture.PERSONAL_OBSERVATION, human_texture_note="phone opened for one task, feed redirects attention", claims=("Clear intent before app use can reduce aimless switching.",), experiment_hypothesis=_hypothesis("Tests whether attention-trap framing generates shares."), risks=("avoid medical or addiction claims",), visual_structure="motion-led attention capture with thought bubble and app tiles", linkedin_title="A simple attention rule for app switching", linkedin_caption="Before opening an app, name the one action and exit condition. Otherwise the feed chooses the next question."),
    )
    return candidates[:limit] if limit is not None else candidates
