"""개발용 렌더러. wire color/Scenario 의미는 바꾸지 않고 같은 FrameResult만 표시한다."""
import colorsys
import math
from datetime import datetime, timezone
from collections import Counter
from app.region_scenario import members, name as scenario_name

from PIL import Image, ImageDraw, ImageFont

HEADER = 242


def time_label(frame):
    pts = f'{frame.pts_ns/1e9:.3f}s' if frame.pts_ns is not None else 'UNAVAILABLE'
    value = frame.frame_time
    source = value.time_source if value else None
    status = value.sync_status if value else 'UNAVAILABLE'
    wall = value.source_time_wall_ms if value else None
    formatted = 'UNAVAILABLE'
    if wall is not None:
        try:
            formatted = datetime.fromtimestamp(wall/1000, timezone.utc).isoformat(timespec='milliseconds')
        except (ValueError, OverflowError, OSError):
            formatted = f'{wall}ms (date out of range)'
    return f'Decision PTS: {pts} | Calculated UTC: {formatted}', f'{source or "NO ANCHOR"} | {status}'


def draw_roi(draw, polygon, name, width, height):
    for a, b in zip(polygon, polygon[1:]+polygon[:1]):
        length = math.hypot(b[0]-a[0], b[1]-a[1])
        if not length:
            continue
        for start in range(0, math.ceil(length), 18):
            lo, hi = start/length, min(start+10, length)/length
            segment = [(a[0]+(b[0]-a[0])*t, a[1]+(b[1]-a[1])*t) for t in (lo, hi)]
            draw.line(segment, fill='#171717', width=5)
            draw.line(segment, fill='#FF9F32', width=2)
    x, y = min(p[0] for p in polygon), min(p[1] for p in polygon)
    x, y = min(x, max(0, width-240)), max(HEADER, min(y, HEADER+height-23))
    draw.rectangle((x, y, min(width-1, x+239), y+22), fill='#171717')
    text_fit(draw, (x+4, y+2), name, min(231, width-x-8), 14, '#FF9F32')


def class_color(class_id):
    if class_id == 0:
        return (60, 235, 145)
    # 초록 주변 hue를 비우고 고정 순열로 인접 class 색을 분산한다.
    hue = (175 + ((class_id - 1) * 137.508) % 250) % 360
    saturation = (.78, .92, .65)[(class_id - 1) % 3]
    value = (.98, .82)[(class_id - 1) % 2]
    return tuple(round(c * 255) for c in colorsys.hsv_to_rgb(hue / 360, saturation, value))


def candidate_summary(result):
    classes = {}
    for command in result.commands:
        row = classes.setdefault(command.detection.class_id, Counter())
        row['total'] += 1
        row['shown' if command.visible else 'hidden'] += 1
    shown = sum(row['shown'] for row in classes.values())
    return dict(total=len(result.commands), shown=shown, hidden=len(result.commands)-shown,
                classes={str(k): dict(total=v['total'], shown=v['shown'], hidden=v['hidden'])
                         for k, v in classes.items()})


def text_fit(draw, xy, value, width, size=16, fill='white'):
    font = ImageFont.load_default(size=size)
    value = str(value)
    while value and draw.textbbox((0, 0), value, font=font)[2] > width:
        value = value[:-1]
    draw.text(xy, value, font=font, fill=fill)


def label_text(command):
    text = f'{command.label_text} {command.detection.score:.2f}'
    if command.track_id is not None:
        text += f' id={command.track_id}'
    if command.scenario is not None:
        text += ' ' + '/'.join(d.state for d in members(command.scenario))
    return text


def command_color(command):
    if command.scenario is not None:
        return command.color
    return class_color(command.detection.class_id)


def render_rgb(result, rgb, *, display_threshold=None):
    """CAPTURE-RGB-01: SDK and replay RGB share one boundary; legacy render accepts BGR."""
    return render(result, rgb[:, :, ::-1], display_threshold=display_threshold)


def render(result, bgr, *, display_threshold=None):
    from app.class_catalog import coco_name
    frame = result.frame
    if bgr.shape != (frame.frame_height, frame.frame_width, 3) or str(bgr.dtype) != 'uint8':
        raise ValueError('Frame image geometry differs.')
    scale = min(1280/frame.frame_width, 720/frame.frame_height, max(1., 640/frame.frame_width))
    w, h = max(1, round(frame.frame_width*scale)), max(1, round(frame.frame_height*scale))
    image = Image.fromarray(bgr[:, :, ::-1]).resize((w, h), Image.Resampling.BILINEAR)
    canvas = Image.new('RGB', (max(640, w), h+HEADER), (22, 24, 26))
    canvas.paste(image, (0, HEADER))
    draw = ImageDraw.Draw(canvas)
    for config in members(result.scenario_config):
        if not config.debug_roi:
            continue
        polygon = [(min(w-1, round(x*w)), HEADER+min(h-1, round(y*h)))
                   for x, y in config.roi]
        draw_roi(draw, polygon, config.roi_name, w, h)
    counts = candidate_summary(result)
    model = result.provider_model or (result.commands[0].detection.model_key if result.commands else
             ('RFDETRKeypointPreview' if result.pose_config else 'RFDETRMedium'))
    text_fit(draw, (10, 8), f'{model} FP16 | Frame {frame.frame_seq} | {frame.frame_width}x{frame.frame_height}', canvas.width-20, 19)
    text_fit(draw, (10, 36), f"Candidates: All {counts['total']} | Show {counts['shown']} | Hide {counts['hidden']}", canvas.width-20)
    threshold = f'score > {display_threshold:g}' if display_threshold is not None else 'result visibility'
    if result.scenario_config is not None:
        threshold = ' / '.join(f'{scenario_name(c)} score {">" if scenario_name(c) == "INTRUSION" else ">="} {c.confidence:g}' for c in members(result.scenario_config))
    text_fit(draw, (10, 61), threshold, canvas.width-20, 14)
    for y, line in zip((85, 109), time_label(frame)):
        text_fit(draw, (10, y), line, canvas.width-20, 14)
    names = {i: coco_name(i) for i in range(80)}
    # CLI의 shown/total/class 정렬을 재사용하되 Runtime이 CLI package에 의존하지 않게 한다.
    ordered = sorted(counts['classes'].items(), key=lambda kv: (-kv[1]['shown'], -kv[1]['total'], int(kv[0])))
    rows = [] if result.pose_config else [(names[int(key)], row['shown'], row['hidden']) for key, row in ordered[:6]]
    omitted = max(0, len(ordered)-6)
    for i, (name, shown, hidden) in enumerate(rows):
        x, y = 10+(i//3)*(canvas.width//2), 135+(i%3)*24
        text_fit(draw, (x, y), f'{name}: {shown} / {hidden}', canvas.width//2-20)
    occupied = []
    labels_omitted = 0
    font = ImageFont.load_default(size=16)
    # 사람/일반 후보 모두 bbox는 유지하고, 충돌 label만 bounded하게 생략한다.
    commands = sorted((c for c in result.commands if c.visible), key=lambda c: c.detection.class_id != 0)
    for c in reversed(commands):
        x1, y1, x2, y2 = c.detection.bbox_xyxy
        rect = (min(w-1, round(x1*w)), HEADER+min(h-1, round(y1*h)),
                min(w-1, round(x2*w)), HEADER+min(h-1, round(y2*h)))
        if result.pose_config is None or result.pose_config.show_bbox:
            draw.rectangle(rect, outline=command_color(c),
                           width=3 if c.detection.class_id == 0 else 2)
        if any(config.debug_roi for config in members(result.scenario_config)):
            for decision in members(c.scenario):
                px, py = decision.point
                px, py = min(w-1, round(px*w)), HEADER+min(h-1, round(py*h))
                draw.ellipse((px-4, py-4, px+4, py+4), fill='white', outline=command_color(c))
    for c in commands:
        x1, y1, x2, y2 = c.detection.bbox_xyxy
        left, right = min(w-1, round(x1*w)), min(w-1, round(x2*w))
        top, bottom = HEADER+min(h-1, round(y1*h)), HEADER+min(h-1, round(y2*h))
        color = command_color(c)
        text = label_text(c)
        while text and draw.textbbox((0, 0), text, font=font)[2]+8 > w:
            text = text[:-1]
        tw = min(w, draw.textbbox((0, 0), text, font=font)[2]+8)
        tx, ty = min(left, w-tw), max(HEADER, top-23)
        found = None
        for attempt in range(10):
            y = HEADER+(ty-HEADER+attempt*24) % max(1, h-24)
            rect = (tx, y, tx+tw, y+23)
            if y+23 <= HEADER+h and not any(rect[0]<a[2] and rect[2]>a[0] and rect[1]<a[3] and rect[3]>a[1] for a in occupied):
                found = rect
                break
        if found is None:
            labels_omitted += 1
            continue
        occupied.append(found)
        draw.rectangle(found, fill=(18, 20, 22))
        draw.text((found[0]+4, found[1]+2), text, font=font, fill=color)
    from app.pose_visual import draw_pose
    pose_counts = draw_pose(draw,result,w,h,HEADER)
    if result.pose_config:
        cfg=result.pose_config
        text_fit(draw,(10,135),f"Skeleton eligible {pose_counts['shown']} | Joint shortage {pose_counts['filtered']} | joint >= {cfg.joint_score:g}, min {cfg.min_joints}/17",canvas.width-20,14)
        text_fit(draw,(10,161),f"bbox={cfg.show_bbox} joints={cfg.show_joints} edges={cfg.show_edges} | revision {(result.scenario_revision or 'UNAVAILABLE')[:12]}",canvas.width-20,14)
        if result.nms_kept is not None:
            text_fit(draw,(10,187),f'NMS: input {len(result.commands)} | kept {sum(result.nms_kept)} | suppressed {len(result.commands)-sum(result.nms_kept)} | IoU > {cfg.nms_iou_threshold:g}',canvas.width-20,14)
    text_fit(draw, (10, 213), f'Classes omitted: {omitted} | Labels omitted: {labels_omitted}', canvas.width-20, 14)
    return canvas, dict(candidate_counts=counts, classes_omitted=omitted, labels_omitted=labels_omitted,
                        header_height=HEADER, image_size=[w, h], label_rects=occupied,pose_counts=pose_counts)
