"""S07 author tutorial helpers, NOT the Blackmagic SDK.

Plan-only by default. Opaque IDs; no all-queue start, preset changes, directory
creation, project save, auto retry or rollback. StopRendering is project-wide.
Selected cleanup is deliberately sequential and may partially complete.
Matching observations are not a lock or an atomic snapshot. Poll deadlines
cannot interrupt a blocked native API call. Use an idle, local test project
with one operator; remote/background workflows are outside mutation scope.
"""
from __future__ import annotations
import argparse
import copy
import hashlib
import json
import math
import time

MAX_JOBS = 1000      # Tutorial bounds, not Resolve limits.
MAX_DELETE = 25
MAX_POLLS = 10000
LOCAL_STATES = frozenset({'Ready', 'Rendering', 'Complete', 'Cancelled', 'Failed'})
CANCELLED = frozenset({'Cancelled', 'Background Render Cancelled', 'Remote Render Cancelled'})
EXTERNAL = frozenset({'Ready for background render', 'Ready to remotely render'})
VOLATILE = frozenset({'JobStatus', 'CompletionPercentage', 'TimeTakenToRenderInMs',
                     'EstimatedTimeRemainingInMs', 'Error'})


class PreflightError(RuntimeError):
    """No mutation has been sent by the failing preflight step."""


def read(obj, method, *args):
    fn = getattr(obj, method, None)
    if not callable(fn):
        raise PreflightError('Missing method: ' + method)
    try:
        return fn(*args)
    except Exception as exc:
        raise PreflightError('Read failed: ' + method + ' (' + type(exc).__name__ + ')') from exc


def text(value, name):
    if not isinstance(value, str) or not value.strip() or len(value) > 4096:
        raise PreflightError('Invalid ' + name + '; expected an exact nonempty string')
    if any(ord(c) < 32 for c in value):
        raise PreflightError('Control characters in ' + name)
    try:
        value.encode('utf-8')
    except UnicodeError as exc:
        raise PreflightError('Invalid Unicode in ' + name) from exc
    return value  # No trim, lower, int coercion or guessed aliases.


def boolean(value, name):
    if type(value) is not bool:
        raise PreflightError(name + ' must be bool; None is not False')
    return value


def json_value(value, depth=0, budget=None):
    if budget is None:
        budget = [200000]
    budget[0] -= 1
    if depth > 8 or budget[0] < 0:
        raise PreflightError('API result exceeds tutorial depth/size bounds')
    if value is None or type(value) in (bool, int):
        return value
    if isinstance(value, str) and len(value) <= 32768:
        try:
            value.encode('utf-8')
        except UnicodeError as exc:
            raise PreflightError('Invalid API Unicode') from exc
        return value
    if type(value) is float and math.isfinite(value):
        return value
    if isinstance(value, list):
        return [json_value(v, depth + 1, budget) for v in value]
    if isinstance(value, dict) and all(isinstance(k, str) for k in value):
        return {k: json_value(v, depth + 1, budget) for k, v in value.items()}
    raise PreflightError('Unsupported API JSON value; no default=str conversion')


def digest(value):
    data = json.dumps(value, ensure_ascii=False, sort_keys=True, allow_nan=False,
                      separators=(',', ':')).encode('utf-8')
    return hashlib.sha256(data).hexdigest()


def classify_status(raw):
    """Preserve unknown status. Progress/global idle alone never means success."""
    if not isinstance(raw, dict):
        raise PreflightError('GetRenderJobStatus must return a dict, not None')
    raw = json_value(raw)
    name = raw.get('JobStatus')
    progress = raw.get('CompletionPercentage')
    issues = []
    if not isinstance(name, str) or not name.strip():
        issues.append('missing_or_invalid_JobStatus')
    if progress is not None and (type(progress) not in (int, float)
                                or not math.isfinite(progress) or not 0 <= progress <= 100):
        issues.append('invalid_CompletionPercentage')
    category = {'Ready': 'ready', 'Rendering': 'rendering', 'Complete': 'complete',
                'Failed': 'failed'}.get(name, 'unknown') if isinstance(name, str) else 'unknown'
    if isinstance(name, str) and name in CANCELLED:
        category = 'cancelled'
    if isinstance(name, str) and name in EXTERNAL:
        category = 'external'
    if category == 'complete' and progress is not None and progress != 100:
        issues.append('complete_progress_conflict')
    if category == 'complete' and raw.get('Error') not in (None, ''):
        issues.append('complete_error_conflict')
    if issues:
        category = 'unknown'
    return {'category': category, 'job_status': name, 'completion_percentage': progress,
            'issues': issues, 'raw': raw, 'file_verified': False}


def target(resolve, project_id=None, timeline_id=None, need_timeline=False):
    manager = read(resolve, 'GetProjectManager')
    if not manager:
        raise PreflightError('No Project Manager')
    project = read(manager, 'GetCurrentProject')
    if not project:
        raise PreflightError('No current project')
    pid = text(read(project, 'GetUniqueId'), 'project ID')
    if project_id is not None and pid != text(project_id, 'expected project ID'):
        raise PreflightError('Active project changed or differs from --project-id')
    tid = None
    if need_timeline:
        timeline = read(project, 'GetCurrentTimeline')
        if not timeline:
            raise PreflightError('No current timeline for AddRenderJob')
        tid = text(read(timeline, 'GetUniqueId'), 'timeline ID')
        if timeline_id is not None and tid != text(timeline_id, 'expected timeline ID'):
            raise PreflightError('Active timeline differs from --timeline-id')
    return project, pid, tid


def jobs(project):
    raw = read(project, 'GetRenderJobList')
    if not isinstance(raw, list) or len(raw) > MAX_JOBS:
        raise PreflightError('Expected bounded list from GetRenderJobList; None is not []')
    result = []
    seen = set()
    for row in raw:
        if not isinstance(row, dict):
            raise PreflightError('RenderJobInfo must be a dict')
        item = json_value(row)
        jid = text(item.get('JobId'), 'JobId')
        if jid in seen:
            raise PreflightError('Duplicate JobId; cannot address a unique queue item')
        seen.add(jid)
        result.append(item)
    return result


def queue_structure(rows):
    return [{k: v for k, v in r.items() if k not in VOLATILE} for r in rows]


def snapshot(resolve, project_id=None, timeline_id=None, need_timeline=False):
    """Two queue reads bracket status reads; this is still non-atomic."""
    project, pid, tid = target(resolve, project_id, timeline_id, need_timeline)
    rows = jobs(project)
    active = boolean(read(project, 'IsRenderingInProgress'), 'IsRenderingInProgress')
    states = {r['JobId']: classify_status(read(project, 'GetRenderJobStatus', r['JobId'])) for r in rows}
    if queue_structure(jobs(project)) != queue_structure(rows):
        raise PreflightError('Queue structure changed during observation')
    if boolean(read(project, 'IsRenderingInProgress'), 'IsRenderingInProgress') != active:
        raise PreflightError('Rendering activity changed during observation')
    target(resolve, pid, tid, need_timeline)
    return project, {'project_id': pid, 'timeline_id': tid, 'is_rendering': active,
                     'jobs': rows, 'statuses': states}


def stable(snapshot_value):
    return {'project_id': snapshot_value['project_id'], 'timeline_id': snapshot_value['timeline_id'],
            'is_rendering': snapshot_value['is_rendering'],
            'jobs': queue_structure(snapshot_value['jobs']),
            'statuses': {jid: {'job_status': v['job_status'], 'category': v['category'],
                               'issues': v['issues']} for jid, v in snapshot_value['statuses'].items()}}


def public_snapshot(s, details=False):
    out = {'project_id': s['project_id'], 'timeline_id': s['timeline_id'],
           'is_rendering': s['is_rendering'], 'count': len(s['jobs']),
           'job_ids': [r['JobId'] for r in s['jobs']], 'observation_hash': digest(stable(s)),
           'statuses': {k: {a: b for a, b in v.items() if a != 'raw'} for k, v in s['statuses'].items()},
           'atomic': False, 'file_verified': False}
    if details:
        out['raw_jobs'] = copy.deepcopy(s['jobs'])
        out['raw_statuses'] = {k: copy.deepcopy(v['raw']) for k, v in s['statuses'].items()}
    return out


def inspect_queue(resolve, project_id=None, details=False):
    _, s = snapshot(resolve, project_id)
    return {'status': 'observed', **public_snapshot(s, details)}


def status_once(resolve, project_id, job_id, details=False):
    jid = text(job_id, 'job ID')
    project, pid, _ = target(resolve, project_id)
    rows = jobs(project)
    matches = [r for r in rows if r['JobId'] == jid]
    if not matches:
        return {'status': 'not_found', 'project_id': pid, 'job_id': jid,
                'file_verified': False, 'repeat_mutation': False}
    state = classify_status(read(project, 'GetRenderJobStatus', jid))
    active = boolean(read(project, 'IsRenderingInProgress'), 'IsRenderingInProgress')
    after = [r for r in jobs(project) if r['JobId'] == jid]
    target(resolve, pid)
    if queue_structure(after) != queue_structure(matches):
        raise PreflightError('Selected job changed or disappeared during status read')
    result = {'status': 'observed', 'project_id': pid, 'job_id': jid,
              'is_rendering': active, **{k: v for k, v in state.items() if k != 'raw'}}
    if details:
        result['raw_status'] = state['raw']
        result['raw_job'] = matches[0]
    return result


def bounds(timeout, interval, max_polls):
    if type(timeout) not in (int, float) or not math.isfinite(timeout) or not 0 < timeout <= 86400:
        raise PreflightError('timeout must be finite, > 0 and <= 86400 seconds')
    if type(interval) not in (int, float) or not math.isfinite(interval) or not 0.1 <= interval <= 60:
        raise PreflightError('interval must be finite and 0.1..60 seconds')
    if type(max_polls) is not int or not 1 <= max_polls <= MAX_POLLS:
        raise PreflightError('max_polls must be an integer in 1..10000')


def wait_job(resolve, project_id, job_id, timeout=120, interval=1, max_polls=600,
             details=False, clock=time.monotonic, sleep=time.sleep):
    """Read-only soft deadline, including Ready. It never stops/restarts Resolve."""
    text(project_id, 'project ID')
    text(job_id, 'job ID')
    bounds(timeout, interval, max_polls)
    begin = clock()
    deadline = begin + timeout
    last = None
    samples = 0
    def result(outcome):
        return {'status': outcome, 'project_id': project_id, 'job_id': job_id,
                'samples': samples, 'elapsed_seconds': max(0, clock() - begin),
                'last_observation': last, 'file_verified': False,
                'render_stopped_by_script': False, 'repeat_mutation': False,
                'timeout_kind': 'soft; cannot interrupt a blocked native call'}
    try:
        while samples < max_polls:
            if clock() >= deadline:
                return result('timeout_unknown')
            last = status_once(resolve, project_id, job_id, details)
            samples += 1
            # A native call may have returned after the deadline; retain evidence,
            # but do not claim a within-budget successful wait.
            if clock() >= deadline:
                return result('timeout_unknown')
            if last['status'] == 'not_found':
                return result('job_missing_unknown')
            category = last['category']
            if category == 'complete':
                return result('job_complete_file_unverified')
            if category in ('failed', 'cancelled'):
                return result('job_' + category)
            if category in ('unknown', 'external'):
                return result('state_unknown' if category == 'unknown' else 'external_mode_not_supported')
            sleep(min(interval, max(0, deadline - clock())))
    except KeyboardInterrupt:
        return result('observer_interrupted_render_unchanged')
    except Exception as exc:
        out = result('observation_failed_unknown')
        out['error_type'] = type(exc).__name__
        return out
    return result('poll_limit_unknown')


def selected_ids(ids, allow_empty=False):
    if not isinstance(ids, list) or len(ids) > MAX_DELETE or (not ids and not allow_empty):
        raise PreflightError('Supply 1..25 explicit job IDs; no wildcard or implicit all')
    values = [text(x, 'job ID') for x in ids]
    if len(set(values)) != len(values):
        raise PreflightError('Duplicate selected job IDs')
    return values


def local_scope(s, allow_running=False):
    for state in s['statuses'].values():
        if not isinstance(state['job_status'], str) or state['job_status'] not in LOCAL_STATES or state['issues']:
            raise PreflightError('Unknown or background/remote state: mutation refused')
    rendering = [k for k, v in s['statuses'].items() if v['category'] == 'rendering']
    if not allow_running and (s['is_rendering'] or rendering):
        raise PreflightError('Queue must be idle; this script will not stop it first')
    if allow_running and bool(rendering) != s['is_rendering']:
        raise PreflightError('Global rendering flag and job states disagree')
    return rendering


def plan_action(resolve, action, project_id=None, job_ids=None, timeline_id=None):
    if action not in ('add', 'start', 'delete', 'cleanup', 'stop'):
        raise PreflightError('Unknown action')
    ids = selected_ids(job_ids or [], allow_empty=action in ('add', 'stop'))
    if action == 'add' and ids:
        raise PreflightError('AddRenderJob does not accept an existing job ID')
    if action in ('start', 'delete') and len(ids) != 1:
        raise PreflightError('Exactly one selected job is required')
    project, s = snapshot(resolve, project_id, timeline_id, action == 'add')
    rendering = local_scope(s, allow_running=action == 'stop')
    known = {r['JobId'] for r in s['jobs']}
    if any(jid not in known for jid in ids):
        raise PreflightError('A selected ID is not present in this project queue')
    if action == 'start' and s['statuses'][ids[0]]['job_status'] != 'Ready':
        raise PreflightError('This tutorial starts only an existing local Ready job; no restart')
    if action == 'cleanup' and any(s['statuses'][jid]['category'] not in ('complete', 'failed', 'cancelled') for jid in ids):
        raise PreflightError('Cleanup accepts selected terminal jobs only; use single-delete for Ready')
    if action == 'stop' and ids and set(ids) != set(rendering):
        raise PreflightError('StopRendering is not job-scoped; supplied IDs must cover every observed Rendering job')
    plan = {'action': action, 'project_id': s['project_id'], 'timeline_id': s['timeline_id'],
            'selected_job_ids': ids, 'observed_rendering_ids': rendering,
            'scope': 'project-wide stop' if action == 'stop' else 'explicit IDs' if ids else 'one new job',
            'observed_state_hash': digest(stable(s)),
            'settings_fully_read_back': False, 'atomic': False}
    plan['plan_hash'] = digest(plan)
    return project, s, plan


def mutate(resolve, action, project_id=None, job_ids=None, timeline_id=None,
           apply=False, expect_plan=None, test_copy=False, settings_reviewed=False,
           allow_project_stop=False, details=False):
    project, before, plan = plan_action(resolve, action, project_id, job_ids, timeline_id)
    report = {'status': 'plan', 'plan': plan, 'before': public_snapshot(before, details),
              'mutations_sent': 0, 'verified_deleted_ids': [], 'file_verified': False,
              'retry_allowed': False}
    if not apply:
        return report
    text(project_id, '--project-id required for apply')
    if not test_copy or not expect_plan or expect_plan != plan['plan_hash']:
        raise PreflightError('Apply needs --test-copy and the exact current --expect-plan')
    if action == 'add':
        text(timeline_id, '--timeline-id required for AddRenderJob apply')
    if action in ('add', 'start') and not settings_reviewed:
        raise PreflightError('Review Deliver/job settings and acknowledge --settings-reviewed')
    if action == 'stop' and not allow_project_stop:
        raise PreflightError('Stop is project-wide; explicit --allow-project-stop required')
    method = {'add': 'AddRenderJob', 'start': 'StartRendering', 'stop': 'StopRendering',
              'delete': 'DeleteRenderJob', 'cleanup': 'DeleteRenderJob'}[action]
    # Resolve callable BEFORE any sent marker; the same capability is reused.
    fn = getattr(project, method, None)
    if not callable(fn):
        raise PreflightError('Missing mutating method: ' + method)
    _, current, current_plan = plan_action(resolve, action, project_id, job_ids, timeline_id)
    if current_plan != plan:
        raise PreflightError('Plan changed immediately before mutation; no call sent')
    if action == 'stop' and not current['is_rendering']:
        report['status'] = 'already_idle_no_call'
        return report
    try:
        if action in ('delete', 'cleanup'):
            expected = stable(current)
            for jid in plan['selected_job_ids']:
                _, now = snapshot(resolve, project_id)
                local_scope(now)
                if stable(now) != expected:
                    report['status'] = 'partial_or_changed_before_next_call'
                    return report
                report['attempted_job_id'] = jid
                report['mutations_sent'] += 1
                returned = fn(jid)  # One attempt per explicitly selected ID.
                _, after = snapshot(resolve, project_id)
                expected['jobs'] = [r for r in expected['jobs'] if r['JobId'] != jid]
                expected['statuses'].pop(jid, None)
                report['after'] = public_snapshot(after, details)
                if returned is not True or stable(after) != expected:
                    report['status'] = 'mutation_outcome_unknown'
                    report['return_type'] = type(returned).__name__
                    return report
                report['verified_deleted_ids'].append(jid)
            report['status'] = 'selected_deletions_verified'
            return report
        report['mutations_sent'] = 1
        returned = fn() if action in ('add', 'stop') else fn([plan['selected_job_ids'][0]], False)
        report['return_type'] = type(returned).__name__
        if action == 'add' and isinstance(returned, str) and returned.strip():
            report['returned_job_id'] = returned  # Preserve before post-call read may fail.
        _, after = snapshot(resolve, project_id, timeline_id, action == 'add')
        report['after'] = public_snapshot(after, details)
        if action == 'add':
            jid = text(returned, 'AddRenderJob returned ID')
            old_ids = [r['JobId'] for r in before['jobs']]
            new_ids = [r['JobId'] for r in after['jobs'] if r['JobId'] not in old_ids]
            retained = copy.deepcopy(after)
            retained['jobs'] = [r for r in retained['jobs'] if r['JobId'] != jid]
            retained['statuses'].pop(jid, None)
            if jid in old_ids or new_ids != [jid] or stable(retained) != stable(before):
                report['status'] = 'mutation_outcome_unknown'
            else:
                report['status'] = 'job_added_needs_settings_review'
        elif action == 'start':
            # A True accepts a launch request, not proof of output completion.
            jid = plan['selected_job_ids'][0]
            unchanged = queue_structure(before['jobs']) == queue_structure(after['jobs'])
            others_ok = all(after['statuses'].get(k, {}).get('job_status') == v['job_status']
                            for k, v in before['statuses'].items() if k != jid)
            report['status'] = ('start_accepted_needs_observation' if returned is True and unchanged and others_ok
                                else 'mutation_outcome_unknown')
        else:
            # StopRendering has no documented bool success contract; None is valid.
            unchanged = queue_structure(before['jobs']) == queue_structure(after['jobs'])
            rendering = any(v['category'] == 'rendering' for v in after['statuses'].values())
            unknown = any(v['category'] in ('unknown', 'external') for v in after['statuses'].values())
            report['status'] = ('idle_observed_after_stop' if unchanged and not after['is_rendering'] and not rendering and not unknown
                                else 'stop_sent_needs_observation')
            report['rolled_back'] = False
        return report
    except (Exception, KeyboardInterrupt) as exc:
        report['status'] = 'mutation_outcome_unknown'
        report['error_type'] = type(exc).__name__
        return report  # Never retry, reverse or delete a newly added job here.


def cli_target(parser, timeline=False):
    parser.add_argument('--project-id', help='Exact opaque ID; required for apply/wait')
    if timeline:
        parser.add_argument('--timeline-id', help='Exact current timeline ID for AddRenderJob')
    parser.add_argument('--details', action='store_true', help='Include local names/paths/errors; redact before sharing')


def cli_write(parser, action):
    cli_target(parser, timeline=action == 'add')
    if action != 'add':
        parser.add_argument('--job-id', action='append', default=[], required=action != 'stop',
                            help='Exact ID; repeat only for cleanup or project-stop scope check')
    parser.add_argument('--apply', action='store_true')
    parser.add_argument('--expect-plan', help='SHA-256 of the reviewed current plan')
    parser.add_argument('--test-copy', action='store_true', help='Acknowledge isolated test project and sole operator')
    if action in ('add', 'start'):
        parser.add_argument('--settings-reviewed', action='store_true', help='Acknowledge manual review; not a technical verification')
    if action == 'stop':
        parser.add_argument('--allow-project-stop', action='store_true', help='Acknowledge non-job-scoped stop')


def write_from_args(resolve, action, args):
    return mutate(resolve, action, project_id=args.project_id, job_ids=getattr(args, 'job_id', []),
                  timeline_id=getattr(args, 'timeline_id', None), apply=args.apply,
                  expect_plan=args.expect_plan, test_copy=args.test_copy,
                  settings_reviewed=getattr(args, 'settings_reviewed', False),
                  allow_project_stop=getattr(args, 'allow_project_stop', False), details=args.details)


def print_result(work):
    try:
        out = work()
    except PreflightError as exc:
        out = {'status': 'preflight_refused', 'error': str(exc), 'mutations_sent': 0}
    except KeyboardInterrupt:
        out = {'status': 'observer_interrupted', 'render_stopped_by_script': False}
    except Exception as exc:
        out = {'status': 'read_or_connection_failed', 'error_type': type(exc).__name__}
    print(json.dumps(out, ensure_ascii=False, allow_nan=False, indent=2))
    if out['status'] in ('plan', 'observed', 'selected_deletions_verified', 'already_idle_no_call',
                         'idle_observed_after_stop', 'job_complete_file_unverified'):
        return 0
    return 2 if out['status'] == 'preflight_refused' else 3
