#!/usr/bin/env python3
"""Reproduce the truck-parking compilation, edition 2026-09-16, version 2.1.
Python 3.10+; standard library only. No network or optional dependencies.
Run beside truck-parking-source-inputs-2026-09-16.json. Existing outputs
for this edition are replaced. Counts are source observations; derived
comparisons are not a time series, shortage estimate, or current census.
"""
from __future__ import annotations
import csv
import json
from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path

EDITION = '2026-09-16'
ROOT = Path(__file__).resolve().parent

def rounded(value: Decimal | int | float, digits: int = 2) -> float:
    return float(Decimal(str(value)).quantize(Decimal(1).scaleb(-digits), rounding=ROUND_HALF_UP))

def quotient(numerator: int | float, denominator: int | float, digits: int = 2) -> float:
    if not denominator:
        raise ValueError('A nonzero denominator is required.')
    return rounded(Decimal(str(numerator)) / Decimal(str(denominator)), digits)

def pct(numerator: int | float, denominator: int | float, digits: int = 2) -> float:
    return quotient(Decimal(str(numerator)) * 100, denominator, digits)

def write_csv(path: Path, rows: list[dict], fields: list[str]) -> None:
    with path.open('w', encoding='utf-8', newline='') as handle:
        writer = csv.DictWriter(handle, fieldnames=fields, extrasaction='raise')
        writer.writeheader()
        writer.writerows(rows)

def main() -> None:
    inp = ROOT / f'truck-parking-source-inputs-{EDITION}.json'
    if not inp.is_file():
        raise FileNotFoundError(f'Missing required input: {inp.name}')
    obj = json.loads(inp.read_text(encoding='utf-8'))
    data = obj['data']
    old, new, budgets = data['FHWA15'], data['ATRI24'], data['ATRI_BUDGET']
    printed = data['FHWA15_PRINTED']
    fields = [
        'state', 'fhwa_2013_14_public_facilities', 'fhwa_2013_14_public_spaces',
        'fhwa_2015_report_private_truck_stops', 'fhwa_2015_report_private_spaces',
        'derived_sum_of_reported_historical_spaces', 'historical_sum_coverage',
        'atri_2024_rest_area_spaces', 'atri_published_spaces_per_100_nhs_miles',
        'atri_nhs_mileage_year', 'atri_published_spaces_per_million_truck_vmt',
        'atri_truck_vmt_year', 'atri_2024_amenities_of_8', 'atri_2024_safety_features_of_5',
        'atri_annual_rest_area_budget_usd_per_parking_space',
        'atri_published_historical_private_per_public_2024_ratio', 'atri_coverage',
        'derived_historical_private_div_public_2024_rounded_1dp', 'derived_ratio_roundtrip_matches',
        'diagnostic_rounded_ratio_times_public_not_an_exact_count',
        'diagnostic_product_minus_historical_private_not_inventory_change',
        'noncomparable_public_count_difference_not_change',
        'noncomparable_public_count_difference_percent_not_growth',
        'fhwa_2022_nhs_miles_spotcheck', 'derived_nhs_spotcheck_spaces_per_100_miles',
        'nhs_spotcheck_matches_published_1dp', 'fhwa_source_url', 'atri_source_url',
        'verification_date'
    ]
    rows = []
    for state in sorted(old):
        pf, ps, stops, private = old[state]
        a = new.get(state)
        row = dict.fromkeys(fields)
        row.update(state=state, fhwa_2013_14_public_facilities=pf,
                   fhwa_2013_14_public_spaces=ps, fhwa_2015_report_private_truck_stops=stops,
                   fhwa_2015_report_private_spaces=private,
                   derived_sum_of_reported_historical_spaces=(ps or 0)+(private or 0) if ps is not None or private is not None else None,
                   historical_sum_coverage='both components reported' if ps is not None and private is not None else 'partial or no reported components; missing is not zero',
                   atri_coverage='reported' if a else 'not reported in source table',
                   fhwa_source_url=obj['source_urls']['FHWA15'], atri_source_url=obj['source_urls']['ATRI24'],
                   verification_date=EDITION)
        if a:
            spaces, nhsratio, vmtratio, amenities, safety, ratio = a
            row.update(atri_2024_rest_area_spaces=spaces,
                       atri_published_spaces_per_100_nhs_miles=nhsratio, atri_nhs_mileage_year=2022,
                       atri_published_spaces_per_million_truck_vmt=vmtratio, atri_truck_vmt_year=2022,
                       atri_2024_amenities_of_8=amenities, atri_2024_safety_features_of_5=safety,
                       atri_annual_rest_area_budget_usd_per_parking_space=budgets.get(state),
                       atri_published_historical_private_per_public_2024_ratio=ratio)
            if spaces and ratio is not None and private is not None:
                check=quotient(private, spaces, 1)
                product=Decimal(str(ratio))*spaces
                row.update(derived_historical_private_div_public_2024_rounded_1dp=check,
                           derived_ratio_roundtrip_matches=check==ratio,
                           diagnostic_rounded_ratio_times_public_not_an_exact_count=rounded(product,1),
                           diagnostic_product_minus_historical_private_not_inventory_change=rounded(product-private,1))
            if ps is not None:
                row['noncomparable_public_count_difference_not_change']=spaces-ps
                row['noncomparable_public_count_difference_percent_not_growth']=pct(spaces-ps, ps) if ps else None
            miles=data['NHS22_SPOT'].get(state)
            if miles is not None:
                check=quotient(spaces*100,miles,1)
                row.update(fhwa_2022_nhs_miles_spotcheck=miles,
                           derived_nhs_spotcheck_spaces_per_100_miles=quotient(spaces*100,miles,4),
                           nhs_spotcheck_matches_published_1dp=check==nhsratio)
        rows.append(row)
    sums={name:sum(v[i] or 0 for v in old.values()) for i,name in enumerate(['public_facilities','public_spaces','private_truck_stops','private_spaces'])}
    sums['total_spaces']=sums['public_spaces']+sums['private_spaces']
    if sums != printed:
        raise AssertionError(f'Historical totals differ: {sums} versus {printed}')
    checks=[r for r in rows if r['derived_ratio_roundtrip_matches'] is not None]
    cohort=[r for r in rows if r['noncomparable_public_count_difference_not_change'] is not None]
    roadchecks=[r for r in rows if r['nhs_spotcheck_matches_published_1dp'] is not None]
    low=min(budgets,key=budgets.get); high=max(budgets,key=budgets.get)
    known_sum=sum(v[0] for v in new.values())
    employment={'oews_heavy_and_tractor_trailer_2025':2062040, 'ooh_heavy_and_tractor_trailer_2025':2221200,
                'oews_light_truck_2025':983300,'oews_driver_sales_2025':409180}
    employment['oews_three_occupations_combined_2025']=employment['oews_heavy_and_tractor_trailer_2025']+employment['oews_light_truck_2025']+employment['oews_driver_sales_2025']
    calculations={
        'historical_inventory_totals':sums,
        'historical_facilities_total':sums['public_facilities']+sums['private_truck_stops'],
        'historical_public_share_pct':pct(sums['public_spaces'],sums['total_spaces']),
        'historical_private_share_pct':pct(sums['private_spaces'],sums['total_spaces']),
        'historical_spaces_per_public_facility':quotient(sums['public_spaces'],sums['public_facilities']),
        'historical_spaces_per_private_truck_stop':quotient(sums['private_spaces'],sums['private_truck_stops']),
        'atri_reporting_states':len(new), 'atri_public_rest_area_spaces_total':known_sum,
        'atri_nonreporting_states':sorted(set(old)-set(new)),
        'ratio_roundtrip':{'states_tested':len(checks),'states_matching':sum(r['derived_ratio_roundtrip_matches'] for r in checks),
                          'formula':'round_1dp(fhwa_2015_report_private_spaces/public_rest_area_spaces_2024)',
                          'warning':'Matching a rounded quotient is not recovery of an exact hidden count. ATRI discloses the historical private source. FHWA sources conflict on whether that private input is labelled 2013 or 2015.'},
        'matched_public_comparison_not_time_series':{
            'states':len(cohort),'historical_public_spaces':sum(r['fhwa_2013_14_public_spaces'] for r in cohort),
            'atri_public_rest_area_spaces':sum(r['atri_2024_rest_area_spaces'] for r in cohort),
            'numerical_difference_not_change':sum(r['noncomparable_public_count_difference_not_change'] for r in cohort),
            'states_with_higher_number':sum(r['noncomparable_public_count_difference_not_change']>0 for r in cohort),
            'states_with_lower_number':sum(r['noncomparable_public_count_difference_not_change']<0 for r in cohort),
            'warning':'Coverage and definitions differ. Neither the difference nor its percentage measures growth, losses, or causes.'},
        'budget':{'reporting_states':len(budgets),'lowest_reported_state':low,'lowest_reported_usd':budgets[low],
                  'highest_reported_state':high,'highest_reported_usd':budgets[high],'max_min_multiple':quotient(budgets[high],budgets[low]),
                  'label':'Annual rest area service and maintenance budget per parking space; not truck-only observed expenditures.'},
        'amenities_all_8':[s for s,a in new.items() if a[3]==8],
        'safety_all_5':[s for s,a in new.items() if a[4]==5],
        'both_maxima':[s for s,a in new.items() if a[3]==8 and a[4]==5],
        'roadway_spotchecks':[{'state':r['state'],'spaces_2024':r['atri_2024_rest_area_spaces'],'nhs_road_miles_2022':r['fhwa_2022_nhs_miles_spotcheck'],
                             'calculated':r['derived_nhs_spotcheck_spaces_per_100_miles'],'published_1dp':r['atri_published_spaces_per_100_nhs_miles'],
                             'matches':r['nhs_spotcheck_matches_published_1dp']} for r in roadchecks],
        'fhwa_2019_approximate_counts':{'total':313000,'public':40000,'private':273000,'all_values_approximate':True,'publication_date':'2020-12-01'},
        'growth_reconciliation':{
            'public_implied_by_6pct':rounded(Decimal(36222)*Decimal('1.06')),
            'private_implied_by_11pct':rounded(Decimal(272698)*Decimal('1.11')),
            'public_published_approx_minus_implied':rounded(Decimal(40000)-Decimal(36222)*Decimal('1.06')),
            'private_published_approx_minus_implied':rounded(Decimal(273000)-Decimal(272698)*Decimal('1.11')),
            'public_growth_implied_by_approximate_endpoints_pct':pct(40000-36222,36222),
            'private_growth_implied_by_approximate_endpoints_pct':pct(273000-272698,272698),
            'warning':'The source baselines do not reconcile; this calculation does not establish the cause or prove that a count was carried forward.'},
        'employment_inputs':employment,
        'employment_per_historical_space_illustrations':{k:quotient(employment[k],313000,1) for k in ['oews_heavy_and_tractor_trailer_2025','ooh_heavy_and_tractor_trailer_2025','oews_three_occupations_combined_2025']},
        'implied_employment_for_exact_11_to_1':313000*11,
        'urban_share_comparison':{'nhs_truck_vmt_urban_pct':47,'parking_urban_pct':34,'nhs_truck_vmt_rural_pct':53,'parking_rural_pct':66,
                                 'large_origin_destination_urban_areas':32,'tonnage_share_pct':38,'parking_share_pct':8.5,
                                 'tonnage_share_div_parking_share_not_deficit':quotient(38,8.5,2)},
        'operator_average_comparison':{'2019_survey_average_spaces':143,'approximate_private_total_div_survey_average':quotient(273000,143,0),
                                       'survey_average_div_historical_inventory_average':quotient(Decimal(143)/(Decimal(272698)/6376),1,1),
                                       'warning':'Different years and populations; not proof of survey bias or an invalid survey estimate.'},
        'diary_2016_model':{'completed_diaries':148,'driver_days':2035,'parking_stops':4763,'remaining_drive_minutes':56,
                           'source_statistic_label_conflict':'Methods calls 56 minutes a median; summary/release calls it an average.',
                           'work_days_assumed':250,'operating_speed_mph_assumed':39.98,'wage_per_mile_usd_assumed':0.499,
                           'calculated_miles_from_printed_inputs':rounded(Decimal(56)/60*Decimal('39.98')*250),
                           'calculated_wages_from_printed_inputs_usd':rounded(Decimal(56)/60*Decimal('39.98')*250*Decimal('0.499')),
                           'source_reported_approximate_miles':9300,'source_reported_approximate_wages_usd':4600,
                           'annual_pay_comparison_baseline_not_model_input_usd':42500,
                           'warning':'Historical illustrative model of revenue time forgone from early parking, not measured search time or a 2026 loss estimate.'},
        'industry_issue_ranks':{str(year):rank for year,rank in zip(range(2016,2026),[4,4,5,5,3,5,3,2,2,4])},
        'funding_version_totals':{'hr1659_introduced_fy2025_2029_usd':151000000*5,'hr8870_introduced_fy2027_2031_usd':150000000*5,
                                 'pl11975_appropriation_usd':200000000,'warning':'Introduced authorizations are not enacted appropriations or delivered spaces.'},
        'pennsylvania':{'planned_spaces_october_2025':1202,'planned_locations_october_2025':133,
                        'spaces_announced_january_28_2026_as_open_before_end_2025':339,'locations':24,
                        'mean_spaces_per_planned_location':quotient(1202,133,1),'mean_spaces_per_announced_open_location':quotient(339,24,1),
                        'announced_delivery_share_of_plan_pct':pct(339,1202,1),'four_lehigh_valley_ramp_listings_spaces':18,
                        'lehigh_four_ramp_subset_spaces':5,'northampton_four_ramp_subset_spaces':13,
                        'four_ramps_share_of_state_plan_pct':pct(18,1202,2),
                        'plan_div_historical_2024_rest_area_count_pct_not_growth':pct(1202,784,0),
                        'warning':'Four ramps are a selected subset, not a complete Lehigh Valley allocation or county census. January is a dated delivery snapshot, not current availability.'}
    }
    assert len(rows)==50 and len(new)==47 and known_sum==30440
    assert len(checks)==46 and all(r['derived_ratio_roundtrip_matches'] for r in checks)
    assert len(cohort)==45 and calculations['matched_public_comparison_not_time_series']['numerical_difference_not_change']==-5165
    assert len(roadchecks)==4 and all(r['nhs_spotcheck_matches_published_1dp'] for r in roadchecks)
    assert len(budgets)==35
    csv_path=ROOT/f'truck-parking-state-table-{EDITION}.csv'
    write_csv(csv_path,rows,fields)
    # Read-back catches the original version's missing-derived-column defect.
    with csv_path.open(encoding='utf-8',newline='') as handle:
        readback=list(csv.DictReader(handle))
    assert len(readback)==50 and len(readback[0])==len(fields)
    pa=next(r for r in readback if r['state']=='Pennsylvania')
    assert pa['derived_ratio_roundtrip_matches']=='True' and pa['derived_historical_private_div_public_2024_rounded_1dp']=='11.9'
    bundle={'title':'Truck parking source-year comparison and ratio verification','creator':'Allentown Dock Door Repair Research',
            'version':'2.1','edition':EDITION,'verification_date':EDITION,'scope':'United States, 50 states; 47 reported ATRI state inputs.',
            'methodology':'Compilation and reproducible arithmetic on published inputs. No survey or field inspection by this publication.',
            'source_urls':obj['source_urls'],'calculation_source_register_file':'truck-parking-primary-source-register-2026-09-16.json','source_year_notes':obj['source_year_notes'], 'fields':fields,'states':rows,'calculations':calculations,
            'missing_value_policy':'JSON null / empty CSV cells preserve missing, inapplicable, and unreported values; a reported zero remains zero.'}
    (ROOT/f'truck-parking-merged-dataset-{EDITION}.json').write_text(json.dumps(bundle,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
    print(json.dumps({'state_rows':len(rows),'csv_fields':len(fields),'ratio_matches':f'{len(checks)}/{len(checks)}','road_spotchecks':len(roadchecks),'totals':sums,'budget_states':len(budgets),'all_tests':'passed'},indent=2))

if __name__=='__main__':
    main()
