"""Callable authored-story contract; consistency checks never accept actual media."""
from __future__ import annotations
import argparse
from copy import deepcopy
import hashlib
import json
from pathlib import Path


class StoryError(ValueError):
    pass


def need(ok, message):
    if not ok:
        raise StoryError(message)


def unique(rows, label):
    need(isinstance(rows, list), f'{label} must be a list')
    ids = [r.get('id') for r in rows]
    need(all(isinstance(i, str) and i for i in ids), f'{label}: missing ID')
    need(len(set(ids)) == len(ids), f'{label}: duplicate ID')
    return {r['id']: r for r in rows}


def develop_reference_story(source_model, inputs, realizations, *, root=None):
    root = Path(root) if root else Path.cwd()
    source = source_model.get('source', {})
    need(source.get('path') and len(source.get('sha256', '')) == 64, 'source identity missing')
    original_path = root / source['path']
    need(original_path.is_file(), 'source file missing')
    need(hashlib.sha256(original_path.read_bytes()).hexdigest() == source['sha256'], 'source file stale')
    duration = source.get('duration_s', 0)
    need(isinstance(duration, (float, int)) and duration > 0, 'source duration missing')
    turns = unique(source_model.get('turns'), 'source turns')
    need(turns, 'source has no observed turns')
    for t in turns.values():
        need(0 <= t['start_s'] < t['end_s'] <= duration, f"source range invalid: {t['id']}")
        for field in ('observed', 'function', 'visual_grammar'):
            need(t.get(field), f"source {t['id']} lacks {field}")
    for field in ('product', 'market', 'audience', 'affected_activity', 'roles', 'initial_state', 'valid_use', 'observable_change', 'local_world', 'next_action'):
        need(inputs.get(field), f'input missing: {field}')
    need(inputs.get('fidelity') in ('close', 'adapted'), 'declare close or adapted fidelity')
    claims = unique(inputs.get('claims', []), 'claims')
    for c in claims.values():
        need(c.get('status') in ('seller-asserted', 'independently-supported', 'fictional-event'), 'unknown claim status')
        need(c.get('statement') and c.get('evidence'), f"unbound claim {c['id']}")
    unique(realizations, 'realizations'); need(realizations, 'no realization')
    order = list(turns)
    used = [r.get('source_turn') for r in realizations]
    need(all(t in turns for t in used), 'unknown source turn')
    omitted = set(turns) - set(used)
    departures = inputs.get('departures', {})
    if inputs['fidelity'] == 'close':
        need(not omitted, f'close adaptation omits {sorted(omitted)}')
        need([order.index(t) for t in used] == sorted(order.index(t) for t in used), 'close adaptation changes source order')
    else:
        for t in omitted:
            need(departures.get(t), f'unexplained omission: {t}')
        for a, b in zip(used, used[1:]):
            if order.index(a) > order.index(b):
                need(departures.get(b), f'unexplained reorder: {b}')
    state = set(inputs['initial_state'])
    need(all(isinstance(s, str) and s for s in state), 'invalid initial state')
    loops, trace, mapping = {}, [], []
    for scene in realizations:
        sid = scene['id']
        for field in ('event', 'dialogue', 'observable_change', 'visual', 'acting', 'source_fragments'):
            need(scene.get(field), f'{sid} missing {field}')
        for ref in scene['source_fragments']:
            path = root / ref['path']
            need(path.is_file(), f'{sid} source fragment missing: {path}')
            need(hashlib.sha256(path.read_bytes()).hexdigest() == ref['sha256'], f'{sid} source fragment stale: {path}')
        for field in ('requires', 'adds', 'removes', 'opens', 'answers', 'claims'):
            need(isinstance(scene.get(field), list), f'{sid} missing list {field}')
        required, added, removed = (set(scene[f]) for f in ('requires', 'adds', 'removes'))
        need(required <= state, f'{sid} unearned prerequisites: {sorted(required-state)}')
        need(removed <= state, f'{sid} removes absent states: {sorted(removed-state)}')
        need(not added & removed, f'{sid} both adds and removes state')
        before = set(state); state = (state - removed) | added
        need(state != before, f'{sid} has no state change')
        for claim in scene['claims']:
            need(claim in claims, f'{sid} undeclared claim: {claim}')
        for opened in scene['opens']:
            qid = opened.get('id')
            need(qid and opened.get('question'), f'{sid} malformed question')
            need(qid not in loops, f'duplicate question: {qid}')
            loops[qid] = {**deepcopy(opened), 'opened_by': sid, 'answered_by': None}
        for qid in scene['answers']:
            need(qid in loops, f'{sid} answers unopened question: {qid}')
            need(not loops[qid]['answered_by'], f'{sid} repeats closed question: {qid}')
            evidence = scene.get('answer_evidence', {}).get(qid)
            need(evidence, f'{sid} closes {qid} without observable evidence')
            loops[qid].update(answered_by=sid, answer_evidence=evidence)
        trace.append({'scene': sid, 'before': sorted(before), 'after': sorted(state), 'observable_change': scene['observable_change']})
        t = turns[scene['source_turn']]
        mapping.append({'scene': sid, 'source_turn': t['id'], 'source_range_s': [t['start_s'], t['end_s']], 'source_function': t['function'], 'local_event': scene['event'], 'departure': scene.get('departure'), 'source_visual_grammar': t['visual_grammar'], 'local_visual': scene['visual']})
    for qid, loop in loops.items():
        if not loop['answered_by']:
            reason = inputs.get('residual_questions', {}).get(qid)
            need(reason, f'unearned ending: unanswered question {qid}')
            loop['unresolved_reason'] = reason
    payload = {'source_model': source_model, 'inputs': inputs, 'realizations': realizations}
    digest = hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True).encode()).hexdigest()
    return {'schema': 'adfactory.reference-story/v1', 'call_sha256': digest,
            'source': deepcopy(source), 'input_summary': deepcopy(inputs), 'scenes': deepcopy(realizations),
            'source_mapping': mapping, 'state_trace': trace, 'questions': list(loops.values()),
            'production_status': 'unmade', 'checks': {'source_coverage': len(set(used)), 'source_turns': len(turns),
                'causal_prerequisites': 'pass', 'claim_references': 'pass', 'question_accounting': 'pass'},
            'limits': ['Authored logic only; semantic truth and emotional quality require editorial review.',
                       'No speech, picture, efficacy, retention or final-film acceptance is implied.']}


def main():
    parser = argparse.ArgumentParser(); parser.add_argument('call', type=Path)
    parser.add_argument('--output', type=Path, required=True)
    args = parser.parse_args(); call = json.loads(args.call.read_text())
    result = develop_reference_story(call['source_model'], call['inputs'], call['realizations'])
    args.output.parent.mkdir(parents=True, exist_ok=True)
    args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + '\n')
    print(json.dumps({'scenes': len(result['scenes']), 'questions': len(result['questions']),
                      'checks': result['checks'], 'production_status': result['production_status']}))


if __name__ == '__main__':
    main()
