"""Whole-story content contract. Pure Python; shared by authored demos and AI."""
import json
import os
import re
from typing import NamedTuple, Tuple

LETTERS = 'ABCD'
COUNTS = {'easy': 10, 'medium': 15, 'hard': 20}
CHAPTERS = 5
LIMITS = {'setup': 220, 'question': 140, 'option': 30, 'reveal': 240, 'ending': 220, 'reason': 220}
DEMO_DIR = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'resources', 'demo', 'story')


class ContentError(ValueError):
    """A pack cannot safely be played. Details are diagnostics, not provider payloads."""


class Unavailable(ContentError):
    """The source explicitly cannot provide the selected plot."""


class Scene(NamedTuple):
    chapter: int
    setup: str
    question: str
    options: Tuple[str, ...]
    correct: int
    reveal: str


class Pack(NamedTuple):
    difficulty: str
    scenes: Tuple[Scene, ...]
    ending: str


def _text(value, field, limit, allow_brackets=False):
    if not isinstance(value, str) or not value.strip():
        raise ContentError('{}: missing text'.format(field))
    value = value.strip()
    if len(value) > limit or re.search(r'[\x00-\x08\x0b-\x1f]', value) or (
            not allow_brackets and ('[' in value or ']' in value)):
        raise ContentError('{}: invalid length or markup'.format(field))
    return value


def _normalized(text):
    return ' '.join(text.casefold().split())


def parse(raw, difficulty, subject=None):
    """Validate raw JSON completely, then normalize once. Never guess missing answers."""
    if difficulty not in COUNTS or not isinstance(raw, dict) or type(raw.get('schema_version')) is not int:
        raise ContentError('Invalid pack or difficulty')
    if raw['schema_version'] != 1:
        raise ContentError('Unsupported schema version')
    if raw.get('status') == 'unavailable':
        raise Unavailable(_text(raw.get('reason'), 'reason', LIMITS['reason']))
    if raw.get('status') != 'ready' or raw.get('difficulty') != difficulty:
        raise ContentError('Invalid status or difficulty')
    identity = raw.get('identity')
    if not isinstance(identity, dict):
        raise ContentError('Missing identity')
    title = _text(identity.get('title'), 'identity.title', 200, allow_brackets=True)
    if identity.get('media_type') not in ('movie', 'series'):
        raise ContentError('Invalid media type')
    for key in ('year', 'season_number', 'episode_number'):
        value = identity.get(key)
        if value is not None and (type(value) is not int or value < 0):
            raise ContentError('Invalid identity.' + key)
    if not isinstance(identity.get('episode_title', ''), str):
        raise ContentError('Invalid episode title')
    if subject is not None:
        if _normalized(title) != _normalized(subject.title):
            raise ContentError('Different work title; identity must echo the requested title')
        media = 'series' if subject.media_type in ('tv', 'episode') else subject.media_type
        if identity['media_type'] != media:
            raise ContentError('Different work type')
        if _normalized(identity.get('episode_title', '')) != _normalized(subject.episode_title or ''):
            raise ContentError('Different episode title')
        for key in ('year', 'season_number', 'episode_number'):
            expected = getattr(subject, key, None)
            if expected not in ('', None) and identity.get(key) != int(expected):
                raise ContentError('Different identity.' + key)
        for key in ('season_number', 'episode_number'):
            if getattr(subject, key, None) is None and identity.get(key) is not None:
                raise ContentError('Unexpected scope.' + key)
    items = raw.get('scenes')
    count = COUNTS[difficulty]
    if not isinstance(items, list) or len(items) != count:
        raise ContentError('Expected {} scenes'.format(count))
    scenes, seen = [], set()
    for index, item in enumerate(items):
        if not isinstance(item, dict):
            raise ContentError('Invalid scene')
        chapter = index // (count // CHAPTERS) + 1
        if type(item.get('chapter')) is not int or item['chapter'] != chapter:
            raise ContentError('Invalid chapter sequence')
        setup = _text(item.get('setup'), 'setup', LIMITS['setup'])
        question = _text(item.get('question'), 'question', LIMITS['question'])
        reveal = _text(item.get('reveal'), 'reveal', LIMITS['reveal'])
        options = item.get('options')
        if not isinstance(options, dict) or set(options) != set(LETTERS):
            raise ContentError('Expected A-D options')
        values = tuple(_text(options[letter], 'option', LIMITS['option']) for letter in LETTERS)
        if len({_normalized(value) for value in values}) != 4:
            raise ContentError('Duplicate options')
        correct = item.get('correct')
        if correct not in tuple(LETTERS):
            raise ContentError('Invalid correct letter')
        normalized = _normalized(question)
        if normalized in seen:
            raise ContentError('Duplicate question')
        seen.add(normalized)
        scenes.append(Scene(chapter, setup, question, values, LETTERS.index(correct), reveal))
    ending = _text(raw.get('ending'), 'ending', LIMITS['ending'])
    return Pack(difficulty, tuple(scenes), ending)


def demo_available(demo_id):
    """Only complete local fixtures are selectable; never replace a missing demo with AI."""
    return isinstance(demo_id, str) and bool(re.fullmatch(r'[a-z0-9-]+', demo_id)) and all(
        os.path.isfile(os.path.join(DEMO_DIR, demo_id, difficulty + '.json')) for difficulty in COUNTS)


class DemoSource:
    name = 'demo'

    def __init__(self, demo_id):
        self.demo_id = demo_id

    def scoped_subject(self, subject):
        """Story-only episode metadata; the existing Millionaire subject/catalog stays intact."""
        if subject.season_number is not None or subject.episode_number is not None or subject.episode_title:
            return subject
        with open(os.path.join(DEMO_DIR, 'scopes.json'), encoding='utf-8') as handle:
            scope = json.load(handle).get(self.demo_id, {})
        return subject._replace(**scope)

    def prepare(self, subject, difficulty):
        if difficulty not in COUNTS or not demo_available(self.demo_id):
            raise Unavailable('This offline story has not been authored.')
        with open(os.path.join(DEMO_DIR, self.demo_id, difficulty + '.json'), encoding='utf-8') as handle:
            return parse(json.load(handle), difficulty, self.scoped_subject(subject))
