#!/usr/bin/env python3
"""Reproduce corrugated-flute reference calculations from supplied CSV inputs.
Python 3.9+, standard library only. Does not fetch or re-verify source websites.
Run: python flute_geometry_v2.py
Outputs are written alongside this script, or to --out PATH.
"""
from __future__ import annotations
import argparse
import csv
import json
import math
from decimal import Decimal, ROUND_HALF_UP
from fractions import Fraction
from pathlib import Path
from typing import Optional

VERSION='2.1.0'
VERIFIED='2026-09-15'
MM_IN=Decimal('25.4')
MM_FT=Decimal('304.8')


def read_csv(path: Path) -> list[dict[str, str]]:
    if not path.is_file():
        raise FileNotFoundError(f'Required input missing: {path}')
    with path.open(encoding='utf-8', newline='') as handle:
        rows=list(csv.DictReader(handle))
    if not rows:
        raise ValueError(f'Input has no records: {path}')
    return rows


def write_csv(path: Path, rows: list[dict]) -> None:
    if not rows:
        raise ValueError(f'Cannot write an empty table: {path}')
    with path.open('w',encoding='utf-8',newline='') as handle:
        writer=csv.DictWriter(handle,fieldnames=list(rows[0]))
        writer.writeheader()
        writer.writerows(rows)


def numeric_range(value: str) -> tuple[Optional[Decimal], Optional[Decimal]]:
    value=value.strip().replace('≤','<=').replace('≥','>=').replace('–','-')
    if value.startswith('<='): return None,Decimal(value[2:])
    if value.startswith('>='): return Decimal(value[2:]),None
    if '-' in value:
        lo,hi=value.split('-',1)
        return Decimal(lo),Decimal(hi)
    d=Decimal(value)
    return d,d


def rounded(d, digits=3) -> str:
    return str(Decimal(str(d)).quantize(Decimal(1).scaleb(-digits),rounding=ROUND_HALF_UP))


def exact_inches_mm(fraction: str) -> Decimal:
    f=Fraction(fraction)
    return Decimal(f.numerator)/Decimal(f.denominator)*MM_IN


def sinusoid_take_up(h: float,p: float,n: int=20000) -> float:
    """Simpson integration of centreline arc length, normalized to one period.
    y=(h/2)*sin(2*pi*x/p); no glue, paper strain, flat tips or crushing model.
    """
    if h<=0 or p<=0 or n<=0 or n%2:
        raise ValueError('Height and pitch must be positive; n must be positive and even.')
    k=math.pi*h/p
    def f(t): return math.sqrt(1+(k*math.cos(2*math.pi*t))**2)
    total=f(0)+f(1)
    total+=sum((4 if i%2 else 2)*f(i/n) for i in range(1,n))
    return total/(3*n)


def build(base: Path,out: Path) -> dict:
    raw=read_csv(base/'flute-source-records.csv')
    mismatch_inputs=read_csv(base/'unit-mismatch-inputs.csv')
    manufacturers=read_csv(base/'manufacturer-source-audit.csv')
    ids={r['record_id'] for r in raw}
    if len(ids)!=len(raw): raise ValueError('Duplicate raw record_id.')
    for r in raw:
        for key in ['publisher','source_title','source_url','date_checked','evidence_role']:
            if not r.get(key): raise ValueError(f"Missing {key}: {r['record_id']}")
    fba={r['flute']:Decimal(r['flutes_per_foot'].replace('about','').strip()) for r in raw if r['record_id'].startswith('fba-')}
    summa={r['flute']:r for r in raw if r['record_id'].startswith('summa-')}
    fefco={r['flute']:r for r in raw if r['record_id'].startswith('fefco-')}
    study={r['flute']:r for r in raw if r['record_id'].startswith('meas-')}
    pitches=[]
    for f in ['G','F','E','D','B','C','A','K']:
        r=summa[f];low,high=numeric_range(r['pitch_mm'])
        minimum=MM_FT/high if high is not None else None
        maximum=MM_FT/low if low is not None else None
        n=fba.get(f)
        match='' if n is None else str((minimum is None or n>=minimum) and (maximum is None or n<=maximum)).lower()
        pitches.append(dict(flute=f,pitch_mm_as_published=r['pitch_mm'],implied_count_min_per_ft='' if minimum is None else rounded(minimum,10),implied_count_max_per_ft='' if maximum is None else rounded(maximum,10),display_min_per_ft='' if minimum is None else rounded(minimum,1),display_max_per_ft='' if maximum is None else rounded(maximum,1),fba_approx_per_ft='' if n is None else str(n),literal_numerical_overlap_only=match,source_record_id=r['record_id'],verified_date=VERIFIED,notes='Arithmetic comparison of different references, not a tolerance or compliance test.'))
    samples=[]
    for f in ['C','B','E']:
        r=study[f]
        h=Decimal(r['height_mm']);p=Decimal(r['pitch_mm']);b=Decimal(r['board_caliper_mm'])
        liners=Decimal(r['upper_liner_mm'])+Decimal(r['lower_liner_mm'])
        diff=b-h;res=diff-liners
        hlo,hhi=numeric_range(summa[f]['height_mm']);plo,phi=numeric_range(summa[f]['pitch_mm'])
        tuf=sinusoid_take_up(float(h),float(p))
        tuf_check=sinusoid_take_up(float(h),float(p),10000)
        if abs(tuf-tuf_check)>1e-9: raise ArithmeticError('Integration convergence check failed.')
        tl,th=numeric_range(fefco[f]['take_up_factor'])
        distance=Decimal(str(tuf))-max(tl,min(Decimal(str(tuf)),th))
        samples.append(dict(flute=f,source_record_id=r['record_id'],flute_height_mm=str(h),flute_period_mm=str(p),image_board_thickness_mm=str(b),upper_liner_mm=r['upper_liner_mm'],medium_material_thickness_mm=r['medium_material_thickness_mm'],lower_liner_mm=r['lower_liner_mm'],board_minus_height_mm=str(diff),sum_liners_mm=str(liners),residual_mm=str(res),derived_flutes_per_m=rounded(Decimal('1000')/p,10),derived_flutes_per_ft=rounded(MM_FT/p,10),display_flutes_per_m=rounded(Decimal('1000')/p,1),display_flutes_per_ft=rounded(MM_FT/p,1),height_within_summa_reference=str(hlo<=h<=hhi).lower(),pitch_within_summa_reference=str(plo<=p<=phi).lower(),sinusoid_take_up_factor=rounded(tuf,10),display_take_up_factor=rounded(tuf,3),fefco_indicative_take_up_range=fefco[f]['take_up_factor'],signed_distance_to_nearest_fefco_boundary=rounded(distance,10),verified_date=VERIFIED,notes='Published image estimates; derived sine model is not a measured paper-consumption result. Residual is arithmetic, not a physical cause determination.'))
    mismatch=[]
    for r in mismatch_inputs:
        mm=exact_inches_mm(r['stated_fraction_inches']);stated=Decimal(r['stated_mm']);gap=mm-stated
        mismatch.append(dict(**r,fraction_converted_mm=str(mm),gap_mm=str(gap),gap_percent_of_stated_mm=str(gap/stated*100),display_converted_mm=rounded(mm,3),display_gap_mm=rounded(gap,3),display_gap_percent=rounded(gap/stated*100,1)))
    fractions=[]
    for r in raw:
        if not r['record_id'].startswith('pakfactory-'):continue
        exact=exact_inches_mm(r['thickness_inches']);display=Decimal(rounded(exact,1));gap=display-exact
        fractions.append(dict(flute=r['flute'],source_record_id=r['record_id'],published_fraction_inches=r['thickness_inches'],exact_mm=str(exact),rounded_to_one_decimal_mm=str(display),rounding_gap_mm=str(gap),rounding_gap_percent_of_exact=str(gap/exact*100),verified_date=VERIFIED,notes='One-decimal metric result is our calculation, not a claim about the origin of all printed charts.'))
    counts=[]
    for f,r in fefco.items():
        lo,hi=numeric_range(r['flutes_per_metre'])
        counts.append(dict(flute_group=f,source_record_id=r['record_id'],published_count_per_m=r['flutes_per_metre'],derived_min_per_ft=str(lo*Decimal('0.3048')),derived_max_per_ft=str(hi*Decimal('0.3048')),display_min_per_ft=rounded(lo*Decimal('0.3048'),1),display_max_per_ft=rounded(hi*Decimal('0.3048'),1),fba_comparison_per_ft=str(fba[f]) if f in fba else '',notes='FGN grouped values are not assigned to F alone.'))
    metrics=dict(source_reference_rows=len(raw),source_reference_publishers=len({r['publisher'] for r in raw}),mismatch_pages=len(mismatch),mismatch_publishers=len({r['publisher'] for r in mismatch}),new_physical_samples_measured=0,image_example_rows=len(samples),source_rows_by_publisher={p:sum(r['publisher']==p for r in raw) for p in sorted({r['publisher'] for r in raw})},count_300mm_to_foot_multiplier='1.016',count_300mm_to_foot_increase_percent='1.6',count_300mm_shortfall_percent_of_per_foot=str((Decimal('304.8')-Decimal('300'))/Decimal('304.8')*100),one_eighth_inch_exact_mm=str(exact_inches_mm('1/8')))
    if (metrics['source_reference_rows'],metrics['source_reference_publishers'])!=(44,8):
        raise ValueError('Source corpus count changed; update page and version before releasing.')
    out.mkdir(parents=True,exist_ok=True)
    for filename,table in [('derived-cross-convention.csv',pitches),('derived-image-study.csv',samples),('unit-mismatch-audit.csv',mismatch),('derived-fractions.csv',fractions),('derived-fefco-counts.csv',counts)]:write_csv(out/filename,table)
    bundle=dict(dataset='Corrugated flute published-reference reconciliation',version=VERSION,last_verified=VERIFIED,method='Source-labeled compilation; exact unit conversions; arithmetic residuals; explicitly hypothetical sinusoidal geometry model. No new physical board measurements.',limits='Counts of source rows are not counts of specimens. Manufacturer/audit publishers are not independent laboratories. No causation or copying lineage inferred from repeated wording.',calculation_constants={'mm_per_inch':'25.4','mm_per_foot':'304.8'},metrics=metrics,source_records=raw,unit_mismatch_audit=mismatch,pitch_count_comparison=pitches,published_image_examples_and_derivations=samples,fraction_conversions=fractions,fefco_count_conversions=counts,manufacturer_source_audit=manufacturers)
    (out/'corrugated-flute-reference.json').write_text(json.dumps(bundle,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
    return bundle


def main() -> None:
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--out',type=Path,help='Output directory; default: script directory')
    args=parser.parse_args()
    base=Path(__file__).resolve().parent
    try:
        result=build(base,args.out or base)
    except (OSError,ValueError,ArithmeticError) as exc:
        parser.exit(1,f'Error: {exc}\n')
    print(json.dumps(result['metrics'],indent=2))
    print('Computed outputs written. Websites were not fetched or re-verified.')

if __name__=='__main__': main()
