"""EVART-01: bounded, disposable renderer process; no RTSP connection or inference."""
import json
import os
from pathlib import Path
import struct
import sys
import threading
import time
from fractions import Fraction
from collections import deque

from PIL import Image, ImageDraw
from app.event_capture import PACKET, MAX_PACKET


def packets(path):
    with path.open('rb') as source:
        while size := source.read(4):
            if len(size) != 4:
                raise ValueError('Truncated capture.')
            n = struct.unpack('<I', size)[0]
            if not PACKET.size < n <= MAX_PACKET:
                raise ValueError('Invalid compressed packet.')
            raw = source.read(n)
            if len(raw) != n:
                raise ValueError('Truncated packet.')
            h = PACKET.unpack_from(raw)
            if PACKET.size + h[3] + h[4] != n:
                raise ValueError('Invalid packet lengths.')
            yield h, raw[PACKET.size:PACKET.size+h[4]].decode(), raw[PACKET.size+h[4]:]


def draw_frame(image, snapshot, size=(1280, 720), *, scenario=None, event=None):
    if event is not None and 'ruleId' in event:
        from app.multi_roi.artifacts import event_snapshot
        snapshot = event_snapshot(snapshot, event)
    scale = min(size[0]/image.width, size[1]/image.height)
    width, height = round(image.width*scale), round(image.height*scale)
    offset = ((size[0]-width)//2, (size[1]-height)//2)
    output = Image.new('RGB', size)
    output.paste(image.resize((width, height), Image.Resampling.BILINEAR), offset)
    if snapshot is None:
        return output
    draw = ImageDraw.Draw(output)
    def point(x, y):
        return (offset[0]+max(0, min(width-1, round(x*width))),
                offset[1]+max(0, min(height-1, round(y*height))))
    if scenario == 'CROWD':
        from app.crowd_artifact_overlay import draw_crowd
        return draw_crowd(output, snapshot, point, width)
    configurations = snapshot.get('scenarioConfig', {})
    roi_configs = ([configurations.get(scenario, {})] if scenario is not None else configurations.values())
    for roi_config in roi_configs:
        if roi_config.get('debug_roi') and roi_config.get('roi'):
            polygon = [point(*p) for p in roi_config['roi']]
            draw.line(polygon + polygon[:1], fill='#FF9F32', width=2)
    for item in snapshot['objects']:
        if not item['visible']:
            continue
        x1, y1, x2, y2 = item['bbox']
        a, b = point(x1, y1), point(x2, y2)
        draw.rectangle((*a, *b), outline=item['color'], width=2)
        label = f"{item['labelText']} {item['confidence']:.2f} id={item['trackId']}"
        if item.get('eventIds'):
            names = [d['scenario'] for d in item.get('scenarioResults', []) if d.get('eventId')]
            label += ' ' + '/'.join(names or configurations)
        draw.text((a[0], max(offset[1], a[1]-13)), label[:100], fill=item['color'], stroke_width=1, stroke_fill='black')
    return output


def frame_duration(pts, next_pts, source_end):
    """EVART-02: sparse analysis frames retain source presentation time, not frame count."""
    end = next_pts if next_pts is not None else source_end
    if end is None or end <= pts:
        raise ValueError('Invalid presentation interval')
    return end-pts


def render(stage, diagnostic=None):
    from app.artifact_diagnostics import RenderDiagnostic
    diagnostic = diagnostic or RenderDiagnostic()
    import gi
    gi.require_version('Gst', '1.0')
    gi.require_version('GstVideo', '1.0')
    from gi.repository import Gst, GstVideo
    Gst.init(None)
    diagnostic.enter('input_setup')
    job = json.loads((stage/'job.json').read_text())
    first = next(packets(stage/'capture.bin'))
    caps = Gst.Caps.from_string(first[1])
    media_type = caps.get_structure(0).get_name()
    decoder = {'video/x-h264': 'h264parse ! avdec_h264 max-threads=2',
               'video/x-h265': 'h265parse ! avdec_h265 max-threads=2'}.get(media_type)
    if not decoder:
        raise ValueError('Unsupported compressed artifact codec.')
    decode = Gst.parse_launch(f'appsrc name=input format=time block=true max-bytes=4194304 ! {decoder} ! videoconvert ! video/x-raw,format=RGB ! appsink name=output sync=false max-buffers=2 drop=false')
    encode = Gst.parse_launch('appsrc name=input format=time block=true max-bytes=8294400 ! videoconvert ! video/x-raw,format=I420 ! x264enc tune=zerolatency speed-preset=ultrafast threads=2 bitrate=2000 key-int-max=30 bframes=0 ! h264parse name=parsed ! mp4mux faststart=true ! filesink name=file')
    outdir = stage/'ready'
    outdir.mkdir(exist_ok=False)
    encode.get_by_name('file').set_property('location', str(outdir/'clip.mp4'))
    source, sink = decode.get_by_name('input'), decode.get_by_name('output')
    source.set_property('caps', caps)
    writer = encode.get_by_name('input')
    writer.set_property('caps', Gst.Caps.from_string('video/x-raw,format=RGB,width=1280,height=720,framerate=30/1'))
    origin = first[0][0]
    errors = []
    durations = deque()
    def restore_duration(pad, probe):
        buffer = probe.get_buffer()
        if not durations:
            errors.append('ENCODER_FRAME_MAPPING')
            return Gst.PadProbeReturn.DROP
        # x264 replaces duration with nominal FPS; AU order is preserved with bframes=0.
        buffer.duration = durations.popleft()
        return Gst.PadProbeReturn.OK
    encode.get_by_name('parsed').get_static_pad('src').add_probe(Gst.PadProbeType.BUFFER,restore_duration)
    def feed():
        try:
            for h, description, payload in packets(stage/'capture.bin'):
                if description != first[1]:
                    raise ValueError('Compressed caps changed within Event.')
                buf = Gst.Buffer.new_allocate(None, len(payload), None)
                buf.fill(0, payload)
                buf.pts = max(0, h[0]-origin)
                buf.dts = Gst.CLOCK_TIME_NONE if h[1] == Gst.CLOCK_TIME_NONE else max(0, h[1]-origin)
                buf.duration = h[2]
                if not h[5]:
                    buf.set_flags(Gst.BufferFlags.DELTA_UNIT)
                if source.emit('push-buffer', buf) != Gst.FlowReturn.OK:
                    raise RuntimeError('Decode input rejected.')
            source.emit('end-of-stream')
        except Exception as exc:
            errors.append(type(exc).__name__)
            source.emit('end-of-stream')
    count, missing, thumbnail = 0, 0, False
    begin = end = duration = source_end = None
    pending = None
    metadata = {s['ptsNs']: s for s in job['metadata']}
    thread = threading.Thread(target=feed, name='artifact-decode-feed', daemon=True)
    deadline = time.monotonic()+50
    try:
        diagnostic.enter('pipeline_start')
        decode.set_state(Gst.State.PLAYING)
        encode.set_state(Gst.State.PLAYING)
        thread.start()
        while time.monotonic() < deadline:
            diagnostic.enter('decode_pull')
            sample = sink.emit('try-pull-sample', 100*Gst.MSECOND)
            if sample is None:
                message = decode.get_bus().pop_filtered(Gst.MessageType.ERROR)
                if message:
                    raise RuntimeError('Artifact decoder failed.')
                if sink.get_property('eos'):
                    break
                continue
            buffer = sample.get_buffer()
            pts = int(buffer.pts)+origin
            if not job['startPtsNs'] <= pts <= job['endPtsNs']:
                continue
            info = GstVideo.VideoInfo.new_from_caps(sample.get_caps())
            if info.width*info.height > 4096*2160:
                raise ValueError('Artifact decode dimensions exceed cap.')
            source_duration = (int(buffer.duration) if buffer.duration not in (0,Gst.CLOCK_TIME_NONE) else
                               round(Gst.SECOND*info.fps_d/info.fps_n) if info.fps_n and info.fps_d else 33_333_333)
            source_end = pts+source_duration
            snapshot = metadata.get(pts)
            if snapshot is None:
                missing += 1
                continue
            diagnostic.enter('map_overlay')
            ok, mapped = buffer.map(Gst.MapFlags.READ)
            if not ok:
                raise RuntimeError('Artifact decoder mapping failed.')
            try:
                image = Image.frombytes('RGB', (info.width, info.height), bytes(mapped.data), 'raw', 'RGB', info.stride[0])
            finally:
                buffer.unmap(mapped)
                rendered = draw_frame(image, snapshot, scenario=job['event'].get('scenario', ''), event=job['event'])
            if pts == job['event']['ptsNs'] and snapshot is not None:
                draw_frame(image, snapshot, (640, 360), scenario=job['event'].get('scenario', ''), event=job['event']).save(outdir/'thumbnail.jpg', quality=90)
                thumbnail = True
            if begin is None:
                selected = sorted(p for p in metadata if p is not None and job['startPtsNs'] <= p <= job['endPtsNs'])
                gaps = sorted(b-a for a,b in zip(selected,selected[1:]) if b>a)
                rate = Fraction(Gst.SECOND,gaps[len(gaps)//2]).limit_denominator(1001) if gaps else Fraction(10,1)
                writer.set_property('caps', Gst.Caps.from_string(
                    f'video/x-raw,format=RGB,width=1280,height=720,framerate={rate.numerator}/{rate.denominator}'))
                begin = pts
            diagnostic.enter('encode_push')
            payload = rendered.tobytes()
            encoded = Gst.Buffer.new_allocate(None, len(payload), None)
            encoded.fill(0, payload)
            encoded.pts = pts-begin
            if pending is not None:
                previous, previous_pts = pending
                previous.duration = frame_duration(previous_pts,pts,None)
                durations.append(previous.duration)
                if writer.emit('push-buffer',previous) != Gst.FlowReturn.OK:
                    raise RuntimeError('Artifact encoder rejected Frame.')
            pending = (encoded,pts)
            count += 1
            diagnostic.frames = count
            end = pts
            if count > 2000:
                raise ValueError('Artifact Frame cap exceeded.')
        else:
            raise TimeoutError('Artifact decode deadline.')
        thread.join(2)
        if thread.is_alive() or errors or not count:
            raise RuntimeError('Artifact capture could not finish.')
        duration = frame_duration(end,None,source_end)
        pending[0].duration = duration
        durations.append(duration)
        if writer.emit('push-buffer',pending[0]) != Gst.FlowReturn.OK:
            raise RuntimeError('Artifact encoder rejected final Frame.')
        diagnostic.enter('encode_eos')
        writer.emit('end-of-stream')
        message = encode.get_bus().timed_pop_filtered(10*Gst.SECOND, Gst.MessageType.ERROR | Gst.MessageType.EOS)
        if message is None or message.type != Gst.MessageType.EOS or durations or errors:
            raise RuntimeError('Artifact encoder could not finish.')
    finally:
        decode.set_state(Gst.State.NULL)
        encode.set_state(Gst.State.NULL)
        if thread.ident:
            thread.join(2)
    media = dict(clip=True, thumbnail=thumbnail, frames=count, metadataMissingFrames=missing,
                 omittedFramesWithoutMetadata=missing, selection='EXACT_PTS_METADATA',
                 thumbnailMissingReason=None if thumbnail else 'CONFIRMED_FRAME_NOT_DECODED_OR_METADATA_MISSING',
                 startPtsNs=begin, endPtsNs=end, durationMs=(end-begin+duration)/1e6,
                 requestedStartPtsNs=job['startPtsNs'], requestedEndPtsNs=job['endPtsNs'],
                 preShortMs=max(0, begin-job['startPtsNs'])/1e6,
                 postShortMs=max(0, job['endPtsNs']-end-duration)/1e6,
                 captureReason=job['captureReason'], nativeDrop=job['nativeDrop'],
                 metadataQueueDrop=job['metadataQueueDrop'], audio=False,
                 resize='SOURCE_ASPECT_PRESERVED_WITH_PADDING')
    manifest = dict(event=job['event'], media=media, artifactCreatedAtWallMs=time.time_ns()//1_000_000,
                    folderClock='aiRecordedAtWallMs', folderTimezone='Asia/Seoul')
    (outdir/'event.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2))
    for path in outdir.iterdir():
        with path.open('rb') as target:
            os.fsync(target.fileno())
    return media


if __name__ == '__main__':
    from app.artifact_diagnostics import RenderDiagnostic, save_diagnostic
    diagnostic = RenderDiagnostic()
    try:
        import ctypes
        import signal
        ctypes.CDLL(None).prctl(1, signal.SIGKILL)
        if os.getppid() != int(os.environ.get('CONTEXTRA_ARTIFACT_PARENT_PID', os.getppid())):
            raise RuntimeError('Artifact parent exited.')
        stage = Path(sys.argv[1])
        render(stage, diagnostic)
        save_diagnostic(stage, dict(**diagnostic.snapshot(), result='success'))
    except Exception as exc:
        import traceback
        traceback.print_exc()
        save_diagnostic(Path(sys.argv[1]), dict(**diagnostic.snapshot(), result='failed',
                                              exception=type(exc).__name__))
        print('ARTIFACT_RENDER_' + type(exc).__name__.upper(), flush=True)
        raise SystemExit(1)
