"""The ambulance is slower: where the extra time goes.

Ambulance response to life-threatening medical calls in New York City, fiscal 2016 to the
latest month, and where the fiscal-2026 slowdown sits: call-taking, waiting for a free
ambulance (dispatch), or the trip (travel); held and upgraded calls; boroughs; and the
supply and demand figures the Mayor's Management Report prints beside it.

Reads (all on disk; refresh with `py src/pipelines/fetch_sources.py --force --only
nyc_ems_dispatch_monthly nyc_ems_dispatch_lt_monthly_long nyc_911_end_to_end`):
  raw/safety/nyc_ems_dispatch_monthly.csv   FDNY EMS Incident Dispatch Data (76xm-jjuj),
      server-side monthly sums by borough, initial and final segment, validity and held flags
  raw/safety/nyc_ems_dispatch_lt_monthly_long.csv   the same file, life-threatening calls with
      valid times only, summed by month from January 2005 (the file's first month): the long view
  raw/safety/nyc_911_end_to_end.csv          911 End-to-End Data (t7p9-n9dy), weekly from
      November 2013; fiscal years 2015 to 2026 are the full years used
  reference/reports/mmr_fy2026_fdny.pdf      Mayor's Management Report, Fiscal 2026, FDNY
      chapter; every figure quoted from it is checked against the saved PDF on each run
  reference/reports/mmr_fy1997_summary.pdf, mmr_fy1999_summary.pdf, mmr_fy2001_vol1.pdf,
      mmr_fy2004.pdf, mmr_fy2006.pdf, mmr_fy2016_fdny.pdf, mmr_fy2021_fdny.pdf and
      raw/safety/nyc_mmr_fy03_fy12_fdny.csv     the city's own figure for every fiscal year
      from 1988 (the thirty-year view), chained from report to report (the windows overlap up
      to fiscal 2016 and meet end to end after it) and
      checked against each saved report on every run
  reference/reports/nber_w35414_ems_congestion_2026.pdf, amny_ems_response_2026-09-21.html
      the working paper's estimates and the FDNY's statement, quoted in the text
Every quoted sentence, figure and table row is looked up in its saved source with
sparkylib.quotes.QuoteBook, and the quotes are kept in results.json with their pages.

Definitions (FDNY data dictionary; MMR FY2026 FDNY chapter, p. 61):
  life-threatening   final segment 1-3, as the MMR counts it, timed from incident creation
  response           creation to first unit on scene = dispatch + travel; valid rows only
  dispatch           creation to first assignment; only the held flag says no unit was free
  travel             first assignment to arrival; it starts at the FIRST assignment, so it
                     includes reassignment and the distance of whichever unit was free, and
                     is not a measure of driving speed
  upgraded           first coded below segment 3 and upgraded to life-threatening later
  held               the file's held flag: no unit could be assigned at once
  fiscal year t      July (t-1) to June t; a 911 week's fiscal year is set by its midpoint
The dispatch file holds sums and counts, so every time here is a mean; the 911 file's
medians are weekly medians, and their average is only an approximation to the median call.
Deterministic: no randomness.

    py src/build_article.py the-ambulance-is-slower              # results, charts, data, the manifest
    py src/build_article.py the-ambulance-is-slower --manifest   # the manifest alone
"""
from __future__ import annotations

import argparse
import re
from pathlib import Path

import numpy as np
import pandas as pd

from _paths import P, REFERENCE
import article_kit as K
from nycdata import city
from sparkylib.quotes import QuoteBook

from . import viz as V

SLUG = "the-ambulance-is-slower"
PATHS = K.article_paths(SLUG)          # output/articles/the_ambulance_is_slower/ and the site folder
OUT = PATHS.out
MMR_PDF = REFERENCE / "reports" / "mmr_fy2026_fdny.pdf"
MMR_URL = "https://www.nyc.gov/assets/operations/downloads/pdf/mmr2026/fdny.pdf"
BOROUGHS = {"BRONX": "Bronx", "BROOKLYN": "Brooklyn", "MANHATTAN": "Manhattan", "QUEENS": "Queens",
            "RICHMOND / STATEN ISLAND": "Staten Island"}
FYS = list(range(2017, 2027))

# Figures printed in the MMR, FY2022 to FY2026 (FDNY chapter, pp. 61-62). Each row is
# found in the saved PDF by check_mmr() before anything is written.
MMR = {
    "lt_incidents": [564412, 605140, 633361, 620467, 610771],
    "amb_incidents": [1531959, 1613316, 1644446, 1615531, 1605979],
    "patient_transports": [959114, 1009613, 1029292, 1001801, 918431],
    "amb_dispatch_travel": ["7:26", "7:59", "8:16", "8:49", "10:32"],
    "amb_end_to_end": ["10:17", "10:43", "10:52", "11:21", "13:09"],
    "combined_dispatch_travel": ["6:31", "7:03", "7:23", "7:45", "9:13"],
    "combined_from_upgrade": ["6:11", "6:31", "6:29", "6:46", "7:28"],
    "structural_fire_dispatch_travel": ["4:32", "4:31", "4:33", "4:35", "4:38"],
    "fire_cos_lt_dispatch_travel": ["5:35", "5:50", "5:56", "5:52", "6:03"],
    "hospital_turnaround": ["36:17", "38:10", "40:50", "40:40", "40:25"],
    "amb_in_service_hours_per_day": [8891, 8686, 8653, 8572, 8109],
}
MMR_FY = [2022, 2023, 2024, 2025, 2026]

# The city's own figure, fiscal 1988 to 2026: ambulance response to life-threatening
# (Segment 1-3) calls as each Mayor's Management Report printed it. The reports' five-year
# windows overlap up to fiscal 2016; the fiscal 2016, 2021 and 2026 windows meet end to end,
# and the dispatch-file rebuild (long_view) checks those two joins. Where a later print revised
# a year (fiscal 2003-2005, by one second), the later print is used. Sources in publication order.
REPORTS = REFERENCE / "reports"
BOOK = QuoteBook(REPORTS)
CITY_SOURCES = [
    # (source id, file, first fiscal year, printed values, how they appear)
    ("nyc_mmr_fy1999_summary", REPORTS / "mmr_fy1999_summary.pdf", 1988,
     ["11:04", "10:03", "8:56", "8:39", "8:21", "8:36", "8:45", "8:46", "8:58", "8:14", "7:54", "7:35"], "chart"),
    ("nyc_mmr_fy2001_vol1", REPORTS / "mmr_fy2001_vol1.pdf", 1997, ["8:14", "7:54", "7:35", "7:48", "7:04"], "row"),
    ("nyc_mmr_fy2004", REPORTS / "mmr_fy2004.pdf", 2000, ["7:48", "7:04", "6:52", "6:54", "7:00"], "row"),
    ("nyc_mmr_fy2006", REPORTS / "mmr_fy2006.pdf", 2002, ["6:52", "6:54", "7:00", "6:46", "6:42"], "row"),
    ("nyc_mmr_fy03_fy12_fdny", P.raw / "safety" / "nyc_mmr_fy03_fy12_fdny.csv", 2003,
     ["6:55", "7:01", "6:47", "6:42", "6:36", "6:39", "6:40", "6:41", "7:00", "6:25"], "csv"),
    ("nyc_mmr_fy2016_fdny", REPORTS / "mmr_fy2016_fdny.pdf", 2012, ["6:25", "6:45", "6:46", "7:04", "7:03"], "row"),
    ("nyc_mmr_fy2021_fdny", REPORTS / "mmr_fy2021_fdny.pdf", 2017, ["6:46", "6:55", "7:23", "7:37", "6:46"], "row"),
    ("nyc_mmr_fy2026_fdny", MMR_PDF, 2022, MMR["amb_dispatch_travel"], "row"),
]
# Sentences quoted from the archive: (source id, saved file, the words).
CITY_QUOTES = [
    ("nyc_mmr_fy1997_summary", "mmr_fy1997_summary.pdf", "The March 1996 merger of the Emergency Medical Service (EMS) into the Fire Department"),
    ("nyc_mmr_fy1997_summary", "mmr_fy1997_summary.pdf", "averaged 8 minutes 14 seconds in Fiscal 1997, compared with 8 minutes 58 seconds the previous year"),
    ("nyc_mmr_fy1999_summary", "mmr_fy1999_summary.pdf", "comparable measurements began in Fiscal 1988"),
    ("nyc_mmr_fy1999_summary", "mmr_fy1999_summary.pdf", "No comparable data is available prior to Fiscal 1988"),
    ("nyc_mmr_fy2001_vol1", "mmr_fy2001_vol1.pdf", "In Fiscal 2001 the average EMS response time to Segments 1-3 incidents was 7:04, compared to 7:48 during Fiscal 2000"),
    # The indicator's two names, either side of the fiscal 2004 bridge.
    ("nyc_mmr_fy2001_vol1", "mmr_fy2001_vol1.pdf", "EMS Average Response Time to Life-Threatening (Segments 1-3) Incidents"),
    ("nyc_mmr_fy2004", "mmr_fy2004.pdf", "Average response time to life-threatening medical emergencies by ambulance units"),
    ("nyc_mmr_fy2006", "mmr_fy2006.pdf", "In November 2005 FDNY began outfitting ambulances with automatic vehicle locators (AVL)"),
    ("nyc_mmr_fy2006", "mmr_fy2006.pdf", "all ambulances were equipped by the end of the fiscal year"),
]
# Everything else the text quotes from a saved source: the fiscal 2026 report's narrative,
# the FDNY's statement to amNY and the working paper's estimates.
QUOTES = [
    ("nyc_mmr_fy2026_fdny", "mmr_fy2026_fdny.pdf", "ambulance in-service hours decreased five percent"),
    ("nyc_mmr_fy2026_fdny", "mmr_fy2026_fdny.pdf", "Some hospitals have removed their ambulances from the 911 system, 11 in Fiscal 2026"),
    ("nyc_mmr_fy2026_fdny", "mmr_fy2026_fdny.pdf", "EMS staffing decreased more than usual this year, as many Emergency Medical Technicians (EMTs) "
                                                   "were called off the promotional exam list to become firefighters"),
    ("amny_ems_response_2026_09", "amny_ems_response_2026-09-21.html",
     "several factors including fewer ambulances on the street and record-breaking call volume"),
    ("nber_w35414_ems_congestion_2026", "nber_w35414_ems_congestion_2026.pdf", "reducing total travel times by 63\u201370 seconds"),
    ("nber_w35414_ems_congestion_2026", "nber_w35414_ems_congestion_2026.pdf", "Travel time to incident scenes declined by approximately 7\u20139 seconds"),
    ("nber_w35414_ems_congestion_2026", "nber_w35414_ems_congestion_2026.pdf", "hospital transport time fell by roughly 54\u201359 seconds"),
    ("nber_w35414_ems_congestion_2026", "nber_w35414_ems_congestion_2026.pdf", "though these estimates are imprecise"),
]


def check_mmr() -> dict:
    """Every MMR row the article uses must appear in the saved PDF as its run of values, in
    order (the indicator table prints a row's five years side by side); returns each row's
    page."""
    found = {}
    for key, vals in MMR.items():
        printed = " ".join(f"{v:,}" if isinstance(v, int) else v for v in vals)
        found[key] = BOOK.quote("nyc_mmr_fy2026_fdny", MMR_PDF.name, printed)["page"]
    return found


def check_quotes(quotes: list) -> list[dict]:
    """Each (source id, file, words) found in its saved source: {"source", "page", "quote"}."""
    return [BOOK.quote(sid, fname, words) for sid, fname, words in quotes]


def _chart_labels(path: Path) -> list[str]:
    """The value labels of the fiscal 1999 report's chart, read left to right by their
    position on the page: the m:ss words between the chart's subtitle ("EMS Units Only") and
    its footnote, less the axis ticks, which stand in a column at the chart's left edge."""
    import fitz  # PyMuPDF, which sparkylib.quotes reads PDFs with

    with fitz.open(path) as doc:
        page = next(pg for pg in doc if "EMS Units Only" in pg.get_text())
        words = page.get_text("words")              # x0, y0, x1, y1, word, block, line, word number
    lines: dict = {}
    for w in words:
        lines.setdefault((w[5], w[6]), []).append(w)
    def y_of(phrase: str) -> float:
        hits = [min(w[1] for w in ws) for ws in lines.values() if phrase in " ".join(w[4] for w in ws)]
        if len(hits) != 1:
            raise SystemExit(f"{path.name}: {phrase!r} found {len(hits)} times on the chart's page")
        return hits[0]
    top, bottom = y_of("EMS Units Only"), y_of("No comparable data is available prior to Fiscal 1988")
    cells = [(w[0], w[4]) for w in words if top < w[1] < bottom and re.fullmatch(r"\d{1,2}:\d{2}", w[4])]
    axis_x = min(x for x, _ in cells) + 10           # the tick column, 13:00 down to 5:00
    return [v for x, v in sorted(cells) if x > axis_x]


def city_series() -> dict:
    """The city's printed figure for every fiscal year from 1988, each value found in its
    saved source; overlapping prints must agree within a second (the reports' revisions)."""
    series, source, printed = {}, {}, {}
    for sid, path, first, vals, how in CITY_SOURCES:
        if how == "chart":
            got = _chart_labels(path)
            if got != vals:
                raise SystemExit(f"{path.name}: chart labels {got} are not {vals}")
        elif how == "row":
            BOOK.quote(sid, path.name, " ".join(vals))
        else:
            rows = pd.read_csv(path, dtype=str)
            row = rows[rows.indicator_name.str.startswith("Average response time to life-threatening medical emergencies by ambulance units")]
            got = [row.iloc[0][f"_{i}"].strip() for i in range(1, 11)]
            if len(row) != 1 or got != vals:
                raise SystemExit(f"{path.name}: ambulance row {got} is not {vals}")
        for i, v in enumerate(vals):
            fy = first + i
            if fy in series and abs(series[fy] - K.secs(v)) > 1:
                raise SystemExit(f"fiscal {fy}: {sid} prints {v}, {source[fy]} printed {K.mmss(series[fy])}")
            series[fy], source[fy] = K.secs(v), sid
            printed.setdefault(fy, []).append([sid, v])
    assert sorted(series) == list(range(1988, 2027)), sorted(series)
    return {"series": series, "source": source, "printed": printed, "quotes": check_quotes(CITY_QUOTES)}


def city_view(cs: dict, lv: dict) -> dict:
    """What the thirty-year view says: the merger year, the low, how 2026 ranks, the
    largest earlier rise, and how closely the dispatch-file rebuild tracks the city."""
    s = pd.Series(cs["series"]).sort_index()
    rise = s.diff().dropna()
    low_fy = int(s.idxmin())
    slower_before = [fy for fy in s.index if fy < 2026 and s[fy] >= s[2026]]
    run = 0
    for fy in range(2026, 1988, -1):
        if s[fy] > s[fy - 1]:
            run += 1
        else:
            break
    rebuilt = {int(k): v for k, v in lv["response_s"].items()}
    gaps = {fy: rebuilt[fy] - s[fy] for fy in rebuilt if fy in s.index}
    return {"fy1988_s": float(s[1988]), "fy1996_s": float(s[1996]), "fy2026_s": float(s[2026]),
            "low_fy": low_fy, "low_s": float(s[low_fy]),
            "fall_1996_to_low_s": float(s[1996] - s[low_fy]),
            "last_year_as_slow_before_2026": int(max(slower_before)) if slower_before else None,
            "rise_2026_s": float(rise[2026]), "largest_prior_rise_s": float(rise.drop(2026).max()),
            "largest_prior_rise_fy": int(rise.drop(2026).idxmax()), "rising_years_to_2026": run,
            "max_1990s_s": float(s.loc[1990:1999].max()),
            "max_2013_2021_s": float(s.loc[2013:2021].max()), "min_2013_2021_s": float(s.loc[2013:2021].min()),
            "rebuild_gap_max_s": float(max(abs(g) for g in gaps.values())),
            "rebuild_gap_first_fy": int(min(gaps)), "rebuild_gap_by_fy": {str(k): float(v) for k, v in gaps.items()}}


def means(d: pd.DataFrame, by) -> pd.DataFrame:
    return city.ems_means(d, by)


def lt(d: pd.DataFrame) -> pd.DataFrame:
    return d[d.lt_final & d.valid]


def fy_table(d: pd.DataFrame) -> pd.DataFrame:
    rows = []
    for fy, x in d[d.fy.isin(FYS)].groupby("fy"):
        a = x[x.lt_final]; v = a[a.valid]
        m = means(v.assign(k=1), "k").iloc[0]
        rows.append({"fy": int(fy), "all_incidents": int(x.n.sum()), "lt_incidents": int(a.n.sum()),
                     "response_s": m.response_s, "dispatch_s": m.dispatch_s, "travel_s": m.travel_s,
                     "held_share": a[a.held].n.sum() / a.n.sum(), "upgraded_share": a[~a.lt_initial].n.sum() / a.n.sum()})
    return pd.DataFrame(rows).set_index("fy")


def decomposition(d: pd.DataFrame) -> dict:
    """Shift-share of the FY2025 -> FY2026 change between calls life-threatening from the
    start and calls upgraded later: within-group change at the average share, plus the
    effect of the change in mix at the average time."""
    v = lt(d[d.fy.isin([2025, 2026])]).copy()
    v["group"] = np.where(v.lt_initial, "from_start", "upgraded")
    g = means(v, ["fy", "group"]); g["share"] = g.n / g.groupby(level=0).n.transform("sum")
    a, b = g.loc[2025], g.loc[2026]
    within = (a.share + b.share) / 2 * (b.response_s - a.response_s)
    mix = (b.share - a.share) * (a.response_s + b.response_s) / 2
    out = {}
    for k in a.index:
        out[k] = {"share_2025": a.share[k], "share_2026": b.share[k], "response_2025": a.response_s[k], "response_2026": b.response_s[k],
                  "dispatch_2025": a.dispatch_s[k], "dispatch_2026": b.dispatch_s[k], "travel_2025": a.travel_s[k], "travel_2026": b.travel_s[k],
                  "within_s": within[k], "mix_s": mix[k], "contribution_s": within[k] + mix[k],
                  "change_s": b.response_s[k] - a.response_s[k], "dispatch_change_s": b.dispatch_s[k] - a.dispatch_s[k],
                  "travel_change_s": b.travel_s[k] - a.travel_s[k]}
    out["total_change_s"] = float((within + mix).sum())
    return out


def call_groups(d: pd.DataFrame) -> dict:
    """Mean times by call group, FY2024-FY2026: life-threatening from the start and not
    held (the ordinary call), from the start but held, and upgraded."""
    v = lt(d[d.fy.isin([2024, 2025, 2026])]).copy()
    v["group"] = np.select([v.lt_initial & ~v.held, v.lt_initial & v.held], ["ordinary", "held"], "upgraded")
    g = means(v, ["group", "fy"])
    allg = means(v, "fy")
    out = {grp: {str(fy): {"response_s": g.loc[(grp, fy), "response_s"], "dispatch_s": g.loc[(grp, fy), "dispatch_s"],
                           "travel_s": g.loc[(grp, fy), "travel_s"], "n": int(g.loc[(grp, fy), "n"])} for fy in (2024, 2025, 2026)}
           for grp in ("ordinary", "held", "upgraded")}
    out["all"] = {str(fy): {"response_s": allg.loc[fy, "response_s"], "dispatch_s": allg.loc[fy, "dispatch_s"],
                            "travel_s": allg.loc[fy, "travel_s"], "n": int(allg.loc[fy, "n"])} for fy in (2024, 2025, 2026)}
    held_all = means(v.assign(h=np.where(v.held, "held", "not")), ["h", "fy"])
    out["held_any"] = {str(fy): {"response_s": held_all.loc[("held", fy), "response_s"], "dispatch_s": held_all.loc[("held", fy), "dispatch_s"]} for fy in (2025, 2026)}
    return out


def boroughs(d: pd.DataFrame) -> dict:
    """Per borough and call group (all, from the start, upgraded), FY2017-FY2026."""
    x = d[d.fy.isin(FYS) & d.borough.isin(BOROUGHS)].copy()
    x["boro"] = x.borough.map(BOROUGHS)
    out = {}
    for grp, mask in (("all", x.lt_final), ("from_start", x.lt_final & x.lt_initial), ("upgraded", x.lt_final & ~x.lt_initial)):
        sub = x[mask]
        m = means(sub[sub.valid], ["boro", "fy"]); n_all = sub.groupby(["boro", "fy"]).n.sum(); n_held = sub[sub.held].groupby(["boro", "fy"]).n.sum()
        mc = means(sub[sub.valid], "fy"); nc = sub.groupby("fy").n.sum(); hc = sub[sub.held].groupby("fy").n.sum()
        out[grp] = {}
        for b in list(BOROUGHS.values()) + ["City"]:
            out[grp][b] = {}
            for fy in FYS:
                if b == "City":
                    r, n, h = mc.loc[fy], nc.loc[fy], hc.get(fy, 0)
                else:
                    r, n, h = m.loc[(b, fy)], n_all.loc[(b, fy)], n_held.get((b, fy), 0)
                out[grp][b][str(fy)] = {"response_s": r.response_s, "dispatch_s": r.dispatch_s, "travel_s": r.travel_s,
                                        "held_share": h / n, "incidents": int(n)}
    return out


def monthly_series(d: pd.DataFrame) -> pd.DataFrame:
    v = lt(d)
    m = means(v, "month")
    m["lt_n"] = d[d.lt_final].groupby("month").n.sum()
    m["held_share"] = d[d.lt_final & d.held].groupby("month").n.sum() / m.lt_n
    m["avg12"] = (v.groupby("month").sum_incident_rspns.sum().rolling(12).sum() / v.groupby("month").n.sum().rolling(12).sum())
    return m


def noise(m: pd.DataFrame) -> dict:
    yoy = m.response_s.diff(12)
    covid = pd.date_range("2020-03-01", "2021-06-01", freq="MS")
    pre = yoy.loc["2017-01-01":"2025-06-01"].drop(covid, errors="ignore")
    fy26 = yoy.loc["2025-07-01":"2026-06-01"]
    return {"yoy_mean_s": pre.mean(), "yoy_sd_s": pre.std(), "yoy_max_s": pre.max(),
            "fy26_months_above_max": int((fy26 > pre.max()).sum()), "first_month_above": str(fy26[fy26 > pre.max()].index.min().date())[:7]}


def end_to_end_summary() -> dict:
    e = city.end_to_end()
    e = e[(e.Agency == "EMS") & e["Final Incident Type"].str.startswith("1.", na=False)].copy()

    def wmean(x, col): return float((x[col] * x.n).sum() / x.n.sum())
    fy = {}
    for f, x in e[e.fy.between(2015, 2026)].groupby("fy"):
        fy[str(int(f))] = {"call_to_arrival_s": wmean(x, "Call to Agency Arrival"), "call_to_job_s": wmean(x, "Call to Agency Job Creation"),
                           "dispatch_s": wmean(x, "avg_dispatch_s"), "travel_s": wmean(x, "avg_travel_s"),
                           "median_dispatch_s": wmean(x, "Median Dispatch"), "median_travel_s": wmean(x, "Median Travel"), "weeks": len(x)}
    summers = {}
    # Eight matched summer weeks; the latest week in the file (24 Aug 2026) holds fewer
    # incidents than usual and may be incomplete, so the window stops the week before.
    for lab, s0, s1 in (("2024", "2024-07-01", "2024-08-19"), ("2025", "2025-06-30", "2025-08-18"), ("2026", "2026-06-29", "2026-08-17")):
        x = e[e.week.between(s0, s1)]
        summers[lab] = {"call_to_arrival_s": wmean(x, "Call to Agency Arrival"), "weeks": len(x), "first_week": str(x.week.min().date()), "last_week": str(x.week.max().date())}
    # The same windows one week longer: how much the possibly incomplete week would change the gap.
    with_latest = {}
    for lab, s0, s1 in (("2025", "2025-06-30", "2025-08-25"), ("2026", "2026-06-29", "2026-08-24")):
        x = e[e.week.between(s0, s1)]
        with_latest[lab] = {"call_to_arrival_s": wmean(x, "Call to Agency Arrival"), "weeks": len(x)}
    slowest = e.sort_values("Call to Agency Arrival", ascending=False).iloc[0]
    last = e.iloc[-1]
    return {"fy": fy, "summers": summers, "summers_with_latest_week": with_latest, "slowest_week":{"week": str(slowest.week.date()), "call_to_arrival_s": float(slowest["Call to Agency Arrival"])},
            "latest_week": str(last.week.date()), "latest_week_incidents": int(last.n),
            "typical_week_incidents": float(e.iloc[-14:-1].n.mean())}


def mmr_context(fyt: pd.DataFrame) -> dict:
    h = dict(zip(MMR_FY, MMR["amb_in_service_hours_per_day"]))
    c = {"in_service_hours_per_day": h, "hospital_turnaround_s": dict(zip(MMR_FY, map(K.secs, MMR["hospital_turnaround"]))),
         "patient_transports": dict(zip(MMR_FY, MMR["patient_transports"])), "lt_incidents_mmr": dict(zip(MMR_FY, MMR["lt_incidents"])),
         "combined_dispatch_travel_s": dict(zip(MMR_FY, map(K.secs, MMR["combined_dispatch_travel"]))),
         "combined_from_upgrade_s": dict(zip(MMR_FY, map(K.secs, MMR["combined_from_upgrade"]))),
         "structural_fire_s": dict(zip(MMR_FY, map(K.secs, MMR["structural_fire_dispatch_travel"]))),
         "fire_cos_lt_s": dict(zip(MMR_FY, map(K.secs, MMR["fire_cos_lt_dispatch_travel"]))),
         "amb_dispatch_travel_s": dict(zip(MMR_FY, map(K.secs, MMR["amb_dispatch_travel"]))),
         "amb_end_to_end_s": dict(zip(MMR_FY, map(K.secs, MMR["amb_end_to_end"])))}
    c["in_service_change_pct"] = (h[2026] / h[2025] - 1) * 100
    c["in_service_hours_drop"] = h[2025] - h[2026]
    lt_m = dict(zip(MMR_FY, MMR["lt_incidents"])); amb = dict(zip(MMR_FY, MMR["amb_incidents"]))
    c["amb_incidents_mmr"] = amb
    c["lt_incidents_mmr_change_pct"] = (lt_m[2026] / lt_m[2025] - 1) * 100
    c["amb_incidents_mmr_change_pct"] = (amb[2026] / amb[2025] - 1) * 100
    # Calls per ambulance in-service hour, both from the report: the load on the fleet. The
    # report gives hours a day, so a year's hours are those times its days (366 in fiscal 2024).
    days = {fy: (pd.Timestamp(fy, 7, 1) - pd.Timestamp(fy - 1, 7, 1)).days for fy in MMR_FY}
    hours = {fy: h[fy] * days[fy] for fy in MMR_FY}
    c["days_in_fy"], c["in_service_hours_year"] = days, hours
    c["lt_per_inservice_hour"] = {fy: lt_m[fy] / hours[fy] for fy in MMR_FY}
    c["lt_per_hour_change_pct"] = (c["lt_per_inservice_hour"][2026] / c["lt_per_inservice_hour"][2025] - 1) * 100
    c["amb_per_hour_change_pct"] = ((amb[2026] / hours[2026]) / (amb[2025] / hours[2025]) - 1) * 100
    # Index, FY2022 = 100, for the supply-and-demand chart.
    c["index"] = {"in_service_hours": {fy: h[fy] / h[2022] * 100 for fy in MMR_FY},
                  "lt_incidents": {fy: lt_m[fy] / lt_m[2022] * 100 for fy in MMR_FY},
                  "lt_per_hour": {fy: c["lt_per_inservice_hour"][fy] / c["lt_per_inservice_hour"][2022] * 100 for fy in MMR_FY},
                  "amb_incidents": {fy: amb[fy] / amb[2022] * 100 for fy in MMR_FY},
                  "amb_per_hour": {fy: (amb[fy] / hours[fy]) / (amb[2022] / hours[2022]) * 100 for fy in MMR_FY},
                  "response": {fy: fyt.loc[fy, "response_s"] / fyt.loc[2022, "response_s"] * 100 for fy in MMR_FY},
                  "hospital_turnaround": {fy: K.secs(t) / K.secs(MMR["hospital_turnaround"][0]) * 100 for fy, t in zip(MMR_FY, MMR["hospital_turnaround"])}}
    return c


def long_monthly() -> pd.DataFrame:
    """Monthly life-threatening response from January 2005, with its 12-month average."""
    L = pd.read_csv(P.raw / "safety" / "nyc_ems_dispatch_lt_monthly_long.csv")
    L["month"] = pd.to_datetime(L["month"].str[:10])
    L = L.set_index("month").sort_index()
    L["response_s"] = L.sum_incident_rspns / L.n
    L["avg12"] = L.sum_incident_rspns.rolling(12).sum() / L.n.rolling(12).sum()
    return L[["response_s", "avg12"]]


def long_view(fyt: pd.DataFrame) -> dict:
    """Life-threatening response by fiscal year from FY2006 (the first full one in the file)."""
    L = pd.read_csv(P.raw / "safety" / "nyc_ems_dispatch_lt_monthly_long.csv")
    L["month"] = pd.to_datetime(L["month"].str[:10]); L["fy"] = city.fiscal_year(L["month"])
    g = L[L.fy.between(2006, 2026)].groupby("fy")[["n", "sum_incident_rspns"]].sum()
    r = (g.sum_incident_rspns / g.n).rename("response_s")
    for fy in FYS:      # the same filter as the main monthly sums: they must agree
        assert abs(r[fy] - fyt.loc[fy, "response_s"]) < 1, (fy, r[fy], fyt.loc[fy, "response_s"])
    rise = r.diff().dropna()
    prior = rise.drop(2026)
    return {"first_fy": int(r.index.min()), "response_s": {str(int(k)): float(v) for k, v in r.items()},
            "rise_2026_s": float(rise[2026]), "largest_prior_rise_s": float(prior.max()), "largest_prior_rise_fy": int(prior.idxmax()),
            "slowest_before_2026_fy": int(r.drop(2026).idxmax()), "slowest_before_2026_s": float(r.drop(2026).max())}


def companion(d: pd.DataFrame) -> pd.DataFrame:
    """The monthly sums themselves, life-threatening and not, by borough and call flags: a
    reader can rebuild every mean as sum / incidents."""
    x = d.copy()
    x["borough"] = x.borough.map(BOROUGHS).fillna("Unknown")
    out = x.groupby([x.month.dt.strftime("%Y-%m"), "fy", "borough", "lt_final", "lt_initial", "valid", "held"]).agg(
        incidents=("n", "sum"), sum_response_s=("sum_incident_rspns", "sum"), sum_dispatch_s=("sum_dispatch", "sum"), sum_travel_s=("sum_travel", "sum")).reset_index()
    out = out.rename(columns={"month": "month", "lt_final": "life_threatening_final", "lt_initial": "life_threatening_initial", "valid": "valid_times", "held": "held_call"})
    for c in ("life_threatening_final", "life_threatening_initial", "valid_times", "held_call"): out[c] = out[c].astype(int)
    for c in ("sum_response_s", "sum_dispatch_s", "sum_travel_s"): out[c] = out[c].round(0).astype("int64")
    return out


def main(argv=None) -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.parse_args(argv)
    rows = check_mmr()
    d = city.ems_dispatch()
    fyt = fy_table(d)
    anchor = {fy: {"mmr_s": K.secs(MMR["amb_dispatch_travel"][i]), "reproduced_s": fyt.loc[fy, "response_s"],
                   "mmr_lt_incidents": MMR["lt_incidents"][i], "reproduced_lt_incidents": int(fyt.loc[fy, "lt_incidents"])} for i, fy in enumerate(MMR_FY)}
    m = monthly_series(d)
    e2e = end_to_end_summary()
    res = {"article": SLUG, "as_of": "2026-10-01",
           "latest": {"dispatch_month": m.index.max().strftime("%Y-%m"), "e2e_week": e2e["latest_week"]},
           "mmr_rows_checked": rows, "anchor": anchor,
           "fy": fyt.to_dict(orient="index"),
           "change_2025_2026": {"response_s": fyt.loc[2026, "response_s"] - fyt.loc[2025, "response_s"],
                                "dispatch_s": fyt.loc[2026, "dispatch_s"] - fyt.loc[2025, "dispatch_s"],
                                "travel_s": fyt.loc[2026, "travel_s"] - fyt.loc[2025, "travel_s"],
                                "lt_incidents_pct": (fyt.loc[2026, "lt_incidents"] / fyt.loc[2025, "lt_incidents"] - 1) * 100,
                                "all_incidents_pct": (fyt.loc[2026, "all_incidents"] / fyt.loc[2025, "all_incidents"] - 1) * 100,
                                "largest_rise_before_s": fyt.response_s.diff().drop(2026).max(),
                                # The same rise under the initial-coding definition (initial segment 1-3),
                                # which leaves out calls upgraded later.
                                "initial_def_s": (lambda g: g.loc[2026, "response_s"] - g.loc[2025, "response_s"])(
                                    means(d[d.lt_initial & d.valid & d.fy.isin([2025, 2026])], "fy")),
                                "call_taking_s": e2e["fy"]["2026"]["call_to_job_s"] - e2e["fy"]["2025"]["call_to_job_s"],
                                "median_dispatch_s": e2e["fy"]["2026"]["median_dispatch_s"] - e2e["fy"]["2025"]["median_dispatch_s"],
                                "median_travel_s": e2e["fy"]["2026"]["median_travel_s"] - e2e["fy"]["2025"]["median_travel_s"]},
           "decomposition": decomposition(d), "call_groups": call_groups(d), "boroughs": boroughs(d),
           "monthly": {"latest_response_s": m.response_s.iloc[-1], "latest_same_month_prior_s": m.response_s.iloc[-13],
                       "slowest": [[i.strftime("%Y-%m"), v] for i, v in m.response_s.sort_values(ascending=False).head(10).items()],
                       "march_2020_s": m.loc["2020-03-01", "response_s"], "noise": noise(m)},
           "end_to_end": e2e, "mmr": mmr_context(fyt), "long_view": long_view(fyt),
           "slowest_fy_dispatch_file": int(fyt.response_s.idxmax()),
           "slowest_fy_end_to_end": max(e2e["fy"], key=lambda k: e2e["fy"][k]["call_to_arrival_s"])}
    cs = city_series()
    res["city_series"] = {"by_fy": {str(k): v for k, v in cs["series"].items()},
                          "source_by_fy": {str(k): v for k, v in cs["source"].items()},
                          "printed_by_fy": {str(k): v for k, v in cs["printed"].items()},
                          "view": city_view(cs, res["long_view"])}
    res["quotes"] = cs["quotes"] + check_quotes(QUOTES)
    res = K.clean(res, nd=4)
    PATHS.json("results.json", res)
    PATHS.json("monthly.json", {i.strftime("%Y-%m"): {"response_s": r.response_s, "dispatch_s": r.dispatch_s, "travel_s": r.travel_s,
                                                      "avg12_s": r.avg12, "held_share": r.held_share, "lt_incidents": int(r.lt_n)}
                                for i, r in m.iterrows()}, compact=True, nd=4)
    PATHS.csv("data.csv", companion(d))
    PATHS.serve(Path(__file__), "analysis.py")
    ml = long_monthly()
    assert (ml.loc[m.index, "response_s"] - m.response_s).abs().max() < 1, "long and main monthly series disagree"
    V.build(res, ml, PATHS)
    c = res["change_2025_2026"]
    print(f"FY2026 response {K.mmss(res['fy']['2026']['response_s'])} (MMR 10:32), change {c['response_s']:+.0f} s: dispatch {c['dispatch_s']:+.0f}, travel {c['travel_s']:+.0f}, call-taking {c['call_taking_s']:+.0f}; latest {res['latest']}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
