#!/usr/bin/env python3
"""Combine 50 measured size candidates, apply declared gates, and publish decisions."""
from __future__ import annotations

import csv
import html
import json
import math
import re
import shutil
import sys
from pathlib import Path

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np

HERE=Path(__file__).resolve().parent
ROOT=HERE.parents[1]
sys.path.insert(0,str(ROOT/"전처리"))
from compare_graph_representations import prepared_cad
from representations import make_multiview
from extend_sizes import DIRECTIONS, raster_geometry

PARTS={"AC28":ROOT,"ME1a_DT":ROOT/"experiments/ME1a_DT"}
SIZES={"image":(64,128,256,512,1024,2048),
       "voxel":(32,64,128,256,512,1024),
       "graph":(3000,5000,6000,12000,24000,48000,96000),
       "point":(2048,4096,8192,16384,32768,65536)}
LABEL={"image":"Image","voxel":"Voxel","graph":"Graph","point":"Point"}


def read(path:Path)->list[dict]:
    with path.open(newline="",encoding="utf8") as f:return list(csv.DictReader(f))


def save(path:Path,rows:list[dict])->None:
    with path.open("w",newline="",encoding="utf8") as f:
        w=csv.DictWriter(f,fieldnames=list(dict.fromkeys(k for r in rows for k in r)))
        w.writeheader();w.writerows(rows)


def measured_rows()->list[dict]:
    baseline=HERE/"baseline_measurements.csv"
    if not baseline.exists():shutil.copy2(HERE/"measurements.csv",baseline)
    rows=read(baseline)+read(HERE/"extended_measurements.csv")
    for part in PARTS:
        folder=HERE/"candidates"/part
        with np.load(folder/"image_2048.npz") as z:reference=z["visibility"]
        for row in rows:
            if row["part"]!=part or row["modality"]!="image":continue
            size=int(row["candidate"])
            with np.load(folder/f"image_{size}.npz") as z:vis=z["visibility"]
            ix=np.rint(np.linspace(0,size-1,2048)).astype(int)
            up=vis[:,ix][:,:,ix]
            row["fidelity_metric"]="silhouette_iou_to_2048"
            row["fidelity"]=float((up&reference).sum()/max(1,(up|reference).sum()))
            row["secondary_metric"]="visible_pixels_all_six_views"
            row["secondary"]=int(vis.sum())
        # One common physical camera per part prevents a changing graph bbox
        # from affecting the graph-size silhouette comparison.
        ref=prepared_cad(part_graph(part,12000))
        fov=float(np.ptp(ref["pos_mm"],axis=0).max()*1.02)
        ref_masks=np.stack([raster_geometry(ref,v,128,fov)[1] for v in DIRECTIONS])
        for row in rows:
            if row["part"]!=part or row["modality"]!="graph":continue
            size=int(row["candidate"])
            if size==12000:ious=1.
            else:
                graph=prepared_cad(part_graph(part,size))
                masks=np.stack([raster_geometry(graph,v,128,fov)[1] for v in DIRECTIONS])
                ious=float((masks&ref_masks).sum()/max(1,(masks|ref_masks).sum()))
            row["secondary"]=ious
            row["secondary_metric"]="fixed_fov_six_view_iou_to_12k"
            print(part,"graph",size,"view IoU",round(ious,5),flush=True)
    assert len(rows)==50 and len({(r["part"],r["modality"],r["candidate"]) for r in rows})==50
    save(HERE/"measurements.csv",rows)
    return rows


def part_graph(part:str,size:int)->Path:
    if size>12000:return HERE/"candidates"/part/"high_graphs"/f"graph_{size}.npz"
    return PARTS[part]/"data_processed/graphs"/f"graph_{size}.npz"


def scores_for(rows:list[dict])->list[dict]:
    out=[]
    for modality,sizes in SIZES.items():
        minimum=min(int(r["native_elements"]) for r in rows if r["modality"]==modality)
        for size in sizes:
            group=[r for r in rows if r["modality"]==modality and int(r["candidate"])==size]
            assert len(group)==2
            cost=20*math.sqrt(minimum/max(int(r["native_elements"]) for r in group))
            if modality=="image":
                worst=min(float(r["fidelity"]) for r in group)
                pitch=max(float(r["physical_unit_mm"]) for r in group)
                quality=50*min(1,worst/.975)
                detail=30*min(1,2/pitch)
                gate=worst>=.975 and pitch<=2
                evidence=f"2048-reference view IoU {worst:.4f}; {pitch:.3f} mm/pixel; 10 mm span ≈ {10/pitch:.1f} pixels"
            elif modality=="voxel":
                worst=max(float(r["fidelity"]) for r in group)
                pitch=max(float(r["physical_unit_mm"]) for r in group)
                quality=50*min(1,15/worst);detail=30*min(1,15/pitch)
                gate=worst<=15 and pitch<=15
                evidence=f"worst occupied-center P95 {worst:.2f} mm; pitch {pitch:g} mm"
            elif modality=="graph":
                worst=max(float(r["fidelity"]) for r in group)
                iou=min(float(r["secondary"]) for r in group)
                quality=50*min(1,.35/worst);detail=30*min(1,iou/.99)
                gate=worst<=.35 and iou>=.99
                evidence=f"worst surface P95 {worst:.4f} mm; view IoU {iou:.4f}"
            else:
                worst=max(float(r["fidelity"]) for r in group)
                quality=50*min(1,10/worst);detail=30*min(1,size/8192)
                gate=worst<=10
                evidence=f"worst CAD-node coverage P95 {worst:.2f} mm"
            out.append(dict(modality=modality,candidate=size,evidence=evidence,
                            quality_points_of_50=round(quality,2),
                            detail_points_of_30=round(detail,2),
                            cost_points_of_20=round(cost,2),
                            total_of_100=round(quality+detail+cost,2),
                            passes_gate=gate,selected=False))
        next(r for r in out if r["modality"]==modality and r["passes_gate"])["selected"]=True
    save(HERE/"decision_scores.csv",out)
    return out


def plots(rows:list[dict],scores:list[dict])->None:
    fig,ax=plt.subplots(1,2,figsize=(12,4.2),constrained_layout=True)
    for part in PARTS:
        sub=sorted((r for r in rows if r["part"]==part and r["modality"]=="image"),key=lambda r:int(r["candidate"]))
        ax[0].plot([int(r["candidate"]) for r in sub],[float(r["fidelity"]) for r in sub],"o-",label=part)
    ax[0].axhline(.975,color="#bd673b",ls="--",label="decision gate 0.975")
    ax[0].set_xscale("log",base=2);ax[0].set_xticks(SIZES["image"],map(str,SIZES["image"]))
    ax[0].set_ylim(.78,1.02);ax[0].set_xlabel("pixels per side");ax[0].set_ylabel("silhouette IoU vs 2048")
    sizes=np.asarray(SIZES["image"])
    ax[1].plot(sizes,6*17*sizes*sizes*4/1e6,"o-",color="#a45a2a")
    ax[1].set_xscale("log",base=2);ax[1].set_yscale("log");ax[1].set_xticks(sizes,map(str,sizes))
    ax[1].set_xlabel("pixels per side");ax[1].set_ylabel("full 17-channel float32 tensor (MB)")
    for a in ax:a.grid(alpha=.2)
    ax[0].legend(fontsize=8);fig.suptitle("Six-view CAD image resolution: measured geometry and calculated tensor memory")
    fig.savefig(HERE/"image_high_resolution_reference.png",dpi=165);plt.close(fig)
    fig,ax=plt.subplots(1,4,figsize=(17,4.2),constrained_layout=True)
    for a,modality in zip(ax,SIZES):
        sub=[r for r in scores if r["modality"]==modality]
        a.bar(range(len(sub)),[r["total_of_100"] for r in sub],color=["#087f8c" if r["selected"] else "#aab9c6" if r["passes_gate"] else "#dcb09a" for r in sub])
        a.set_xticks(range(len(sub)),[str(r["candidate"]) for r in sub],rotation=45)
        a.set_ylim(0,105);a.set_title(LABEL[modality]);a.grid(axis="y",alpha=.15)
    ax[0].set_ylabel("decision score / 100")
    fig.suptitle("50 fidelity + 30 detail + 20 cost; the smallest passing size is selected")
    fig.savefig(HERE/"extended_scorecard.png",dpi=165);plt.close(fig)
    fig,ax=plt.subplots(1,3,figsize=(15,4),constrained_layout=True)
    for part in PARTS:
        for a,modality,key,ylabel in zip(ax,("voxel","graph","point"),("fidelity",)*3,
                                           ("Occupied-center P95 (mm)","CAD surface P95 (mm)","CAD-node coverage P95 (mm)")):
            sub=sorted((r for r in rows if r["part"]==part and r["modality"]==modality),key=lambda r:int(r["candidate"]))
            a.plot([int(r["candidate"]) for r in sub],[float(r[key]) for r in sub],"o-",label=part)
            a.set_xscale("log",base=2);a.set_title(LABEL[modality]);a.set_xlabel("requested size");a.set_ylabel(ylabel);a.grid(alpha=.2)
    ax[0].legend(fontsize=8)
    fig.savefig(HERE/"extended_geometry_curves.png",dpi=165);plt.close(fig)


def selected_contract(scores:list[dict])->None:
    contract_path=HERE/"standard_sizes.json"
    baseline=HERE/"baseline_standard_sizes.json"
    if not baseline.exists():shutil.copy2(contract_path,baseline)
    contract=json.loads(baseline.read_text())
    chosen={r["modality"]:r["candidate"] for r in scores if r["selected"]}
    assert chosen=={"image":512,"voxel":64,"graph":5000,"point":8192},chosen
    contract["image"].update(height=512,width=512,pixel_pitch_mm=980/511,
                             source="graph_5000",format="six full 17-channel orthographic views with validity masks")
    contract["selection_rule"]="smallest candidate passing every representation-specific gate among 50 part/size measurements"
    contract["hard_gates"]["image"]="worst six-view silhouette IoU to 2048 raster >= 0.975 and pixel pitch <= 2 mm (at least five pixels across 10 mm weak-anchor scale)"
    contract["image"]["reference_resolution"] = 2048
    contract["image"]["estimated_full_tensor_mb"] = round(6*17*512*512*4/1e6,3)
    contract["candidates_per_part"]={k:list(v) for k,v in SIZES.items()}
    contract_path.write_text(json.dumps(contract,indent=2,ensure_ascii=False))
    for part,root in PARTS.items():
        dest=HERE/"standardized"/part/"image_512.npz"
        cad=prepared_cad(root/"data_processed/graphs/graph_5000.npz")
        mv=make_multiview(cad,512,980.)
        np.savez_compressed(dest,**mv)
        # The 256 file remains available under candidates; standardized/ contains
        # only the current selected image size.
        (dest.parent/"image_256.npz").unlink(missing_ok=True)
        print("Full selected CNN input",part,dest.stat().st_size/1e6,"MB",flush=True)


def publish(rows:list[dict],scores:list[dict])->None:
    score_map={(r["modality"],int(r["candidate"])):r for r in scores}
    by={(r["part"],r["modality"],int(r["candidate"])):r for r in rows}
    table_rows=[]
    for modality,sizes in SIZES.items():
        selected=next(r["candidate"] for r in scores if r["modality"]==modality and r["selected"])
        candidates=", ".join(f"{n:,} ({score_map[modality,n]['total_of_100']:.2f})" for n in sizes)
        evidence=score_map[modality,selected]["evidence"]
        table_rows.append(f"<tr><td>{LABEL[modality]}</td><td>{candidates}</td><td>{selected:,}"
                          f"{' CAD 입력' if modality=='graph' else ''}</td><td>{html.escape(evidence)}</td></tr>")
    table_html="<div class='scroll'><table><thead><tr><th>표현</th><th>후보 (점수)</th><th>선택</th><th>측정 근거</th></tr></thead><tbody>"+"".join(table_rows)+"</tbody></table></div>"
    details=[]
    for modality,sizes in SIZES.items():
        details.append(f"<h2>{LABEL[modality]} 후보 상세</h2><div class='scroll'><table><thead><tr><th>크기</th><th>AC28 측정</th><th>ME1a 측정</th><th>물리 간격</th><th>전체 입력 비용 MB</th><th>점수</th><th>게이트</th></tr></thead><tbody>")
        for n in sizes:
            a,b=(by[p,modality,n] for p in PARTS)
            s=score_map[modality,n]
            value=lambda r:f"{float(r['fidelity']):.4f}" if modality in ("image","graph") else f"{float(r['fidelity']):.2f} mm"
            pitch=f"{float(a['physical_unit_mm']):.4f}" if a["physical_unit_mm"] else "—"
            details.append(f"<tr><td>{n:,}</td><td>{value(a)}</td><td>{value(b)}</td><td>{pitch}</td>"
                           f"<td>{float(a['raw_feature_mb']):,.2f}</td><td>{s['total_of_100']:.2f}</td>"
                           f"<td>{'선택' if s['selected'] else '통과' if s['passes_gate'] else '미달'}</td></tr>")
        details.append("</tbody></table></div>")
    page='''<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>확장 크기 결정</title><link rel="stylesheet" href="../../reports/260929/assets.css"></head><body><main>
<h1>AC28 + ME1a 전처리 크기 결정: 50개 후보</h1><p><a href="../../reports/260929/preprocessing.html">날짜별 전처리 보고서</a> · <a href="candidates/index.html">모든 후보 PNG</a> · <a href="standardized/index.html">선택된 전체 입력 NPZ</a></p>
<p>같은 CAD graph_5000에서 이미지 64–2048, 등방 표면 복셀 32–1024, 점군 2048–65536을 생성했습니다. 그래프는 각 부품의 원 STL에서 요청 노드 3k–96k로 단순화했습니다. 아래 값은 정확도가 아니라 기하 충실도 및 입력 비용입니다.</p>
<h2>선택 결과</h2>'''+table_html+'''
<p><strong>변경:</strong> 2048 래스터를 고해상도 참조로 두면 256 이미지의 최악 silhouette IoU는 0.9526이고 512는 0.9788입니다. 10 mm 약한 anchor 길이에 최소 5픽셀, 참조 IoU ≥0.975를 동시에 요구하여 512를 선택했습니다. 이전 256 자기참조 IoU 1.000은 해상도 검증 근거가 아니었습니다.</p>
<p>게이트 통과 후보 중 가장 작은 입력을 선택합니다. 설명용 점수는 충실도 50 + 공간 세부 30 + 상대 비용 20입니다. 512 CNN 전체 6×17×512² float32 텐서는 107 MB이며, 2048은 1.71 GB입니다. 512–2048 후보 NPZ는 실제 깊이·가시성만 저장한 진단이고 선택한 512의 전체 17채널 텐서는 standardized/에 별도로 생성했습니다. 큰 복셀 NPZ는 sparse 12채널 입력을 저장하고 중간 triangle sample 대응표는 생략합니다.</p>
<div class="gallery"><figure><img src="image_high_resolution_reference.png" alt="2048 참조 이미지 IoU와 메모리"><figcaption>이미지 해상도 대 참조 실루엣 IoU 및 전체 텐서 비용</figcaption></figure><figure><img src="extended_scorecard.png" alt="확장 후보 점수"><figcaption>50개 후보의 공통 결정 점수</figcaption></figure><figure><img src="extended_geometry_curves.png" alt="복셀 그래프 점군 오차"><figcaption>복셀, 그래프, 점군의 실제 기하 오차</figcaption></figure></div>
<p>그래프 5k는 형상 문맥 입력입니다. 현재 국소 약한 위험 출력은 12k를 유지합니다. 24k–96k 추가 그래프는 형상 오차를 낮추지만 5k 문맥 게이트를 이미 통과하고, 추가 노드의 위험 예측 성능은 학습하지 않았습니다. 2048 자기참조 IoU 1.0도 결함 검출 점수가 아닙니다.</p>'''+"".join(details)+'''
<h2>재현과 파일</h2><p><a href="extend_sizes.py">확장 후보 생성 코드</a> · <a href="finalize_extended.py">비교·결정 코드</a> · <a href="measurements.csv">전체 측정 CSV</a> · <a href="decision_scores.csv">전체 점수 CSV</a> · <a href="standard_sizes.json">선택 계약 JSON</a> · <a href="candidates/index.html">각 후보 PNG</a></p>
<p>새 크기에서의 CNN, 복셀, 고해상도 GNN 예측 정확도는 아직 측정하지 않았습니다. 기존 모델 훈련 결과는 별도 모델 보고서의 과거 입력 크기에 한정됩니다.</p></main></body></html>'''
    (HERE/"baseline_256.html").write_text((HERE/"index.html").read_text()) if not (HERE/"baseline_256.html").exists() else None
    (HERE/"index.html").write_text(page)
    report=ROOT/"reports/260929/preprocessing.html"
    text=report.read_text()
    prefix='<h2 id="selection">1. 후보 크기와 선택</h2>'
    next_marker='<div class="gallery">'
    start=text.index(prefix);end=text.index(next_marker,start)
    replacement=prefix+'<p>총 50개 후보를 AC28·ME1a에서 측정했습니다. 이미지에는 2048 실제 래스터를 참조로 쓰며 10 mm 약한 anchor 길이의 최소 5픽셀과 IoU 0.975 게이트를 적용합니다. 모든 점수는 기하·비용 결정용이며 모델 정확도가 아닙니다.</p>'+table_html+'\n'
    text=text[:start]+replacement+text[end:]
    text=text.replace('image_sizes.png','image_high_resolution_reference.png')
    text=text.replace('voxel_sizes.png','extended_geometry_curves.png')
    text=text.replace('공통 980 mm FOV의 6개 직교 뷰, 256²','공통 980 mm FOV의 6개 직교 뷰, 512²')
    text=text.replace('256의 저장 대응 산술 오차 최대 0.000259 mm','512 전체 채널 입력 생성 및 2048 참조 IoU 최악 0.9788')
    text=text.replace('256 이미지와 64 복셀 선택 입력','512 이미지와 64 복셀 선택 입력')
    text=text.replace('선정 NPZ는','확장 크기별 실제 수치·방법은 <a href="../../experiments/preprocess_size_desicisions/index.html">50개 후보 결정 보고서</a>에서 확인할 수 있습니다. 선정 NPZ는')
    report.write_text(text)
    readme=HERE/"README.md"
    readme.write_text('''# AC28 + ME1a preprocessing size decisions (expanded)

## What

Fifty AC28/ME1a candidate inputs were measured: images 64, 128, 256, 512, 1024, 2048; surface voxels 32, 64, 128, 256, 512, 1024; requested graph nodes 3k, 5k, 6k, 12k, 24k, 48k, 96k; CAD points 2048, 4096, 8192, 16384, 32768, 65536. Selected: six 512² images with 980 mm FOV; 64×8×40, 15 mm surface voxels; 5k requested CAD context graph; 8192 CAD points. The localized weak-risk output remains 12k.

## How

The first three sizes per modality come from `build.py`; extra image, voxel and point candidates come from `extend_sizes.py`; the three extra graph budgets are generated from each source STL with `전처리/cad_graphs.py`. Run `finalize_extended.py` after those steps. It uses a measured 2048 raster as the high-resolution image reference, one common physical camera per part for graph comparisons, and the existing occupied-center/CAD-node distance measures for voxel and point. Run `validate_standardized.py`, `render_candidate_samples.py`, and `publish_candidate_gallery.py` afterward. Raw values: `measurements.csv`, scores: `decision_scores.csv`, size contract: `standard_sizes.json`. The browser report is `index.html`; every candidate has a PNG in `candidates/`.

## Why

The smallest candidate passing its gate wins. The explanatory score is fidelity 50 + spatial detail 30 + relative input cost 20. Image needs 2048-reference six-view silhouette IoU ≥0.975 and ≤2 mm/pixel, corresponding to at least five pixels across the 10 mm weak-anchor scale. The worst 256 image IoU is 0.9526; 512 reaches 0.9788 at 1.918 mm/pixel. The old 256 self-reference IoU 1.0 was inadequate evidence. A full six-view 17-channel 512 float32 tensor takes 107 MB; 2048 takes 1.71 GB. The voxel gate is P95 occupied-center distance ≤15 mm and pitch ≤15 mm; graph context gate is surface P95 ≤0.35 mm and fixed-camera IoU ≥0.99 to the 12k graph; point gate is CAD-node coverage P95 ≤10 mm.

These are preprocessing decisions, not proof of trained-model gain. Historical CNN and voxel checkpoints used 128 image and 32 voxel inputs. The present 12k risk GNN uses 12k directly; 5k geometry context does not reproduce the same-run ME1a 12k risk score under its current nearest-node output mapping. ME1a weak labels and registration remain unvalidated.
''')
    print("Published expanded decision",flush=True)


def main()->None:
    rows=measured_rows();scores=scores_for(rows)
    plots(rows,scores);selected_contract(scores);publish(rows,scores)

if __name__=="__main__":main()
