"""Reproduce Skanworld's openly labelled analytical models and image encoding test.

Run from the project root: python scripts/studies_v3.py
No network, hardware performance or market prices are used by this script.
"""
from __future__ import annotations

import csv
import hashlib
import io
import json
import math
import random
import sys
from pathlib import Path
from xml.sax.saxutils import escape

from PIL import Image, ImageDraw, features, __version__ as pillow_version

ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "_research" / "studies-v3"
CSV = ROOT / "site" / "static" / "downloads" / "research"
CHART = ROOT / "site" / "static" / "img" / "research"
FORMATS = {"A4": (210, 297), "A3": (297, 420), "photo_10x15": (100, 150), "photo_13x18": (130, 180), "film_24x36": (24, 36), "A6": (105, 148)}
DATA = {}


def px(mm, dpi):
    return math.floor(mm / 25.4 * dpi + 0.5)


def raw_bytes(width, height, channels, bits):
    return math.ceil(width * channels * bits / 8) * height


def save(slug, rows):
    assert rows and all(set(r) == set(rows[0]) for r in rows)
    for folder in (OUT, CSV):
        folder.mkdir(parents=True, exist_ok=True)
        with (folder / f"{slug}.csv").open("w", encoding="utf-8-sig", newline="") as stream:
            writer = csv.DictWriter(stream, fieldnames=rows[0].keys())
            writer.writeheader()
            writer.writerows(rows)
    DATA[slug] = {"rows": len(rows), "columns": list(rows[0]), "sha256": hashlib.sha256((CSV / f"{slug}.csv").read_bytes()).hexdigest()}


def chart(slug, title, subtitle, labels, series, ylabel, x_label):
    """Accessible, static SVG line chart. Every line starts on a zero y axis."""
    CHART.mkdir(parents=True, exist_ok=True)
    w, h, left, top, right, bottom = 960, 540, 86, 94, 40, 105
    pw, ph = w - left - right, h - top - bottom
    bars = slug in {"duplex-paper-savings", "starter-cartridge-impact", "paper-format-efficiency", "rgb-gray-storage"}
    numeric_x = all(isinstance(label, (int, float)) for label in labels) and not bars
    x_label = x_label.replace(" (категории)", "").replace(" (точки равномерно по категориям)", "")
    if numeric_x:
        xmin, xmax = min(labels), max(labels)
        xpos = [left + pw * (label - xmin) / max(1, xmax - xmin) for label in labels]
    else:
        xpos = [left + pw * (i + 0.5) / len(labels) for i in range(len(labels))]
    values = [value for _, _, vector in series for value in vector]
    estimate = (max(values) * 1.12 or 1) / 5
    magnitude = 10**math.floor(math.log10(estimate))
    ystep = next(candidate*magnitude for candidate in [1,2,2.5,5,10] if candidate*magnitude >= estimate)
    ysteps = math.ceil((max(values)*1.12 or 1)/ystep)
    vmax = ystep*ysteps
    chunks = [f'<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540" role="img" aria-labelledby="title desc">', f'<title id="title">{escape(title)}</title>', f'<desc id="desc">{escape(subtitle)}. {escape(ylabel)}; {escape(x_label)}.</desc>', '<rect width="960" height="540" rx="20" fill="#f8f5ef"/>', '<g font-family="Arial, sans-serif" fill="#202d2a">', f'<text x="36" y="36" font-size="23" font-weight="700">{escape(title)}</text>', f'<text x="36" y="62" font-size="15">{escape(subtitle)}</text>']
    for i in range(ysteps+1):
        y = top + ph - ph * i / ysteps
        value = ystep * i
        tick = f"{value:g}"
        chunks += [f'<path d="M {left} {y:.2f} H {left + pw}" stroke="#c9d2c8" stroke-width="1"/>', f'<text x="{left-12}" y="{y+5:.2f}" text-anchor="end" font-size="14">{tick}</text>']
    last_label_x = -1000
    for i, label in enumerate(labels):
        x = xpos[i]
        # Closely spaced numeric observations remain plotted; crowded tick labels are omitted.
        if x-last_label_x >= 55 or i==len(labels)-1:
            chunks += [f'<text x="{x:.2f}" y="{top+ph+26}" text-anchor="middle" font-size="14">{escape(str(label))}</text>']
            last_label_x = x
    for j, (name, color, vector) in enumerate(series):
        if numeric_x:
            points = ' '.join(f'{xpos[i]:.2f},{top+ph-value/vmax*ph:.2f}' for i, value in enumerate(vector))
            chunks += [f'<polyline points="{points}" fill="none" stroke="{color}" stroke-width="4"/>']
            for i,value in enumerate(vector):
                chunks += [f'<circle cx="{xpos[i]:.2f}" cy="{top+ph-value/vmax*ph:.2f}" r="4" fill="{color}"/>']
        else:
            bar_width = pw/len(labels)/(len(series)+1)*0.75
            for i,value in enumerate(vector):
                x = xpos[i] + (j-(len(series)-1)/2)*bar_width*1.2-bar_width/2
                y = top+ph-value/vmax*ph
                chunks += [f'<rect x="{x:.2f}" y="{y:.2f}" width="{bar_width:.2f}" height="{top+ph-y:.2f}" fill="{color}"/>',f'<text x="{x+bar_width/2:.2f}" y="{y-8:.2f}" text-anchor="middle" font-size="14">{value:.2f}</text>']
        chunks += [f'<rect x="{left+j*275}" y="{h-39}" width="22" height="4" fill="{color}"/>', f'<text x="{left+30+j*275}" y="{h-33}" font-size="15">{escape(name)}</text>']
    chunks += [f'<text x="{left}" y="{top-14}" font-size="14">{escape(ylabel)}</text>', f'<text x="{left+pw/2}" y="{h-66}" text-anchor="middle" font-size="14">{escape(x_label)}</text>', '</g></svg>']
    (CHART / f"{slug}.svg").write_text('\n'.join(chunks), encoding="utf-8")


def build():
    rows = []
    modes = [("BW1", 1, 1), ("GRAY8", 1, 8), ("GRAY16", 1, 16), ("RGB8", 3, 8), ("RGB16", 3, 16)]
    dpis = [75, 150, 200, 300, 400, 600, 1200, 2400]
    for name, (width, height) in FORMATS.items():
        for dpi in dpis:
            for mode, channels, bits in modes:
                x, y = px(width, dpi), px(height, dpi)
                size = raw_bytes(x, y, channels, bits)
                rows.append(dict(format=name, width_mm=width, height_mm=height, dpi=dpi, mode=mode, channels=channels, bits_per_channel=bits, width_px=x, height_px=y, pixels=x*y, raw_bytes=size, raw_mib=round(size/2**20,6)))
    save("dpi-storage-matrix", rows)
    selected = [150, 300, 600, 1200]
    chart("dpi-storage-matrix", "Разрешение и несжатый RGB: A4", "Модель: 8 бит на канал; контейнер и метаданные не включены", selected, [("RGB 8 бит", "#126248", [raw_bytes(px(210,d),px(297,d),3,8)/2**20 for d in selected]), ("RGB 16 бит", "#925018", [raw_bytes(px(210,d),px(297,d),3,16)/2**20 for d in selected])], "МиБ на изображение", "Разрешение сканирования, dpi (точки равномерно по категориям)")

    rows = []
    for pages in range(1, 101):
        for jobs in [1, 10, 100]:
            for pack_price in [400, 600, 800]:
                simplex, duplex = pages*jobs, math.ceil(pages/2)*jobs
                rows.append(dict(pages_per_job=pages, jobs=jobs, pack_price_rub=pack_price, logical_pages=pages*jobs, simplex_sheets=simplex, duplex_sheets=duplex, saved_sheets=simplex-duplex, saving_percent=round((simplex-duplex)/simplex*100,6), saved_rub=round((simplex-duplex)*pack_price/500,6), combined_job_duplex_sheets=math.ceil(pages*jobs/2)))
    save("duplex-paper-savings", rows)
    labels = [1, 2, 3, 5, 10, 20, 50, 100]
    chart("duplex-paper-savings", "Экономия бумаги зависит от длины задания", "Модель: каждое задание начинается с нового листа; без брака", labels, [("Сэкономлено, %", "#126248", [(n-math.ceil(n/2))/n*100 for n in labels])], "% листов", "Страниц в одном задании (категории)")

    def costs(n):
        a = 12000 + max(0, n-100)*3
        b = 26000 + max(0, n-1000)*0.65
        ac = 12000 + math.ceil(max(0,n-100)/1000)*3000
        bc = 26000 + math.ceil(max(0,n-1000)/4000)*2600
        return a, b, ac, bc
    rows = []
    for monthly in range(5, 1001, 5):
        for months in [12, 24, 36, 60]:
            n = monthly*months
            a,b,ac,bc = costs(n)
            rows.append(dict(monthly_pages=monthly, months=months, pages=n, a_device_rub=12000, b_device_rub=26000, a_starter_pages=100, b_starter_pages=1000, a_rub_per_replacement_page=3, b_rub_per_replacement_page=0.65, a_material_rub=a, b_material_rub=round(b,2), a_cash_rub=ac, b_cash_rub=bc, b_advantage_material_rub=round(a-b,2), b_advantage_cash_rub=ac-bc))
    save("printer-cost-crossover", rows)
    labels = [0,1000,3000,5000,6000,10000,20000]
    chart("printer-cost-crossover", "Когда более дорогой принтер становится дешевле", "Учебные цены: устройство A 12 000 ₽, B 26 000 ₽; расход 3 и 0,65 ₽/стр.", labels, [("A: устройство + ресурс", "#925018", [costs(n)[0] for n in labels]), ("B: устройство + ресурс", "#126248", [costs(n)[1] for n in labels])], "Стоимость, ₽", "Всего отпечатанных страниц (категории)")

    rows=[]
    for n in [50,100,250,500,1000,2000,5000,10000]:
        for starter in [0,100,300,500,1000]:
            for yield_ in [500,1500,3000]:
                for price in [1200,2400,4800]:
                    paid=max(0,n-starter)
                    rows.append(dict(total_pages=n, starter_pages=starter, replacement_yield_pages=yield_, replacement_price_rub=price, replacement_pages=paid, replacements_to_buy=math.ceil(paid/yield_), used_resource_rub=round(paid*price/yield_,6), cash_rub=math.ceil(paid/yield_)*price, starter_avoided_material_rub=round(min(n,starter)*price/yield_,6)))
    save("starter-cartridge-impact",rows)
    labels=[0,100,300,500,1000]
    chart("starter-cartridge-impact","Стартовый ресурс: расход и реальные покупки","Модель для 1 000 страниц: сменный ресурс 1 500 стр., цена 2 400 ₽",labels,[("Израсходованная доля", "#126248", [max(0,1000-s)*2400/1500 for s in labels]),("Покупки картриджей", "#925018", [math.ceil(max(0,1000-s)/1500)*2400 for s in labels])],"Расходники, ₽","Страниц на стартовом картридже (категории)")

    rows=[]
    for n in [100,500,1000,5000]:
        for scan in [5,10,20,40,80]:
            for handling in [5,10,20,40]:
                for batch in [1,2,4]:
                    for overhead in [30,60,120]:
                        for redo in [0,0.02,0.1]:
                            retries=math.ceil(n*redo)
                            passes=math.ceil(n/batch)+math.ceil(retries/batch)
                            seconds=passes*scan+(n+retries)*handling+math.ceil(n/100)*overhead
                            rows.append(dict(originals=n, scan_seconds_per_pass=scan, handling_seconds_per_item=handling, items_per_pass=batch, overhead_seconds_per_100=overhead, retry_fraction=redo, retry_items=retries, passes=passes, total_seconds=seconds, hours=round(seconds/3600,6), four_hour_sessions=math.ceil(seconds/14400)))
    save("scan-time-batch-model",rows)
    labels=[1,2,4]
    chart("scan-time-batch-model","Почему раскладка пачки не ускоряет всё в четыре раза","Модель 1 000 оригиналов: 40 с/проход, 20 с/оригинал, 60 с на 100 оригиналов",labels,[("Без повторов", "#126248", [(math.ceil(1000/b)*40+1000*20+600)/3600 for b in labels]),("10% повторов", "#925018", [(math.ceil(1000/b)*40+math.ceil(100/b)*40+1100*20+600)/3600 for b in labels])],"Часов работы","Оригиналов за проход (категории)")

    rows=[]
    for name,(width,height) in {k:FORMATS[k] for k in ["photo_10x15","photo_13x18","A4","film_24x36"]}.items():
        for dpi in [150,300,600,1200,2400,3200,4800]:
            for ppi in [100,150,200,240,300,360]:
                for fraction in [1,0.75,0.5,0.25]:
                    x,y=px(width,dpi),px(height,dpi)
                    cx,cy=math.floor(x*math.sqrt(fraction)),math.floor(y*math.sqrt(fraction))
                    rows.append(dict(original=name, original_width_mm=width, original_height_mm=height, scan_dpi=dpi, print_ppi=ppi, retained_area_fraction=fraction, source_width_px=x, source_height_px=y, crop_width_px=cx, crop_height_px=cy, output_width_cm=round(cx/ppi*2.54,6), output_height_cm=round(cy/ppi*2.54,6)))
    save("photo-print-pixel-limits",rows)
    labels=[150,300,600,1200]
    chart("photo-print-pixel-limits","Скан 10×15 см: допустимый размер по пикселям","Вывод 300 ppi; граница числа пикселей, а не гарантия детализации",labels,[("Без кадрирования", "#126248", [px(150,d)/300*2.54 for d in labels]),("Остаётся 25% площади", "#925018", [math.floor(px(150,d)*0.5)/300*2.54 for d in labels])],"Длинная сторона отпечатка, см","Разрешение скана, dpi (категории)")

    rows=[]
    fixture_dir=OUT/"fixtures"
    fixture_dir.mkdir(parents=True,exist_ok=True)
    for pattern in ["lines", "gradient", "noise"]:
        for side in [256,512,1024]:
            gray=Image.new("L",(side,side),255)
            if pattern=="lines":
                draw=ImageDraw.Draw(gray)
                for y in range(16,side-16,16):
                    draw.rectangle((16,y,side-17,y+2),fill=0)
                    draw.rectangle((16,y+5,side//2,y+7),fill=80)
            elif pattern=="gradient":
                gray.putdata([round(x*255/(side-1)) for _ in range(side) for x in range(side)])
            else:
                rng=random.Random(20261014+side)
                gray.putdata([rng.randrange(256) for _ in range(side*side)])
            gray.save(fixture_dir/f"{pattern}-{side}.png")
            for mode in ["L","RGB"]:
                image=gray.convert(mode)
                for compression in ["raw","tiff_adobe_deflate"]:
                    stream=io.BytesIO()
                    image.save(stream,format="TIFF",compression=compression)
                    data=stream.getvalue()
                    decoded=Image.open(io.BytesIO(data))
                    assert decoded.mode==mode and decoded.size==image.size and decoded.tobytes()==image.tobytes()
                    rows.append(dict(pattern=pattern,width_px=side,height_px=side,mode=mode,channels=1 if mode=="L" else 3,bits_per_channel=8,compression=compression,pixel_bytes=side*side*(1 if mode=="L" else 3),file_bytes=len(data),file_kib=round(len(data)/1024,6),roundtrip_identical=True,fixture_sha256=hashlib.sha256(gray.tobytes()).hexdigest(),pillow_version=pillow_version))
    save("rgb-gray-storage",rows)
    def measured(pattern,mode,compression):
        return next(r["file_bytes"]/1024 for r in rows if r["pattern"]==pattern and r["mode"]==mode and r["compression"]==compression and r["width_px"]==1024)
    chart("rgb-gray-storage","TIFF: одинаковые серые пиксели в RGB и Grayscale","Измерены синтетические 1 024×1 024 изображения; DEFLATE без потерь",["Полосы","Градиент","Шум"],[("Grayscale DEFLATE", "#126248", [measured(p,"L","tiff_adobe_deflate") for p in ["lines","gradient","noise"]]),("RGB DEFLATE", "#925018", [measured(p,"RGB","tiff_adobe_deflate") for p in ["lines","gradient","noise"]])],"Размер файла, КиБ","Тип собственноручно сгенерированного изображения")

    rows=[]
    for size in [10,25,50,100,250,500,1000,2000]:
        for local in [1,2,3]:
            for cloud in [0,1]:
                for reserve in [0,0.1,0.25,0.5]:
                    for growth in [0,0.05,0.1,0.2]:
                        for years in [1,3,5,10]:
                            future=size*(1+growth)**years
                            rows.append(dict(initial_gib=size,local_copies=local,cloud_copies=cloud,reserve_fraction=reserve,annual_growth_fraction=growth,years=years,future_data_gib=round(future,6),required_each_local_gib=round(future*(1+reserve),6),total_local_capacity_gib=round(future*(1+reserve)*local,6),cloud_data_gib=round(future*cloud,6),all_copy_data_gib=round(future*(local+cloud),6),required_each_local_decimal_tb=round(future*(1+reserve)*2**30/10**12,6)))
    save("archive-copy-budget",rows)
    labels=[0,1,3,5,10]
    chart("archive-copy-budget","Рост архива и необходимая ёмкость одной копии","Начало 100 ГиБ, резерв 25%; рост — сценарий, а не прогноз",labels,[("Рост 5% в год", "#126248", [100*1.05**y*1.25 for y in labels]),("Рост 20% в год", "#925018", [100*1.2**y*1.25 for y in labels])],"Ёмкость одной копии, ГиБ","Годы планирования (категории)")

    rows=[]
    for yield_ in [500,1500,3000,10000]:
        for factor in [0.25,0.5,0.75,1,1.25,1.5]:
            for price in [1200,2400,4800]:
                for n in [100,1000,5000,10000]:
                    actual=yield_*factor
                    rows.append(dict(declared_yield_pages=yield_,realized_yield_factor=factor,replacement_price_rub=price,total_pages=n,modeled_effective_yield_pages=actual,rub_per_page=round(price/actual,6),used_resource_rub=round(n*price/actual,6),replacements_to_buy=math.ceil(n/actual),cash_rub=math.ceil(n/actual)*price,cost_multiplier_vs_declared=round(1/factor,6)))
    save("ink-yield-sensitivity",rows)
    labels=[25,50,75,100,125,150]
    chart("ink-yield-sensitivity","Недобор ресурса нелинейно увеличивает цену страницы","Модель: расходник 2 400 ₽ на заявленные 1 500 страниц; без других затрат",labels,[("Стоимость страницы", "#126248", [2400/(1500*f/100) for f in labels])],"₽ за страницу","Реализованный ресурс, % от заявленного (категории)")

    rows=[]
    paper={"A4":(210,297),"A3":(297,420),"Letter":(215.9,279.4)}
    pieces={"photo_10x15":(100,150),"photo_13x18":(130,180),"photo_9x13":(90,130),"card_85x55":(85,55),"A6":(105,148),"label_70x35":(70,35)}
    for sheet,(sw,sh) in paper.items():
        for piece,(iw,ih) in pieces.items():
            for margin in [0,3,5,10,15]:
                for gap in [0,2,5,10]:
                    ww,hh=sw-2*margin,sh-2*margin
                    normal=(math.floor((ww+gap)/(iw+gap)),math.floor((hh+gap)/(ih+gap)))
                    turned=(math.floor((ww+gap)/(ih+gap)),math.floor((hh+gap)/(iw+gap)))
                    nc,rc=math.prod(normal),math.prod(turned)
                    count=max(nc,rc)
                    rows.append(dict(sheet=sheet,sheet_width_mm=sw,sheet_height_mm=sh,piece=piece,piece_width_mm=iw,piece_height_mm=ih,margin_mm=margin,gap_mm=gap,normal_grid_count=nc,rotated_grid_count=rc,best_uniform_grid_count=count,uniform_rotation_degrees=90 if rc>nc else 0,covered_sheet_percent=round(count*iw*ih/(sw*sh)*100,6),sheet_area_mm2=sw*sh))
    save("paper-format-efficiency",rows)
    labels=[0,3,5,10,15]
    def layouts(piece):
        return [next(r["best_uniform_grid_count"] for r in rows if r["sheet"]=="A4" and r["piece"]==piece and r["margin_mm"]==margin and r["gap_mm"]==0) for margin in labels]
    chart("paper-format-efficiency","Поле принтера меняет раскладку на A4","Модель прямоугольной сетки; без промежутка и смешанной ориентации",labels,[("Карточки A6", "#126248", layouts("A6")),("Фото 9×13 см", "#925018",layouts("photo_9x13"))],"Фрагментов на листе","Одинаковое поле с каждой стороны, мм (категории)")

    OUT.mkdir(parents=True,exist_ok=True)
    report={"date":"2026-10-14","python_version":sys.version,"pillow_version":pillow_version,"libtiff_version":features.version_codec("libtiff"),"zlib_version":features.version_codec("zlib"),"studies":DATA,"total_rows":sum(d["rows"] for d in DATA.values()),"scope":"9 transparent arithmetic models plus one actual lossless encoding test on synthetic images. No hardware measurements, market price observations or traffic forecasts."}
    (OUT/"manifest.json").write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding="utf-8")
    (CSV/"studies-v3.py").write_bytes(Path(__file__).read_bytes())
    print(json.dumps(report,ensure_ascii=False,indent=2))


if __name__=="__main__":
    build()
