#!/usr/bin/env python3
"""Count a market from the public record for a case study page.

Usage:  python3 tools/case-study.py <PublicBenefit>/markets [slug ...]

Reads src/_data/case-studies.json for each study's ridings, counts the
Canary v3 score file (markets/_processed/canary_v3_scores_2024.csv), and
where the market has a data pack with a revoked.csv, counts revocations
2022 to 2025. Writes src/_data/case-data.json, one entry per slug.
Everything is arithmetic on public files; nothing is edited by hand here.
Names never leave this script. Any count under five is written as null and
the page prints "fewer than five".
"""
import csv, json, os, re, sys, collections, statistics

BANDS = [("<25k", "Under $25,000"), ("25-75k", "$25,000 to $75,000"), ("75-150k", "$75,000 to $150,000"),
         ("150-500k", "$150,000 to $500,000"), ("500k-2m", "$500,000 to $2 million"), ("2m+", "Over $2 million")]
HEADS = [("Advancement of Religion", "Religion"), ("Beneficial to Community", "Community benefit"),
         ("Relief of Poverty", "Relief of poverty"), ("Advancement of Education", "Education")]
SMALL = {"<25k", "25-75k", "75-150k"}
SUPPRESS = 5

def norm(s):  # riding names in the score file use an em dash; the site uses an en dash
    # Only the em dash converts. A hyphen inside a compound place name stays a
    # hyphen (Trois-Rivieres, Saint-Maurice): converting it wrote names no atlas
    # has. Fixed 12 September 2026, with the two names it had already spoiled.
    return s.replace("—", "–")

def cell(n):
    return None if 0 < n < SUPPRESS else n

def parts(total, *ns):
    """Suppress the parts of a printed total together (rule of 7 September 2026): a total
    beside one suppressed part gives that part by subtraction, so if any part is under
    five, every part is withheld and only the total prints."""
    cs = [cell(n) for n in ns]
    return cs if all(c is not None for c in cs) else [None] * len(ns)

def merged(rows):
    """Bands under five are folded into a neighbour until every printed band is five or
    more (rule of 8 September 2026, for the smallest sectors): the ladder keeps its
    order, the labels join, and no rung prints a count that is a name."""
    rows = [dict(r) for r in rows]
    while len(rows) > 1 and any(0 < r["n"] < SUPPRESS for r in rows):
        i = next(i for i, r in enumerate(rows) if 0 < r["n"] < SUPPRESS)
        j = i + 1 if i + 1 < len(rows) else i - 1
        a, b = rows[min(i, j)], rows[max(i, j)]
        lo = a["label"].split(" to ")[0].replace("Under ", "Under ")
        first = a["label"]; last = b["label"]
        if first.startswith("Under") and last.startswith("Over"):
            label = "All bands"
        elif first.startswith("Under"):
            label = "Under " + (last.split(" to ")[1] if " to " in last else last.replace("Over ", ""))
        elif last.startswith("Over"):
            label = first.split(" to ")[0] + " and over"
        else:
            label = first.split(" to ")[0] + " to " + last.split(" to ")[1]
        rows[min(i, j):max(i, j) + 1] = [{"label": label, "n": a["n"] + b["n"]}]
    return rows

def main():
    if len(sys.argv) < 2:
        sys.exit(__doc__)
    mk = sys.argv[1]
    here = os.path.dirname(os.path.abspath(__file__))
    studies = json.load(open(os.path.join(here, "..", "src", "_data", "caseStudies.json"), encoding="utf-8"))
    want = set(sys.argv[2:])
    rows = list(csv.DictReader(open(os.path.join(mk, "_processed", "canary_v3_scores_2024.csv"), encoding="utf-8")))
    national = sum(r["canary_flag"] == "1" for r in rows) / len(rows) * 100
    out_path = os.path.join(here, "..", "src", "_data", "caseData.json")
    out = json.load(open(out_path, encoding="utf-8")) if os.path.exists(out_path) else {}
    gaz = list(csv.DictReader(open(os.path.join(mk, "_processed", "gazette_revocations.csv"), encoding="utf-8")))
    for s in studies["studies"]:
        if want and s["slug"] not in want:
            continue
        if s.get("kind") == "tradition":
            sel = s["select"]
            if "bn" in sel:
                pick = lambda x: x["bn"].startswith(sel["bn"])
                gpick = lambda x: x["bn"].startswith(sel["bn"])
            elif "regex" in sel:
                rx, exc = re.compile(sel["regex"]), re.compile(sel.get("exclude", "(?!)"))
                pick = lambda x: bool(rx.search(x["legal_name"].upper())) and not exc.search(x["legal_name"].upper())
                gpick0 = lambda x: bool(rx.search(x["charity_name"].upper())) and not exc.search(x["charity_name"].upper())
                gpick = gpick0
            else:
                pat, exc = sel["name"], re.compile(sel.get("exclude", "(?!)"))
                pick = lambda x: pat in x["legal_name"].upper() and not exc.search(x["legal_name"].upper())
                gpick = lambda x: pat in x["charity_name"].upper() and not exc.search(x["charity_name"].upper())
            r = [x for x in rows if pick(x)]
            shared = None
            if sel.get("split") == "sharedBN":
                pref = collections.Counter(x["bn"][:9] for x in r)
                shared = {k for k, v in pref.items() if v >= 5}
                gpick = lambda x, g0=gpick: g0(x) or x["bn"][:9] in shared
            gv = [x for x in gaz if gpick(x)]
        else:
            ridings = {norm(r) for r in s["ridings"]}
            r = [x for x in rows if norm(x["riding_name_en"]) in ridings]
            gv = None
        n = len(r)
        boards = [float(x["board_size"]) for x in r if x["board_size"]]
        flagged = [x for x in r if x["canary_flag"] == "1"]
        d = {
            "scored": n,
            "byRiding": {k: v for k, v in collections.Counter(norm(x["riding_name_en"]) for x in r).items()} if not gv else None,
            "byRegion": {k: v for k, v in collections.Counter(x["region"] or "Unassigned" for x in r).items()} if gv else None,
            "bands": merged([{"label": lab, "n": sum(x["size_band"] == k for x in r)} for k, lab in BANDS]),
            "heads": [{"label": lab, "n": cell(sum(x["head"] == k for x in r))} for k, lab in HEADS],
            "smallShare": round(sum(x["size_band"] in SMALL for x in r) / n * 100),
            "medianRevenue": int(statistics.median(float(x["revenue"]) for x in r if x["revenue"])),
            "boards": len(boards),
            "boardsSmall": cell(sum(b <= 3 for b in boards)),
            "boardMedian": int(statistics.median(boards)),
            "flagged": cell(len(flagged)),
            "flaggedRate": None if cell(len(flagged)) is None else round(len(flagged) / n * 100, 1),
            "nationalRate": round(national, 1),
            "flaggedSmall": cell(sum(x["size_band"] in SMALL for x in flagged)),
            "distress": cell(sum(x["distress_flag"] == "1" for x in r)),
        }
        if gv is not None:
            years = collections.Counter(x["revocation_date"][:4] for x in gv)
            d["revokedByYear"] = [{"year": y, "n": years.get(str(y), 0)} for y in range(2011, 2027)]
            since = [x for x in gv if x["revocation_date"] >= "2019"]
            d["revoked"] = cell(len(since))
            d["revokedFailure"], d["revokedVoluntary"] = parts(len(since), sum(x["reason_type"] == "Failure to file" for x in since), sum(x["reason_type"] == "Voluntary" for x in since))
            d["revokedWindow"] = "2019 to " + max(x["revocation_date"] for x in gv)[:4]
            d["revokedTotal"] = len(gv)
            d["revenueTotal"] = int(sum(float(x["revenue"]) for x in r if x["revenue"]))
            if shared:
                def part(rr, gg):
                    bs = [float(x["board_size"]) for x in rr if x["board_size"]]
                    gs = [x for x in gg if x["revocation_date"] >= "2019"]
                    return {"scored": len(rr), "flagged": cell(len([x for x in rr if x["canary_flag"] == "1"])),
                            "flaggedRate": None if cell(len([x for x in rr if x["canary_flag"] == "1"])) is None else round(len([x for x in rr if x["canary_flag"] == "1"]) / len(rr) * 100, 1),
                            "boardsSmallShare": round(sum(b <= 3 for b in bs) / len(bs) * 100),
                            "medianRevenue": int(statistics.median(float(x["revenue"]) for x in rr if x["revenue"])),
                            "revoked": cell(len(gs)), "revokedFailure": parts(len(gs), sum(x["reason_type"] == "Failure to file" for x in gs), sum(x["reason_type"] == "Voluntary" for x in gs))[0],
                            "revokedVoluntary": parts(len(gs), sum(x["reason_type"] == "Failure to file" for x in gs), sum(x["reason_type"] == "Voluntary" for x in gs))[1]}
                d["split"] = {"held": part([x for x in r if x["bn"][:9] in shared], [x for x in gv if x["bn"][:9] in shared]),
                              "independent": part([x for x in r if x["bn"][:9] not in shared], [x for x in gv if x["bn"][:9] not in shared]),
                              "holders": len(shared)}
        pack = s.get("pack")
        if pack:
            cands = [os.path.join(mk, pack, "data", f"{pack}_revoked.csv"), os.path.join(mk, pack, "data", f"{s.get('packPrefix', pack)}_revoked.csv")]
            rv = next((c for c in cands if os.path.exists(c)), None)
            if rv:
                rev = [x for x in csv.DictReader(open(rv, encoding="utf-8")) if "2022" <= x["revocation_date"][:4] <= "2025"]
                d["revoked"] = cell(len(rev))
                d["revokedFailure"], d["revokedVoluntary"] = parts(len(rev), sum(x["reason_type"] == "Failure to file" for x in rev), sum(x["reason_type"] == "Voluntary" for x in rev))
                d["revokedWindow"] = "2022 to 2025"
        if gv is not None:  # keep the selection for the network script, outside the site
            os.makedirs(os.path.join(here, ".cache"), exist_ok=True)
            open(os.path.join(here, ".cache", f"bn-{s['slug']}.txt"), "w").write("\n".join(x["bn"] for x in r))
        out[s["slug"]] = d
        print(s["slug"], n, d["flagged"], d["distress"], d.get("revoked"))
    json.dump(out, open(out_path, "w", encoding="utf-8"), indent=2, ensure_ascii=False)

if __name__ == "__main__":
    main()
