"""Issueureum lab 001: a synthetic text screenshot, not a photograph.

Run: python reproduce.py
Uses the supplied original PNG by default; no font is needed for re-encoding.
To regenerate the input: python reproduce.py --regenerate --font PATH --bold-font PATH
Dependencies: Pillow==12.3.0, numpy==2.3.5, with JPEG/WebP/AVIF codecs.
"""
from __future__ import annotations

import argparse
import csv
import hashlib
import io
import json
import math
import os
from pathlib import Path
import platform
import random
import statistics
import sys
import time
from datetime import datetime, timezone, timedelta

import numpy as np
import PIL
from PIL import Image, ImageDraw, ImageFont, features, _avif

ROOT = Path(__file__).resolve().parent
SIZE = (1200, 900)
TEXT_ROI = (64, 264, 624, 386)
COLOR_ROI = (64, 318, 624, 386)
REPEATS = 5


def sha(data):
    return hashlib.sha256(data).hexdigest()


def write_json(name, data):
    (ROOT / name).write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")


def make_input(font_path, bold_path):
    im = Image.new("RGB", SIZE, "#f2f4f8")
    d = ImageDraw.Draw(im)
    fonts = {}

    def label(x, y, text, size=22, fill="#19283b", bold=False):
        key = (size, bold)
        if key not in fonts:
            fonts[key] = ImageFont.truetype(str(bold_path if bold else font_path), size)
        d.text((x, y), text, font=fonts[key], fill=fill, anchor="lt")

    d.rectangle((0, 0, 1199, 125), fill="#102337")
    label(40, 24, "글자 공유 테스트", 34, "#ffffff", True)
    label(42, 78, "직접 만든 시험 화면 · 실제 앱이나 사진이 아닙니다", 19, "#c0d3e6")
    d.rounded_rectangle((40, 150, 1159, 440), radius=16, fill="white", outline="#d6dce5")
    label(64, 173, "읽을 수 있는 크기와 선명한 글자는 다릅니다", 28, bold=True)
    label(64, 222, "아래 글자 크기는 원본 이미지 기준입니다.", 18, "#506277")
    label(64, 268, "14px  준비물: 물 · 수건 · 운동화 0123456789", 14)
    label(64, 293, "18px  약속 시간 18:30 / 입장료 1,700원", 18)
    label(64, 326, "18px  빨간 글자: 변경된 시간 19:30", 18, "#db1535")
    label(64, 358, "18px  파란 글자: 준비물과 숫자를 확인하세요", 18, "#165be2")
    label(64, 402, "24px  글자 테두리를 원본과 비교해 보세요", 24)
    d.line((676, 258, 676, 416), fill="#d6dce5", width=1)
    label(704, 270, "얇은 선과 색 면", 18, bold=True)
    for i, col in enumerate(("#16263b", "#db1535", "#165be2", "#139a72")):
        y = 307 + 25 * i
        d.line((704, y, 1115, y), fill=col, width=1)
        d.rectangle((1094, y - 6, 1115, y + 6), fill=col)
    d.rounded_rectangle((40, 462, 1159, 706), radius=16, fill="white", outline="#d6dce5")
    label(64, 486, "표와 숫자", 26, bold=True)
    columns = [64, 447, 792]
    for x, txt in zip(columns, ("항목", "예시 값", "확인 사항")):
        label(x, 538, txt, 17, "#506277", True)
    rows = [("알림", "18:30 → 19:30", "바뀐 숫자"), ("가상 가격", "1,700원 / 17,000원", "자릿수와 쉼표"), ("준비물", "물 · 수건 · 운동화", "작은 한글")]
    for i, row in enumerate(rows):
        y = 573 + i * 38
        d.line((64, y - 11, 1135, y - 11), fill="#e5e9ef", width=1)
        for x, txt in zip(columns, row):
            label(x, y, txt, 18)
    label(42, 730, "부드러운 색 변화와 도형도 함께 담았습니다.", 18, "#506277")
    for x in range(1118):
        t = x / 1117
        color = tuple(round((1 - t) * a + t * b) for a, b in zip((19, 154, 114), (35, 78, 205)))
        d.line((41 + x, 768, 41 + x, 818), fill=color)
    label(42, 846, "1200 × 900 px / RGB / 한글과 도형으로 구성한 실험 입력", 18, "#506277")
    path = ROOT / "original.png"
    im.save(path, format="PNG", compress_level=9, optimize=False)
    write_json("input-provenance.json", {
        "kind": "algorithmically drawn synthetic screenshot-like test data",
        "not_a_photograph": True,
        "not_a_capture_of_real_product": True,
        "not_ai_generated_artwork": True,
        "dimensions": list(SIZE), "mode": "RGB",
        "content": "Original Korean sample text, fictional numbers, flat colors, 1px lines, gradient; no personal data or third-party screenshot.",
        "font": {"regular_name": Path(font_path).name, "regular_sha256": sha(Path(font_path).read_bytes()), "bold_name": Path(bold_path).name, "bold_sha256": sha(Path(bold_path).read_bytes()), "font_files_redistributed": False},
        "generator": "reproduce.py make_input()", "original_sha256": sha(path.read_bytes()),
    })
    return im


def encode(im, config):
    buffer = io.BytesIO()
    im.save(buffer, format=config["format"], **config["options"])
    return buffer.getvalue()


def error_metrics(reference, candidate):
    delta = np.asarray(candidate, dtype=np.float64) - np.asarray(reference, dtype=np.float64)
    mse = float(np.mean(delta ** 2))
    return {"mae_rgb_0_255": float(np.mean(np.abs(delta))), "mse_rgb": mse, "psnr_rgb_db": None if mse == 0 else float(10 * math.log10(255 ** 2 / mse)), "changed_rgb_pixel_percent": float(np.mean(np.any(delta != 0, axis=2)) * 100)}


def make_montage():
    labels = [("original", "ORIGINAL PNG"), ("jpeg-q75", "JPG q75 / 4:2:0"), ("webp-q75", "WebP q75 / lossy"), ("avif-q75", "AVIF q75 / 4:2:0")]
    row_h = 300
    panel = Image.new("RGB", (1152, row_h * len(labels)), "#f2f4f8")
    draw = ImageDraw.Draw(panel)
    for i, (key, label) in enumerate(labels):
        draw.text((16, i * row_h + 12), label + " - same region, nearest-neighbor 2x", fill="#102337", font=ImageFont.load_default(size=20))
        with Image.open(ROOT / f"{key}-text-crop.png") as crop:
            enlarged = crop.resize((crop.width * 2, crop.height * 2), Image.Resampling.NEAREST)
            panel.paste(enlarged, (16, i * row_h + 48))
    panel.save(ROOT / "q75-text-comparison.png", compress_level=9)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--regenerate", action="store_true")
    parser.add_argument("--font", default="C:/Windows/Fonts/malgun.ttf")
    parser.add_argument("--bold-font", default="C:/Windows/Fonts/malgunbd.ttf")
    args = parser.parse_args()
    ROOT.mkdir(parents=True, exist_ok=True)
    if not all(features.check(f) for f in ("jpg", "webp", "avif")):
        raise RuntimeError("This experiment requires all three encoders and decoders.")
    if not _avif.encoder_codec_available("aom"):
        raise RuntimeError("AVIF aom encoder is required to reproduce these settings.")
    started = datetime.now(timezone.utc).isoformat()
    if args.regenerate:
        source = make_input(args.font, args.bold_font)
    else:
        source = Image.open(ROOT / "original.png").convert("RGB")
    if source.size != SIZE:
        raise ValueError("Unexpected source dimensions")
    source.load()
    source_bytes = (ROOT / "original.png").read_bytes()
    configs = []
    for q in (50, 75, 90):
        configs.extend([
            {"id": f"jpeg-q{q}", "file": f"jpeg-q{q}.jpg", "format": "JPEG", "quality": q, "options": {"quality": q, "subsampling": 2, "optimize": False, "progressive": False}},
            {"id": f"webp-q{q}", "file": f"webp-q{q}.webp", "format": "WEBP", "quality": q, "options": {"quality": q, "lossless": False, "method": 4}},
            {"id": f"avif-q{q}", "file": f"avif-q{q}.avif", "format": "AVIF", "quality": q, "options": {"quality": q, "subsampling": "4:2:0", "speed": 6, "codec": "aom", "max_threads": 1}},
        ])
    configs.append({"id": "jpeg-q75-444", "file": "jpeg-q75-444.jpg", "format": "JPEG", "quality": 75, "options": {"quality": 75, "subsampling": 0, "optimize": False, "progressive": False}})
    # Warm each configuration once; warm-up time is not part of the reported timing.
    first_outputs = {c["id"]: encode(source, c) for c in configs}
    trials = []
    rng = random.Random(20260912)
    for repetition in range(1, REPEATS + 1):
        order = configs.copy()
        rng.shuffle(order)
        for position, config in enumerate(order):
            start = time.perf_counter_ns()
            output = encode(source, config)
            elapsed_ns = time.perf_counter_ns() - start
            stable = output == first_outputs[config["id"]]
            trials.append({"repetition": repetition, "position": position + 1, "id": config["id"], "encode_ns": elapsed_ns, "bytes": len(output), "sha256": sha(output), "identical_to_warmup": stable})
            if not stable:
                raise AssertionError(f"Non-deterministic bytes: {config['id']}")
    results = []
    output_images = {"original": source}
    for config in configs:
        output = first_outputs[config["id"]]
        (ROOT / config["file"]).write_bytes(output)
        with Image.open(io.BytesIO(output)) as opened:
            detected_format = opened.format
            decoded = opened.convert("RGB")
            decoded.load()
        if decoded.size != source.size or detected_format != config["format"]:
            raise AssertionError("Output dimensions/format mismatch")
        output_images[config["id"]] = decoded
        ms = [t["encode_ns"] / 1e6 for t in trials if t["id"] == config["id"]]
        results.append({**config, "detected_format": detected_format, "width": decoded.width, "height": decoded.height, "bytes": len(output), "kib": len(output) / 1024, "saving_vs_original_png_percent": (1 - len(output) / len(source_bytes)) * 100, "sha256": sha(output), "whole_image": error_metrics(source, decoded), "text_region": error_metrics(source.crop(TEXT_ROI), decoded.crop(TEXT_ROI)), "colored_text_region": error_metrics(source.crop(COLOR_ROI), decoded.crop(COLOR_ROI)), "encode_ms": {"n": REPEATS, "median": statistics.median(ms), "min": min(ms), "max": max(ms), "all": ms}})
    # Preview PNGs are pixel-identical to each Pillow-decoded output. They are
    # for displaying comparison images; the original-format downloads above
    # remain the measured files. Their preview PNG size is never reported.
    for key in ("original", "jpeg-q75", "webp-q75", "avif-q75", "jpeg-q75-444"):
        im = output_images[key]
        if key != "original":
            im.save(ROOT / f"{key}-decoded.png", compress_level=9)
        crop = im.crop(TEXT_ROI)
        crop.save(ROOT / f"{key}-text-crop.png", compress_level=9)
        crop.resize((crop.width * 4, crop.height * 4), Image.Resampling.NEAREST).save(ROOT / f"{key}-text-4x.png", compress_level=9)
        with Image.open(ROOT / f"{key}-text-4x.png") as zoom:
            if not np.array_equal(np.asarray(zoom)[::4, ::4], np.asarray(crop)):
                raise AssertionError("Zoom altered the underlying pixels")
    make_montage()
    metric_definitions = {
        "mae_rgb_0_255": "Mean absolute difference of all decoded R,G,B channel values versus source; 0 equals pixel identity. Includes background within each region. Not OCR accuracy or perceived quality.",
        "mse_rgb": "Mean squared error across R,G,B channel values.",
        "psnr_rgb_db": "10*log10(255^2 / RGB MSE), without gamma linearization; null for zero MSE. Higher is closer pixel match, not guaranteed perceptual superiority.",
        "changed_rgb_pixel_percent": "Percentage of pixels with at least one unequal RGB channel; not a quality score or percentage of unreadable text.",
        "encode_ms": "In-memory Image.save wall time only; excludes input drawing/reading, decode, metrics, hash, disk writing and upload. One warmup per configuration then five runs in seeded shuffled order; one workstation, not isolated load or a smartphone benchmark.",
    }
    report = {"experiment_id": "lab-001-text-image-formats", "title": "글자 있는 캡처, JPG로 보내면 왜 뭉개질까?", "started_at_utc": started, "finished_at_kst": datetime.now(timezone(timedelta(hours=9))).isoformat(), "source": {"file": "original.png", "bytes": len(source_bytes), "kib": len(source_bytes) / 1024, "width": SIZE[0], "height": SIZE[1], "sha256": sha(source_bytes), "mode": "RGB", "photographic_content": False, "real_app_screenshot": False}, "environment": {"python": platform.python_version(), "pillow": PIL.__version__, "numpy": np.__version__, "os": platform.platform(), "processor": os.environ.get("PROCESSOR_IDENTIFIER", platform.processor()), "logical_cpu_count": os.cpu_count(), "libjpeg_api": features.version("jpg"), "libjpeg_turbo": features.version_feature("libjpeg_turbo"), "libwebp": features.version("webp"), "libavif": features.version("avif"), "avif_codecs": _avif.codec_versions()}, "design": {"text_roi_xyxy_right_bottom_exclusive": TEXT_ROI, "colored_text_roi_xyxy_right_bottom_exclusive": COLOR_ROI, "repeats": REPEATS, "random_order_seed": 20260912, "same_numeric_quality_is_not_same_visual_quality": True, "jpeg444_control": "Only JPEG chroma subsampling changes from 4:2:0 to 4:4:4 at q75.", "no_resize_or_sharpening": True, "input_count": 1, "main_quality": 75, "sensitivity_quality_values": [50, 90]}, "metric_definitions": metric_definitions, "results": results, "trial_log_file": "trials.csv", "references": ["https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#jpeg", "https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#webp", "https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#avif"]}
    write_json("results.json", report)
    with (ROOT / "trials.csv").open("w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=list(trials[0]))
        writer.writeheader()
        writer.writerows(trials)
    with (ROOT / "results.csv").open("w", newline="", encoding="utf-8-sig") as f:
        names = ["id", "bytes", "kib", "saving_vs_original_png_percent", "whole_rgb_psnr_db", "text_rgb_mae_0_255", "colored_text_rgb_mae_0_255", "encode_median_ms", "encode_min_ms", "encode_max_ms", "sha256"]
        writer = csv.DictWriter(f, fieldnames=names)
        writer.writeheader()
        for r in results:
            writer.writerow({"id": r["id"], "bytes": r["bytes"], "kib": r["kib"], "saving_vs_original_png_percent": r["saving_vs_original_png_percent"], "whole_rgb_psnr_db": r["whole_image"]["psnr_rgb_db"], "text_rgb_mae_0_255": r["text_region"]["mae_rgb_0_255"], "colored_text_rgb_mae_0_255": r["colored_text_region"]["mae_rgb_0_255"], "encode_median_ms": r["encode_ms"]["median"], "encode_min_ms": r["encode_ms"]["min"], "encode_max_ms": r["encode_ms"]["max"], "sha256": r["sha256"]})
    print(json.dumps({"source_bytes": len(source_bytes), "main": [{"id": r["id"], "bytes": r["bytes"], "text_mae": round(r["text_region"]["mae_rgb_0_255"], 3), "color_mae": round(r["colored_text_region"]["mae_rgb_0_255"], 3), "ms": round(r["encode_ms"]["median"], 3)} for r in results if r["quality"] == 75], "all_50_measured_outputs_byte_identical_to_warmup": all(t["identical_to_warmup"] for t in trials)}, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()
