#!/usr/bin/env python3
"""Reproduce the frozen manufacturing comparison, version 1.1.0.

Run: python manufacturing-by-state-build-script-v1.1.0.py --output-dir ./data
Python standard library only; no API key or network access required.
The frozen source observations below were read against primary BLS/Census
material on 2026-09-15. This script reproduces arithmetic; source validation
is a separate editorial task. Existing non-identical output files are not
overwritten unless --overwrite is explicitly supplied.
"""
from __future__ import annotations
import argparse
import csv
import io
import json
from decimal import Decimal, ROUND_HALF_UP
from fractions import Fraction
from pathlib import Path
from statistics import median

VERSION = "1.1.0"
VERIFIED = "2026-09-15"
BASE = "manufacturing-by-state-2026-07-verified-2026-09-15-v1.1.0"
SOURCE = "https://www.bls.gov/news.release/archives/laus_08212026.htm"
LOCAL_SOURCE = "https://www.bls.gov/sae/tables/annual-average/table-1-employees-on-nonfarm-payrolls-in-states-and-selected-areas-by-major-industry.htm"
NATIONAL_SOURCE = "https://data.bls.gov/timeseries/CES3000000001"
SHIPMENTS_SOURCE = "https://www.census.gov/library/stories/2025/09/manufacturing-week.html"
# geography, manufacturing Jul2025/May2026/Jun2026/Jul2026,
# total nonfarm Jul2025/Jul2026, all in jobs at the source's 100-job precision.
RAW = [['Alabama', 283100, 282800, 281700, 281100, 2195800, 2213300],
 ['Alaska', 12400, 11900, 12100, 12200, 337300, 339700],
 ['Arizona', 192500, 192100, 191400, 191900, 3262700, 3285800],
 ['Arkansas', 158900, 158700, 159100, 158900, 1339800, 1341700],
 ['California', 1214400, 1206400, 1203200, 1201900, 18020300, 18133000],
 ['Colorado', 145800, 143600, 144600, 144400, 2961600, 2973700],
 ['Connecticut', 152100, 155400, 156300, 156800, 1716700, 1730500],
 ['Delaware', 27000, 25900, 25800, 25800, 496200, 495100],
 ['District of Columbia', 1100, 1000, 1000, 1000, 753900, 722400],
 ['Florida', 426200, 427200, 427900, 430400, 9996300, 10037300],
 ['Georgia', 424400, 427400, 426900, 426300, 4993200, 4977500],
 ['Hawaii', 13000, 12900, 12900, 12600, 641500, 642700],
 ['Idaho', 75100, 76400, 76900, 76900, 882600, 886800],
 ['Illinois', 568900, 571300, 571000, 571000, 6164500, 6176400],
 ['Indiana', 513800, 516300, 513300, 512000, 3270100, 3252200],
 ['Iowa', 215000, 213800, 213000, 212900, 1584900, 1583100],
 ['Kansas', 172500, 172600, 174700, 173300, 1464900, 1470500],
 ['Kentucky', 252300, 247200, 247600, 249300, 2035900, 2033300],
 ['Louisiana', 140500, 144100, 143800, 143300, 1989100, 2015900],
 ['Maine', 51300, 51500, 51200, 50900, 661700, 660800],
 ['Maryland', 110100, 110900, 110900, 111700, 2816500, 2815900],
 ['Massachusetts', 225900, 228000, 228500, 228100, 3706700, 3712500],
 ['Michigan', 586700, 582400, 581900, 585500, 4490200, 4498400],
 ['Minnesota', 313600, 319300, 322100, 321800, 3021700, 3063600],
 ['Mississippi', 138800, 137200, 138300, 138800, 1189300, 1196200],
 ['Missouri', 282900, 277300, 279200, 280100, 2985500, 3014900],
 ['Montana', 21100, 20600, 20800, 20700, 527900, 522100],
 ['Nebraska', 102200, 98300, 97700, 97200, 1061500, 1059800],
 ['Nevada', 68400, 68300, 68300, 68500, 1595000, 1612500],
 ['New Hampshire', 67600, 66600, 66800, 67200, 703100, 706300],
 ['New Jersey', 252100, 247800, 248200, 248300, 4377200, 4357600],
 ['New Mexico', 28100, 27900, 27800, 28000, 891400, 895500],
 ['New York', 404300, 397700, 397400, 397200, 9940100, 9960700],
 ['North Carolina', 459200, 450100, 450300, 449300, 5060400, 5112000],
 ['North Dakota', 27700, 27700, 27700, 27800, 446500, 446400],
 ['Ohio', 675500, 681200, 683800, 688700, 5672600, 5690000],
 ['Oklahoma', 140200, 139000, 139400, 139800, 1788400, 1796100],
 ['Oregon', 179200, 173600, 173300, 172800, 1981200, 1963200],
 ['Pennsylvania', 556100, 555600, 556400, 558100, 6169200, 6201100],
 ['Rhode Island', 39200, 39900, 39900, 40000, 515300, 513900],
 ['South Carolina', 261800, 262700, 263300, 264500, 2382400, 2414500],
 ['South Dakota', 44100, 43700, 43800, 43900, 469500, 471300],
 ['Tennessee', 354600, 354900, 356400, 356000, 3363800, 3385100],
 ['Texas', 985500, 976300, 980300, 979700, 14302400, 14468000],
 ['Utah', 154000, 153300, 153700, 154300, 1770400, 1787400],
 ['Vermont', 27400, 27700, 27900, 27900, 309700, 310900],
 ['Virginia', 237200, 228000, 228600, 229000, 4280100, 4232200],
 ['Washington', 270500, 274800, 274400, 276000, 3635300, 3652500],
 ['West Virginia', 45200, 44900, 45100, 45200, 718800, 720200],
 ['Wisconsin', 458500, 456300, 457400, 458400, 3040900, 3042900],
 ['Wyoming', 10500, 10400, 10300, 10300, 293800, 295100]]
TERRITORIES = [['Puerto Rico', 80500, 80300, 80400, 80800, 949100, 957900]]
LOCAL = [['Illinois', 2023, 578500, 6120000],
 ['Illinois', 2024, 578700, 6144900],
 ['Illinois', 2025, 568500, 6155900],
 ['Rockford metropolitan area', 2023, 25500, 145500],
 ['Rockford metropolitan area', 2024, 24900, 145600],
 ['Rockford metropolitan area', 2025, 24300, 144300]]
NATIONAL = {2023: [12903, 12898, 12888, 12884, 12869, 12875, 12862, 12861, 12872, 12837, 12859, 12872],
 2024: [12875, 12852, 12836, 12833, 12825, 12814, 12806, 12772, 12758, 12697, 12711, 12693],
 2025: [12673, 12671, 12666, 12662, 12651, 12636, 12625, 12615, 12612, 12603, 12593, 12580],
 2026: [12582, 12583, 12598, 12597, 12595, 12608, 12622, 12638]}
SHIPMENTS = [['Texas', 1, 808.0],
 ['California', 2, 656.1],
 ['Ohio', 3, 374.0],
 ['Indiana', 4, 333.7],
 ['Illinois', 5, 319.6]]


def rounded_fraction(value: Fraction, places: int) -> float:
    number = Decimal(value.numerator) / Decimal(value.denominator)
    number = number.quantize(Decimal(1).scaleb(-places), rounding=ROUND_HALF_UP)
    return 0.0 if number == 0 else float(number)


def rank_map(values: dict[str, int | Fraction]) -> dict[str, int]:
    """Descending competition ranks, using exact values, not rounded displays."""
    ordered = sorted(values, key=lambda name: (-values[name], name))
    ranks: dict[str, int] = {}
    previous = None
    for position, name in enumerate(ordered, 1):
        if position == 1 or values[name] != previous:
            current_rank = position
        ranks[name] = current_rank
        previous = values[name]
    return ranks


def csv_bytes(rows: list[dict]) -> bytes:
    if not rows:
        raise ValueError("Cannot serialize an empty table")
    out = io.StringIO(newline="")
    writer = csv.DictWriter(out, fieldnames=list(rows[0]), lineterminator="\n")
    writer.writeheader()
    writer.writerows(rows)
    return out.getvalue().encode("utf-8")


def json_bytes(payload: object) -> bytes:
    return (json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False) + "\n").encode("utf-8")


def build() -> tuple[dict[str, bytes], dict]:
    states = [r for r in RAW if r[0] != "District of Columbia"]
    assert len(RAW) == 51 and len(states) == 50
    assert len({r[0] for r in RAW}) == 51
    assert all(isinstance(x, int) and x % 100 == 0 and x > 0 for r in RAW for x in r[1:])
    levels = {r[0]: r[4] for r in states}
    shares = {r[0]: Fraction(r[4] * 100, r[6]) for r in states}
    jobs_rank, share_rank = rank_map(levels), rank_map(shares)
    rows = []
    for name, m25, m5, m6, m26, n25, n26 in RAW:
        gap = jobs_rank[name] - share_rank[name] if name in jobs_rank else None
        rows.append({
            "state": name,
            "geography_type": "state" if name in jobs_rank else "federal district",
            "mfg_jobs_jul2025_thousands": m25 / 1000,
            "mfg_jobs_may2026_thousands": m5 / 1000,
            "mfg_jobs_jun2026_thousands": m6 / 1000,
            "mfg_jobs_jul2026p_thousands": m26 / 1000,
            "total_nonfarm_jul2025_thousands": n25 / 1000,
            "total_nonfarm_jul2026p_thousands": n26 / 1000,
            "mfg_share_of_nonfarm_jul2026p_pct": rounded_fraction(Fraction(m26 * 100, n26), 2),
            "mfg_share_of_nonfarm_jul2026p_pct_six_decimals": rounded_fraction(Fraction(m26 * 100, n26), 6),
            "mfg_yoy_change_thousands": (m26 - m25) / 1000,
            "mfg_yoy_change_pct": rounded_fraction(Fraction((m26-m25)*100, m25), 2),
            "mfg_yoy_change_pct_display": rounded_fraction(Fraction((m26-m25)*100, m25), 1),
            "rank_by_jobs": jobs_rank.get(name),
            "rank_by_share": share_rank.get(name),
            "rank_gap_jobs_minus_share": gap,
            "period": "2026-07",
            "seasonal_adjustment": "seasonally adjusted",
            "july_2026_status": "preliminary",
            "source_precision_jobs": 100,
            "source_url": SOURCE,
            "source_release_date": "2026-08-21",
            "verified_date": VERIFIED,
            "dataset_version": VERSION,
        })
    rows.sort(key=lambda r:r["state"])
    sr = [r for r in rows if r["geography_type"] == "state"]
    changes = [r[4]-r[1] for r in states]
    share_order = sorted(states, key=lambda r:(-shares[r[0]],r[0]))
    level_order = sorted(states, key=lambda r:(-r[4],r[0]))
    top_ten_overlap = sorted(set(r[0] for r in level_order[:10]) & set(r[0] for r in share_order[:10]))
    net50 = sum(changes)
    net51 = sum(r[4]-r[1] for r in RAW)
    positive_count = sum(c > 0 for c in changes)
    negative_count = sum(c < 0 for c in changes)
    unchanged_count = sum(c == 0 for c in changes)
    pcts = [r["mfg_yoy_change_pct_display"] for r in sr]
    exact_gaps = [abs(jobs_rank[r[0]]-share_rank[r[0]]) for r in states]
    top5_gains = sorted(states, key=lambda r: (-(r[4]-r[1]),r[0]))[:5]
    all_levels_ranks=rank_map({r[0]:r[4] for r in RAW})
    all_share_ranks=rank_map({r[0]:Fraction(r[4]*100,r[6]) for r in RAW})
    checks={
        "row_count_matches_51_and_50_ranked":len(rows)==51 and sum(r["rank_by_jobs"] is not None for r in rows)==50,
        "net_changes_tie_to_difference_of_sums":net50==sum(r[4] for r in states)-sum(r[1] for r in states) and net51==sum(r[4] for r in RAW)-sum(r[1] for r in RAW),
        "state_change_counts_sum_to_50":positive_count+negative_count+unchanged_count==50,
        "state_ranks_unchanged_if_dc_temporarily_included":all(jobs_rank[n]==all_levels_ranks[n] and share_rank[n]==all_share_ranks[n] for n in levels),
        "rounded_change_counts_reconcile":(positive_count,negative_count,unchanged_count)==(20,27,3) and (sum(p>0 for p in pcts),sum(p<0 for p in pcts),sum(p==0 for p in pcts))==(20,26,4),
    }
    assert all(checks.values()), checks
    assert share_rank["Rhode Island"] == 27 and share_rank["Oklahoma"] == 28
    assert net50 == -20700 and net51 == -20800
    assert top_ten_overlap == ["Indiana","Michigan","Ohio","Wisconsin"]
    summary={
        "state_count":50,"dc_rows":1,"ranked_geographies":50,
        "sum_50_states_mfg_jul2026_jobs":sum(r[4] for r in states),
        "sum_50_states_mfg_jul2025_jobs":sum(r[1] for r in states),
        "sum_50_states_plus_dc_mfg_jul2026_jobs":sum(r[4] for r in RAW),
        "net_change_50_states_jobs":net50,"net_change_50_states_plus_dc_jobs":net51,
        "state_level_changes":{"positive":positive_count,"negative":negative_count,"unchanged_at_published_precision":unchanged_count},
        "state_rounded_percent_changes":{"positive":sum(p>0 for p in pcts),"negative":sum(p<0 for p in pcts),"zero_rounded":sum(p==0 for p in pcts)},
        "top_ten_overlap":top_ten_overlap,
        "states_moving_at_least_10_places":sum(x>=10 for x in exact_gaps),
        "states_moving_at_least_20_places":sum(x>=20 for x in exact_gaps),
        "median_absolute_rank_gap":median(exact_gaps),
        "maximum_absolute_rank_gap":max(exact_gaps),
        "states_with_equal_ranks":sorted(r[0] for r in states if jobs_rank[r[0]]==share_rank[r[0]]),
        "states_share_at_least_10_pct":sum(s>=10 for s in shares.values()),
        "states_share_below_5_pct":sum(s<5 for s in shares.values()),
        "top5_jobs_percent_of_50_state_arithmetic_sum":rounded_fraction(Fraction(sum(r[4] for r in level_order[:5])*100,sum(r[4] for r in states)),1),
        "top10_jobs_percent_of_50_state_arithmetic_sum":rounded_fraction(Fraction(sum(r[4] for r in level_order[:10])*100,sum(r[4] for r in states)),1),
        "positive_state_net_changes_sum_jobs":sum(c for c in changes if c>0),
        "top5_positive_state_net_changes_jobs":sum(r[4]-r[1] for r in top5_gains),
        "top5_positive_state_net_changes_share_pct":rounded_fraction(Fraction(sum(r[4]-r[1] for r in top5_gains)*100,sum(c for c in changes if c>0)),1),
    }
    local_rows=[{"geography":g,"year":y,"manufacturing_jobs_annual_average":m,"total_nonfarm_jobs_annual_average":n,"manufacturing_share_pct":rounded_fraction(Fraction(m*100,n),2),"seasonal_adjustment":"not seasonally adjusted annual averages","source_url":LOCAL_SOURCE,"verified_date":VERIFIED} for g,y,m,n in LOCAL]
    nat_rows=[{"series_id":"CES3000000001","year":y,"month":i,"manufacturing_jobs_thousands":v,"seasonal_adjustment":"seasonally adjusted","status":"preliminary" if y==2026 and i in (7,8) else "not flagged preliminary in verified series display","source_url":NATIONAL_SOURCE,"verified_date":VERIFIED} for y,vals in NATIONAL.items() for i,v in enumerate(vals,1)]
    annual={str(y):{"months":12,"mean_jobs_exact_numerator":sum(vals)*1000,"mean_jobs_exact_denominator":12,"mean_jobs_rounded_to_nearest_1000":int(rounded_fraction(Fraction(sum(vals),12),0))*1000} for y,vals in NATIONAL.items() if len(vals)==12}
    nat_summary={"annual_average_method":"Arithmetic mean of 12 monthly seasonally adjusted values, not a separately retrieved BLS M13 annual observation.","annual_averages":annual,"2023_to_2025_change_jobs_rounded_to_nearest_1000":int(rounded_fraction(Fraction(sum(NATIONAL[2025])-sum(NATIONAL[2023]),12),0))*1000,"august2026_jobs":NATIONAL[2026][7]*1000,"july_to_august2026_change_jobs":(NATIONAL[2026][7]-NATIONAL[2026][6])*1000,"december2025_to_august2026_change_jobs":(NATIONAL[2026][7]-NATIONAL[2025][11])*1000}
    sh_rows=[{"state":name,"rank":rank,"year":2022,"shipment_value_billions_current_dollars":v,"measure":"manufacturing shipment value, not GDP","source_url":SHIPMENTS_SOURCE,"verified_date":VERIFIED} for name,rank,v in SHIPMENTS]
    input_rows=[dict(zip(["geography","mfg_jobs_jul2025","mfg_jobs_may2026","mfg_jobs_jun2026","mfg_jobs_jul2026","total_nonfarm_jul2025","total_nonfarm_jul2026"],r),source_url=SOURCE,verified_date=VERIFIED) for r in RAW]
    payload={
        "dataset":"Manufacturing employment by state, July 2026: scale, concentration and change",
        "publisher":"Rockford Dock Door Repair Research","version":VERSION,"verified_date":VERIFIED,
        "universe":"50 states ranked; District of Columbia separately reported, with null ranks",
        "employment_units":"thousands of payroll jobs; original source precision 0.1 thousand",
        "monthly_state_status":"July 2026 preliminary, seasonally adjusted, estimated from 2025 benchmark levels",
        "source":{"agency":"U.S. Bureau of Labor Statistics","program":"Current Employment Statistics, State and Area","release":"State Employment and Unemployment - July 2026, USDL-26-1412","table":"Table 3","url":SOURCE,"released":"2026-08-21","verified":VERIFIED},
        "method":{"ranking":"Exact rational manufacturing/nonfarm shares, with descending competition ranks for exact ties; not rounded display values.","share":"manufacturing employment / total nonfarm employment * 100","year_over_year_change":"July 2026 minus July 2025, from published levels","year_over_year_percent":"100 * (July 2026 - July 2025) / July 2025; display rounded to one decimal; negative zero normalized to zero","rank_gap":"job-count rank minus share rank","national_annual_average":nat_summary["annual_average_method"]},
        "notes":["The state sum is an arithmetic result, not the independently estimated BLS national series.","District of Columbia is excluded from every state ranking, top-ten set, and 50-state summary.","A 0.0% displayed change can hide a nonzero change in published job levels.","Neither rounding nor ranks quantify statistical uncertainty.","Puerto Rico is preserved below outside the state/DC dataset and all rankings.","U.S. Virgin Islands manufacturing is not published seasonally adjusted in the source table; absence is not zero.","Annual local data and national observations are separate supplements, not inputs to monthly state rankings.","Raw observations were checked against primary sources on the verification date; reproduction of arithmetic does not independently certify transcription."],
        "checks":checks,"summary":summary,"rows":rows,
        "territories_excluded_from_ranks":[dict(zip(["geography","mfg_jobs_jul2025","mfg_jobs_may2026","mfg_jobs_jun2026","mfg_jobs_jul2026","total_nonfarm_jul2025","total_nonfarm_jul2026"],r),units="jobs",source_url=SOURCE,verified_date=VERIFIED) for r in TERRITORIES],
        "annual_local_supplement":local_rows,"national_supplement":nat_summary,"shipments_supplement":sh_rows,
    }
    outputs={BASE+".csv":csv_bytes(rows),BASE+".json":json_bytes(payload),
             "manufacturing-source-inputs-verified-2026-09-15-v1.1.0.csv":csv_bytes(input_rows),
             "illinois-rockford-annual-2023-2025-verified-2026-09-15.csv":csv_bytes(local_rows),
             "national-manufacturing-monthly-2023-2026-verified-2026-09-15.csv":csv_bytes(nat_rows),
             "manufacturing-shipments-2022-top-five-verified-2026-09-15.csv":csv_bytes(sh_rows)}
    return outputs,payload


def main() -> None:
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--output-dir",type=Path,default=Path(__file__).resolve().parent)
    parser.add_argument("--overwrite",action="store_true",help="Explicitly replace differing files. Do not use to silently revise a published frozen version.")
    args=parser.parse_args()
    outputs,payload=build()
    args.output_dir.mkdir(parents=True,exist_ok=True)
    for name,content in outputs.items():
        target=args.output_dir/name
        if target.exists() and target.read_bytes()!=content and not args.overwrite:
            raise SystemExit(f"Refusing to overwrite different frozen file: {target}; choose a new directory or use --overwrite explicitly.")
    for name,content in outputs.items():
        (args.output_dir/name).write_bytes(content)
    print(json.dumps({"checks":payload["checks"],"summary":payload["summary"],"outputs":list(outputs)},indent=2))


if __name__ == "__main__":
    main()
