#!/usr/bin/env python3
"""Checks a cast-selection.json against seamless-cast-selection-v1, draft 3 (Python 3.8+, standard library only).
Usage: python3 -I check_cast_selection.py cast-selection.json [--schema seamless-cast-selection-v1.json]
Without --schema it looks next to this script, then one directory up.
Exit 0 valid (warnings may follow), 1 invalid (numbered errors with a fix), 2 unreadable. Same as check-cast-selection.mjs."""
import json, os, re, sys
SHORT_MS = 1500

def kind(v):
    if v is None: return 'null'
    if isinstance(v, bool): return 'boolean'
    if isinstance(v, int) or (isinstance(v, float) and v.is_integer()): return 'integer'
    return {float: 'number', str: 'string', list: 'array', dict: 'object'}[type(v)]

def schema_errors(s, v, at='$', out=None):
    out = [] if out is None else out
    add = lambda c, m, f: out.append((c, at, m, f))
    if 'const' in s and (v != s['const'] or kind(v) != kind(s['const'])):
        add('CS001', 'must be %s' % json.dumps(s['const']), 'set it to %s' % json.dumps(s['const'])); return out
    if 'enum' in s and v not in s['enum']: add('CS002', 'must be one of ' + ', '.join(s['enum']), 'use one of the listed values'); return out
    k = kind(v)
    if 'type' in s:
        types = s['type'] if isinstance(s['type'], list) else [s['type']]
        if not any(k == t or (t == 'number' and k == 'integer') for t in types):
            t = ' or '.join(types); add('CS003', 'must be %s, not %s' % (t, k), 'write %s %s' % ('an' if t[0] in 'aeiou' else 'a', t)); return out
    if k == 'string':
        if 'minLength' in s and len(v) < s['minLength']: add('CS004', 'is shorter than %d characters' % s['minLength'], 'fill it in')
        if 'maxLength' in s and len(v) > s['maxLength']: add('CS005', 'is longer than %d characters' % s['maxLength'], 'shorten it')
        if 'pattern' in s and not re.search(s['pattern'], v, re.ASCII):
            add('CS006', '"%s" does not match %s' % (v[:80], s['pattern']), 'it must be: ' + s['description'] if 'description' in s else 'correct its format')
    if k in ('integer', 'number'):
        if 'minimum' in s and v < s['minimum']: add('CS007', '%s is below %s' % (v, s['minimum']), 'use %s or more' % s['minimum'])
        if 'maximum' in s and v > s['maximum']: add('CS008', '%s is above %s' % (v, s['maximum']), 'use %s or less' % s['maximum'])
    if k == 'array':
        if 'minItems' in s and len(v) < s['minItems']: add('CS009', 'has %d items, needs at least %d' % (len(v), s['minItems']), 'add the missing items')
        if 'maxItems' in s and len(v) > s['maxItems']:
            add('CS010', 'has %d items, at most %d allowed' % (len(v), s['maxItems']), 'export an explicit subset of at most %d shots (several files if needed); never truncate silently' % s['maxItems'] if at == '$.shots' else 'remove items')
        for i, x in enumerate(v if 'items' in s else []): schema_errors(s['items'], x, '%s[%d]' % (at, i), out)
    if k == 'object':
        props, extra = s.get('properties', {}), s.get('additionalProperties', True)
        for r in s.get('required', []):
            if r not in v: add('CS011', 'is missing "%s"' % r, 'add "%s"' % r)
        if 'maxProperties' in s and len(v) > s['maxProperties']: add('CS012', 'has %d keys, at most %d' % (len(v), s['maxProperties']), 'remove keys')
        for key, x in v.items():
            if key in props: schema_errors(props[key], x, '%s.%s' % (at, key), out)
            elif extra is False: out.append(('CS013', '%s.%s' % (at, key), 'is not a field of this schema', 'remove it (check its spelling)'))
            elif isinstance(extra, dict): schema_errors(extra, x, '%s.%s' % (at, key), out)
    return out

def frame_ms(frame, num, den): return (int(frame) * 1000 * int(den)) // int(num)

def rule_errors(doc):
    out, warn, ep, seen = [], [], doc['episode'], {}
    num, den, runtime, clock = ep['fps_num'], ep['fps_den'], ep['runtime_ms'], ep['source_clock']
    if clock['kind'] == 'native-conformed' and 'origin_ms' not in clock:
        out.append(('CS112', '$.episode.source_clock', 'is native-conformed but has no origin_ms', 'add origin_ms, the episode time of source frame 0'))
    if clock['kind'] == 'capture':
        warn.append(('CS201', '$.episode.source_clock', 'is "capture": source frames are not mapped to episode time, so the file is not ready for generation or packaging',
                     'conform the source to the episode\'s native rate, re-export with kind "native-conformed" and origin_ms (the batch runner refuses "capture")'))
    if not clock['cadence_verified']:
        warn.append(('CS202', '$.episode.source_clock', 'cadence_verified is false', 'step through the source to check every frame is a new picture at the stated rate before generating'))
    for i, s in enumerate(doc['shots']):
        at, box = '$.shots[%d] (%s)' % (i, s['shot_id']), s.get('person_box')
        if s['shot_id'] in seen: out.append(('CS101', at, 'shot_id repeats shots[%d]' % seen[s['shot_id']], 'give every shot its own shot_id'))
        else: seen[s['shot_id']] = i
        if not s['start_frame'] < s['resume_frame']:
            out.append(('CS102', at, 'start_frame %d is not before resume_frame %d' % (s['start_frame'], s['resume_frame']), 'Resume is the first frame after the shot, so it must be larger than Start'))
        for k in ('start', 'resume'):
            want = frame_ms(s[k + '_frame'], num, den)
            if s[k + '_ms'] != want:
                out.append(('CS103', at, '%s_ms is %d, but floor(%d * 1000 * %d / %d) = %d' % (k, s[k + '_ms'], s[k + '_frame'], den, num, want), 'set %s_ms to %d (frames are the truth)' % (k, want)))
        if s['resume_ms'] > runtime: out.append(('CS104', at, "resume_ms %d is after the episode's runtime_ms %d" % (s['resume_ms'], runtime), 'check the frame rate and the frames'))
        elif clock['kind'] == 'native-conformed' and kind(clock.get('origin_ms')) == 'integer' and clock['origin_ms'] + s['resume_ms'] > runtime:
            out.append(('CS104', at, "origin_ms %d + resume_ms %d = %d is after the episode's runtime_ms %d" % (clock['origin_ms'], s['resume_ms'], clock['origin_ms'] + s['resume_ms'], runtime), 'check origin_ms, the frame rate and the frames'))
        if box is not None and (box['frame'] < s['start_frame'] or box['frame'] >= s['resume_frame']):
            out.append(('CS105', at, 'person_box.frame %d is outside [%d, %d)' % (box['frame'], s['start_frame'], s['resume_frame']), 'put the box on a frame inside the shot'))
        if s['selected'] and box is None: out.append(('CS111', at, 'is selected but has no person_box', 'add the observed person box, or set selected to false with a skip_reason'))
        if s['selected'] and s['other_people'] is None: out.append(('CS113', at, 'is selected but other_people is null', 'count the other people on the anchor frame (0 or more)'))
        if s['selected'] and 'skip_reason' in s: out.append(('CS106', at, 'a selected shot has a skip_reason', 'remove skip_reason, or set selected to false'))
        if not s['selected'] and 'skip_reason' not in s: out.append(('CS107', at, 'an unselected shot has no skip_reason', 'add skip_reason (for example "short" or "human-rejected")'))
    sel = sorted([(s['start_frame'], i, s) for i, s in enumerate(doc['shots']) if s['selected'] and s['start_frame'] < s['resume_frame']], key=lambda x: (x[0], x[1]))
    for (_, ai, a), (_, bi, b) in zip(sel, sel[1:]):
        if a['resume_frame'] > b['start_frame']:
            out.append(('CS108', '$.shots[%d] (%s)' % (bi, b['shot_id']), 'overlaps shots[%d] (%s): frames %d–%d are in both' % (ai, a['shot_id'], b['start_frame'], min(a['resume_frame'], b['resume_frame']) - 1),
                        "selected shots never share a frame: the first shot's Resume must be at or before the second shot's Start"))
    groups = []  # contiguous selected groups, for the 1.5 s delivery minimum
    for x in sel:
        if groups and groups[-1][-1][2]['resume_frame'] == x[2]['start_frame']: groups[-1].append(x)
        else: groups.append([x])
    for g in groups:
        total = g[-1][2]['resume_ms'] - g[0][2]['start_ms']
        for _, i, s in g:
            d = s['resume_ms'] - s['start_ms']
            if d >= SHORT_MS or total >= SHORT_MS: continue
            at = '$.shots[%d] (%s)' % (i, s['shot_id'])
            if len(g) == 1:
                warn.append(('CS109', at, 'lasts %d ms, under the 1.5 s minimum, and touches no other selected shot, so it needs original context at delivery' % d,
                             'nothing to change here: the delivery plan appends untouched original frames after Resume (or prepends them before Start) to reach 1.5 s, or keeps the shot original'))
            else:
                warn.append(('CS110', at, 'lasts %d ms, and its contiguous selected group (%s) lasts only %d ms, under the 1.5 s minimum, so it needs original context at delivery' % (d, ', '.join(x[2]['shot_id'] for x in g), total),
                             'nothing to change here: the delivery plan adds untouched original frames to the group, or keeps it original'))
    return out, warn

def check_text(text, schema):
    try: doc = json.loads(text)
    except ValueError as e: return [('CS000', '$', 'is not valid JSON (%s)' % e, 'fix the JSON syntax')], []
    errs = schema_errors(schema, doc)
    return (errs, []) if errs else rule_errors(doc)

def plural(n, w): return '%d %s%s' % (n, w, '' if n == 1 else 's')

def report(errs, warns):
    lines = ['%d. %s%s %s %s. Fix: %s.' % (n + 1, tag, c, at, m, f) for n, (tag, (c, at, m, f)) in enumerate([('', e) for e in errs] + [('WARNING ', w) for w in warns])]
    lines.append('INVALID: %s, %s' % (plural(len(errs), 'error'), plural(len(warns), 'warning')) if errs else 'VALID: seamless-cast-selection-v1 draft 3, %s' % plural(len(warns), 'warning'))
    return '\n'.join(lines)

def default_schema(d):
    for p in (os.path.join(d, 'seamless-cast-selection-v1.json'), os.path.join(d, '..', 'seamless-cast-selection-v1.json')):
        if os.path.exists(p): return p
    return os.path.join(d, 'seamless-cast-selection-v1.json')

if __name__ == '__main__':
    args, sch = sys.argv[1:], None
    if '--schema' in args:
        k = args.index('--schema'); sch = args[k + 1] if k + 1 < len(args) else None; del args[k:k + 2]
    if not args or ('--schema' in sys.argv[1:] and not sch): print('usage: python3 -I check_cast_selection.py cast-selection.json [--schema schema.json]', file=sys.stderr); sys.exit(2)
    sch = sch or (args[1] if len(args) > 1 else default_schema(os.path.dirname(os.path.abspath(__file__))))
    try:
        if os.path.getsize(args[0]) > 5_000_000: raise ValueError('larger than 5000000 bytes')
        text = open(args[0], encoding='utf-8').read(); schema = json.load(open(sch, encoding='utf-8'))
    except (OSError, ValueError) as e: print('cannot read: %s' % e, file=sys.stderr); sys.exit(2)
    errs, warns = check_text(text, schema)
    print(report(errs, warns)); sys.exit(1 if errs else 0)
