#!/usr/bin/env python3
"""Rebuild the complete Trailer Securement Ledger JSON from the six released CSVs.
Python 3.10+; standard library only. No network access, scraping, or dependencies.
Run: python build-trailer-securement-ledger.py --directory . --check
Omit --check to write the JSON. Empty CSV fields become JSON null, never zero.
"""
from __future__ import annotations
import argparse
import csv
import json
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from pathlib import Path
from typing import Any

VERSION = '1.2.0'
VERIFIED = '2026-09-17'
TABLES = {'authority-ledger':30,'policy-timeline':19,'restraint-specs':15,
          'chock-ratings':12,'manufacturer-instructions':14,'exceptions':18}
NUMERIC = {'seq','published_restraining_force_lb','vertical_window_low_in',
 'vertical_window_high_in','vertical_window_span_in','horizontal_reach_in',
 'stated_min_contact_height_in','wheel_capture_low_in','wheel_capture_high_in',
 'length_in','width_in','height_in','weight_lb','max_tire_diameter_in',
 'published_grade_limit_pct','gross_vehicle_operating_weight_lb','guide_test_grade_pct'}

def number(s: str) -> int | float:
    d = Decimal(s)
    if not d.is_finite():
        raise ValueError(f'Non-finite numeric value: {s!r}')
    return int(d) if d == d.to_integral_value() else float(d)

def read_table(path: Path, expected: int) -> list[dict[str,Any]]:
    with path.open(encoding='utf-8-sig',newline='') as fh:
        reader = csv.DictReader(fh)
        if not reader.fieldnames or len(set(reader.fieldnames)) != len(reader.fieldnames):
            raise ValueError(f'{path.name}: missing/duplicate column name')
        rows = []
        for n, row in enumerate(reader,2):
            if None in row or any(v is None for v in row.values()):
                raise ValueError(f'{path.name}:{n}: malformed CSV row')
            try:
                rows.append({k:None if v=='' else number(v) if k in NUMERIC else v
                             for k,v in row.items()})
            except (InvalidOperation,ValueError) as e:
                raise ValueError(f'{path.name}:{n}: {e}') from e
    if len(rows) != expected:
        raise ValueError(f'{path.name}: expected {expected} release rows, got {len(rows)}')
    keys=[r.get('id',r.get('seq',r.get('source_id'))) for r in rows]
    if len(set(keys)) != len(keys): raise ValueError(f'{path.name}: duplicate record key')
    for row in rows:
        if row.get('verified_date') != VERIFIED:
            raise ValueError(f'{path.name}: verification date differs from this release')
    return rows

def build(directory: Path) -> dict[str,Any]:
    tables={name:read_table(directory/f'trailer-securement-{name}.csv',count)
            for name,count in TABLES.items()}
    specs=tables['restraint-specs']
    complete=[r for r in specs if r['vertical_window_low_in'] is not None
              and r['vertical_window_high_in'] is not None]
    for r in complete:
        lo,hi=Decimal(str(r['vertical_window_low_in'])),Decimal(str(r['vertical_window_high_in']))
        if hi<lo or hi-lo != Decimal(str(r['vertical_window_span_in'])):
            raise ValueError(f"{r['id']}: invalid vertical window/span")
    for r in specs:
        if r not in complete and r['vertical_window_span_in'] is not None:
            raise ValueError(f"{r['id']}: span supplied without both endpoints")
        if any(k.startswith('derived_unreachable') or k.startswith('derived_gap_to_30') for k in r):
            raise ValueError('Removed legal-gap fields must not reappear')
    spans=[Decimal(str(r['vertical_window_span_in'])) for r in complete]
    frows=[r for r in specs if r['published_restraining_force_lb'] is not None]
    chocks=[r for r in tables['chock-ratings'] if r['record_type']=='product']
    total=sum(len(v) for v in tables.values())
    mean=sum(spans)/Decimal(len(spans))
    # NIST SP811 Table B.9 conversion factor (published precision); not a performance comparison.
    lbf_factor=Decimal('4.448222')
    conversion=Decimal('22500')*lbf_factor/Decimal('1000')
    formulas={
      'vertical_window_span_in':'published high endpoint - published low endpoint; arithmetic span, not measured travel',
      'mean_vertical_window_span_in':'sum of 13 complete arithmetic spans / 13',
      'count_windows_starting_at_or_below_9_in':'count complete rows with low endpoint <= 9 in',
      'count_windows_ending_below_30_in':'count complete rows with high endpoint < 30 in; not a legal fit result',
      'nameplate_example_load_kN':'22500 lbf * 4.448222 N/lbf / 1000 N/kN',
      'R15_wheel_capture_high_in':'14 ft * 12 in/ft = 168 in; rear-axle position from dock face, not a RIG window',
      'R01_reach_conversion_check_mm':'14 in * 25.4 mm/in = 355.6 mm; manufacturer prints 381 mm beside 14 in; unresolved source unit inconsistency'}
    summary={
      'record_count':total,'table_row_counts':{k:len(v) for k,v in tables.items()},
      'restraint_model_count':len(specs),'manufacturer_count':len({r['manufacturer'] for r in specs}),
      'models_with_a_published_force_figure':len(frows),
      'printed_force_numbers_lb':sorted({r['published_restraining_force_lb'] for r in frows}),
      'force_summary_limitation':'Printed numbers retain up-to/in-excess-of qualifiers. No mean, equivalent-capacity range, or cross-brand performance ranking is calculated.',
      'models_with_complete_vertical_window':len(complete),
      'low_endpoint_range_in':[min(r['vertical_window_low_in'] for r in complete),max(r['vertical_window_low_in'] for r in complete)],
      'high_endpoint_range_in':[min(r['vertical_window_high_in'] for r in complete),max(r['vertical_window_high_in'] for r in complete)],
      'arithmetic_span_range_in':[float(min(spans)),float(max(spans))],
      'arithmetic_span_sum_in':float(sum(spans)),
      'mean_arithmetic_span_in':float(mean.quantize(Decimal('.01'),rounding=ROUND_HALF_UP)),
      'count_windows_starting_at_or_below_9_in':sum(r['vertical_window_low_in']<=9 for r in complete),
      'count_windows_ending_below_30_in':sum(r['vertical_window_high_in']<30 for r in complete),
      'chock_product_count':len(chocks),'selected_guide_weight_band_count':len(tables['chock-ratings'])-len(chocks),
      'nameplate_example_load_lbf':22500,'N_per_lbf_used':float(lbf_factor),
      'nameplate_example_load_kN_unrounded':float(conversion),
      'nameplate_example_load_kN_rounded':float(conversion.quantize(Decimal('.1'),rounding=ROUND_HALF_UP)),
      'rvr303_14_in_converted_mm':float(Decimal('14')*Decimal('25.4')),
      'serco_14_ft_converted_in':14*12,
      'models_without_a_published_force_figure':len(specs)-len(frows),
      'windows_starting_above_9_in':sum(r['vertical_window_low_in']>9 for r in complete),
      'chock_products_with_a_published_GVW':sum(r['gross_vehicle_operating_weight_lb'] is not None for r in chocks),
      'published_chock_GVW_range_lb':[min(r['gross_vehicle_operating_weight_lb'] for r in chocks if r['gross_vehicle_operating_weight_lb'] is not None),max(r['gross_vehicle_operating_weight_lb'] for r in chocks if r['gross_vehicle_operating_weight_lb'] is not None)],
      'evidence_type':'Calculated from the source-reported inputs in the released CSVs'}
    sources={}
    for name,rows in tables.items():
        for r in rows:
            urls=[r.get('source_url')]+str(r.get('additional_source_urls') or '').split(' | ')
            for url in urls:
                if not url:continue
                if not str(url).startswith('https://'):raise ValueError(f'Non-HTTPS source: {url}')
                ref=f"{name}:{r.get('id',r.get('seq'))}"
                sources.setdefault(url,[]).append(ref)
    return {
      'name':'Trailer Securement Ledger','version':VERSION,'verified_date':VERIFIED,
      'data_edition':'Compiled September 2026; underlying source dates are retained in each row',
      'publisher':'Uptime Dock & Door','editorial_byline':'Uptime Dock & Door Research',
      'canonical_url':'https://uptimedockanddoor.com/research/vehicle-restraints-vs-wheel-chocks/',
      'scope':'US commercial loading-dock comparison; selected manufacturer documents and scoped legal reference records, not a market census or facility compliance determination',
      'rights_note':'Original selection, short factual paraphrases, and editorial analysis; third-party rights remain with their owners. No new open license is assigned to third-party material.',
      'missing_values':'Empty CSV fields are JSON null. Read record_type, device_family, force_qualifier and scope_note to distinguish not stated from not applicable. Neither means zero.',
      'definitions':{'force_lb':'Manufacturer-stated pounds-force for restraint figures; preserve source wording/qualifier.',
                     'vehicle_weight_lb':'Gross vehicle operating weight for named chock ratings, not pull-out force.',
                     'payload':'Separate manufacturer-stated cargo capacity; not a conversion of gross vehicle weight.',
                     'vertical_window_span_in':'Calculated difference between published guard-height endpoints, not a laboratory measurement or permitted legal height range.'},
      'federal_reference_geometry':{'source':'49 CFR 393.86(a)(2)-(a)(5)',
          'minimum_ground_clearance_in':None,
          'minimum_ground_clearance_note':'No minimum ground clearance specified in these clauses. Do not replace this null with zero or infer that every lower position is legal.',
          'scope_note':'Covered vehicles and explicit rounded/curved-end exceptions only; see authority rows A16-A18, current rule and inspection criteria. No fit verdict is computed.'},
      'tables':tables,'summary':summary,'formulas':formulas,
      'source_manifest':read_table(directory/'trailer-securement-sources.csv',52),
      'source_index':[{'url':url,'used_by':sorted(set(refs)),'verified_date':VERIFIED}
                      for url,refs in sorted(sources.items())]}

def main() -> None:
    p=argparse.ArgumentParser(description=__doc__)
    p.add_argument('--directory',type=Path,default=Path(__file__).resolve().parent)
    p.add_argument('--check',action='store_true',help='Validate existing JSON rather than write it')
    args=p.parse_args();expected=build(args.directory)
    dest=args.directory/'trailer-securement-ledger.json'
    if args.check:
        actual=json.loads(dest.read_text(encoding='utf-8'))
        if actual!=expected:raise SystemExit('FAIL: JSON differs from current CSV inputs/derivations')
        print(f"PASS: {expected['summary']['record_count']} records; complete CSV/JSON parity and formulas verified")
    else:
        dest.write_text(json.dumps(expected,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
        print(f"Wrote {dest.name}: {expected['summary']['record_count']} records")
if __name__=='__main__':
    try:main()
    except (OSError,ValueError,KeyError) as e:raise SystemExit(f'ERROR: {e}')
