#!/usr/bin/env python3
"""
Onderzoek: hoeveel datasets staan er per gemeente op data.overheid.nl?

Auteur: TechinFlow (techinflow.nl), 11 oktober 2026. Licentie code: MIT.
De uitvoer (CSV) mag worden hergebruikt onder CC BY 4.0, met bronvermelding
"TechinFlow, op basis van data.overheid.nl en CBS StatLine".

Wat het script doet
1. Haalt de metadata van alle datasets op via de CKAN-API van data.overheid.nl
   (action/package_search, 1.000 per pagina, gesorteerd op id).
2. Haalt de gemeentelijke indeling 2026 (CBS-tabel 86247NED, met provincie) en
   de bevolking op 1 januari 2026 (CBS-tabel 03759ned) op via de OData-API van CBS.
3. Rekent elke dataset toe aan een gemeente via het DCAT-AP-DONL-veld `authority`
   (de bronhouder, een waarde uit de OWMS-lijst). Het veld `publisher` en de
   CKAN-organisatie worden als controle meegeteld.
4. Schrijft:
   - <prefix>.csv            één regel per gemeente (alle gemeenten, ook 0)
   - <prefix>-datasets.csv   één regel per dataset die aan een gemeente is toegerekend
   - <prefix>-snapshot.jsonl.gz  de ruwe API-respons (gebruikte velden), plus SHA-256

Gebruik:  python3 open-data-gemeenten.py [uitvoermap]
Alleen standaardbibliotheek; getest met Python 3.12+.
"""
from __future__ import annotations

import csv
import datetime as dt
import gzip
import hashlib
import json
import re
import sys
import time
import unicodedata
import urllib.parse
import urllib.request
from collections import Counter, defaultdict
from pathlib import Path

CKAN = "https://data.overheid.nl/data/api/3/action/package_search"
CBS = "https://opendata.cbs.nl/ODataApi/odata"
OWMS = "http://standaarden.overheid.nl/owms/terms/"
FIELDS = ",".join([
    "id", "name", "title", "authority", "publisher", "organization", "license_id",
    "res_format", "high_value", "source_catalog", "metadata_modified", "modified",
    "dataset_status", "res_url",
])
PEILDATUM = dt.date.today().isoformat()
UA = {"User-Agent": "techinflow-onderzoek/1.0 (+https://techinflow.nl/onderzoek/open-data-gemeenten/)"}

# OWMS-waarden van gemeenten die per 2026 niet meer bestaan -> huidige CBS-code.
# Bron samenvoeging: CBS, Gebieden in Nederland / gemeentelijke herindelingen.
HISTORISCH = {
    "delfzijl": ("GM1979", "Delfzijl is per 1 januari 2021 opgegaan in Eemsdelta"),
}

# CKAN-organisaties (het account dat registreert) die bij één gemeente horen. Alleen voor de
# ruime telling; de hoofdtelling gebruikt `authority`.
ORG_GEMEENTE = {
    "gemeente-amsterdam": "Amsterdam", "gemeente-arnhem": "Arnhem", "gemeente-assen": "Assen",
    "gemeente-breda": "Breda", "gemeente-den-haag": "'s-Gravenhage", "gemeente-hilversum": "Hilversum",
    "gemeente-nijmegen": "Nijmegen", "gemeente-rotterdam": "Rotterdam",
    "gemeente-zwartewaterland": "Zwartewaterland", "gemeente-land-van-cuijk": "Land van Cuijk",
    "data-utrecht-nl": "Utrecht", "data-groningen-nl": "Groningen", "data-eindhoven-nl": "Eindhoven",
    "data-delft-opendata-arcgis-com": "Delft", "data-oss-nl": "Oss",
}

# Gemeenschappelijke regelingen en samenwerkingsverbanden: niet aan één gemeente toegerekend.
SAMENWERKING = {"bar-organisatie"}

OPEN_LICENTIES = {
    "http://creativecommons.org/publicdomain/zero/1.0/deed.nl": "CC0 1.0",
    "http://creativecommons.org/publicdomain/mark/1.0/deed.nl": "Public Domain Mark",
    "http://creativecommons.org/licenses/by/4.0/deed.nl": "CC BY 4.0",
    "http://creativecommons.org/licenses/by-sa/4.0/deed.nl": "CC BY-SA 4.0",
    "http://opendatacommons.org/licenses/odbl/1-0/": "ODbL 1.0",
}


def get_json(url: str, params: dict | None = None, tries: int = 4) -> dict:
    if params:
        url = url + "?" + urllib.parse.urlencode(params)
    for i in range(tries):
        try:
            with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=120) as r:
                return json.load(r)
        except Exception as exc:  # netwerk of 5xx: kort wachten en opnieuw
            if i == tries - 1:
                raise
            print(f"  fout ({exc}); opnieuw over {2 ** i} s", file=sys.stderr)
            time.sleep(2 ** i)
    raise RuntimeError("onbereikbaar")


def norm(s: str) -> str:
    """Naam vergelijkbaar maken: diakrieten, hoofdletters, spaties, streepjes, apostrofs weg."""
    s = urllib.parse.unquote(s)
    s = re.sub(r"_\((gemeente)\)$", "", s)
    s = re.sub(r"\s*\(gemeente\)$", "", s)
    s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode()
    return re.sub(r"[^a-z]", "", s.lower())


def fetch_ckan() -> list[dict]:
    rows, start, out = 1000, 0, []
    while True:
        res = get_json(CKAN, {"rows": rows, "start": start, "sort": "id asc", "fl": FIELDS})["result"]
        out.extend(res["results"])
        print(f"  CKAN {len(out)}/{res['count']}", file=sys.stderr)
        start += rows
        if start >= res["count"]:
            return out
        time.sleep(1)  # vriendelijk voor het portaal


def fetch_cbs() -> list[dict]:
    gebieden = get_json(f"{CBS}/86247NED/TypedDataSet", {
        "$select": "Code_1,Naam_2,Code_28,Naam_29", "$format": "json"})["value"]
    bev = get_json(f"{CBS}/03759ned/TypedDataSet", {
        "$filter": "Geslacht eq 'T001038' and Leeftijd eq '10000' and BurgerlijkeStaat eq 'T001019' "
                   "and Perioden eq '2026JJ00' and substringof('GM',RegioS)",
        "$select": "RegioS,BevolkingOp1Januari_1", "$format": "json"})["value"]
    inw = {b["RegioS"].strip(): b["BevolkingOp1Januari_1"] for b in bev}
    return [{
        "code": g["Code_1"].strip(), "naam": g["Naam_2"].strip(),
        "provincie": g["Naam_29"].strip(), "inwoners": inw.get(g["Code_1"].strip()),
    } for g in gebieden if g["Code_1"].strip().startswith("GM")]


by_base: dict[str, dict] = {}


def toewijzen(uri: str | None, by_norm: dict[str, dict], by_code: dict[str, dict]) -> tuple[dict | None, str]:
    if not uri or not uri.startswith(OWMS):
        return None, "geen OWMS-waarde"
    term = uri[len(OWMS):]
    n = norm(term)
    if norm(term) in SAMENWERKING or term.lower() in SAMENWERKING:
        return None, "samenwerkingsverband"
    if n in HISTORISCH:
        return by_code[HISTORISCH[n][0]], "historische naam"
    # Groningen en Utrecht bestaan als gemeente én provincie; OWMS zet er dan een achtervoegsel bij.
    if term.endswith("_(provincie)") or term in ("Groningen", "Utrecht"):
        return None, "geen gemeente"
    g = by_norm.get(n)
    if g:
        return g, "naam"
    # CBS onderscheidt enkele namen met een achtervoegsel, bv. "Middelburg (Z.)".
    g = by_base.get(n)
    return (g, "naam zonder achtervoegsel") if g else (None, "geen gemeente")


def main() -> None:
    outdir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(".")
    outdir.mkdir(parents=True, exist_ok=True)
    prefix = outdir / f"open-data-gemeenten-{PEILDATUM}"

    print("CBS ophalen", file=sys.stderr)
    gemeenten = fetch_cbs()
    by_norm = {norm(g["naam"]): g for g in gemeenten}
    by_code = {g["code"]: g for g in gemeenten}
    assert len(by_norm) == len(gemeenten), "dubbele genormaliseerde gemeentenaam"
    basis = Counter(norm(re.sub(r"\s*\(.*\)$", "", g["naam"])) for g in gemeenten)
    for g in gemeenten:
        b = norm(re.sub(r"\s*\(.*\)$", "", g["naam"]))
        if basis[b] == 1 and b not in by_norm:
            by_base[b] = g
    org_code = {o: by_norm[norm(n)]["code"] for o, n in ORG_GEMEENTE.items()}

    print("data.overheid.nl ophalen", file=sys.stderr)
    pakketten = fetch_ckan()

    snap = Path(f"{prefix}-snapshot.jsonl.gz")
    with gzip.open(snap, "wt", encoding="utf-8") as f:
        for p in pakketten:
            f.write(json.dumps(p, ensure_ascii=False, sort_keys=True) + "\n")
    raw_hash = hashlib.sha256(
        "".join(json.dumps(p, ensure_ascii=False, sort_keys=True) + "\n" for p in pakketten).encode()
    ).hexdigest()

    per = defaultdict(list)
    per_pub = Counter()
    ruim = defaultdict(set)
    niet_gemeente = Counter()
    samenwerking = Counter()
    hist = Counter()
    for p in pakketten:
        g, how = toewijzen(p.get("authority"), by_norm, by_code)
        if g:
            per[g["code"]].append(p)
            if how == "historische naam":
                hist[g["code"]] += 1
        elif how == "samenwerkingsverband":
            samenwerking[p.get("authority")] += 1
        else:
            niet_gemeente[p.get("authority")] += 1
        gp, _ = toewijzen(p.get("publisher"), by_norm, by_code)
        if gp:
            per_pub[gp["code"]] += 1
        # Ruime telling: authority, publisher of CKAN-organisatie wijst naar de gemeente.
        for code in {g and g["code"], gp and gp["code"], org_code.get(p.get("organization"))} - {None}:
            ruim[code].add(p["id"])

    # Datasetniveau
    with open(f"{prefix}-datasets.csv", "w", newline="", encoding="utf-8") as f:
        w = csv.writer(f)
        w.writerow(["gemeentecode", "gemeente", "dataset_id", "name", "title", "authority", "publisher",
                    "ckan_organisatie", "licentie", "formaten", "distributies", "high_value", "source_catalog",
                    "metadata_modified", "dataset_status"])
        for code in sorted(per):
            for p in sorted(per[code], key=lambda x: x["id"]):
                w.writerow([code, by_code[code]["naam"], p["id"], p.get("name"), p.get("title"),
                            p.get("authority"), p.get("publisher"), p.get("organization"),
                            p.get("license_id"),
                            "|".join(sorted({x.rsplit("/", 1)[-1] for x in p.get("res_format") or []})),
                            len(p.get("res_url") or []),
                            p.get("high_value"), p.get("source_catalog"), p.get("metadata_modified"),
                            (p.get("dataset_status") or "").rsplit("/", 1)[-1]])

    # Gemeenteniveau
    with open(f"{prefix}.csv", "w", newline="", encoding="utf-8") as f:
        w = csv.writer(f)
        w.writerow(["peildatum", "gemeentecode", "gemeente", "provincie", "inwoners_2026_01_01",
                    "datasets_authority", "per_10000_inwoners", "datasets_publisher", "datasets_ruim",
                    "ruim_per_10000_inwoners", "zonder_formaat",
                    "open_licentie", "cc0_of_pd", "cc_by", "overige_licentie", "high_value",
                    "status_beschikbaar", "unieke_formaten", "top_formaten", "bronportalen",
                    "ckan_organisaties", "laatste_metadata_wijziging", "via_historische_naam"])
        for g in sorted(gemeenten, key=lambda x: x["naam"]):
            ds = per.get(g["code"], [])
            lic = Counter(p.get("license_id") for p in ds)
            fmt = Counter(x.rsplit("/", 1)[-1] for p in ds for x in set(p.get("res_format") or []))
            n = len(ds)
            w.writerow([
                PEILDATUM, g["code"], g["naam"], g["provincie"], g["inwoners"], n,
                f"{n / g['inwoners'] * 10000:.2f}" if g["inwoners"] else "",
                per_pub.get(g["code"], 0), len(ruim.get(g["code"], ())),
                f"{len(ruim.get(g['code'], ())) / g['inwoners'] * 10000:.2f}" if g["inwoners"] else "",
                sum(1 for p in ds if not p.get("res_format")),
                sum(c for l, c in lic.items() if l in OPEN_LICENTIES),
                sum(c for l, c in lic.items() if l and ("zero" in l or "publicdomain" in l)),
                sum(c for l, c in lic.items() if l and "/by/" in l),
                sum(c for l, c in lic.items() if l not in OPEN_LICENTIES),
                sum(1 for p in ds if p.get("high_value")),
                sum(1 for p in ds if (p.get("dataset_status") or "").endswith("beschikbaar")),
                len(fmt), "|".join(f"{k}:{v}" for k, v in fmt.most_common(5)),
                "|".join(sorted({p.get("source_catalog") or "" for p in ds} - {""})),
                "|".join(sorted({p.get("organization") or "" for p in ds} - {""})),
                max((p.get("metadata_modified") or "" for p in ds), default=""),
                hist.get(g["code"], 0),
            ])

    meta = {
        "peildatum": PEILDATUM, "datasets_totaal": len(pakketten),
        "datasets_gemeente": sum(len(v) for v in per.values()),
        "gemeenten_cbs_2026": len(gemeenten), "gemeenten_met_datasets": len(per),
        "datasets_gemeente_ruim": len(set().union(*ruim.values())) if ruim else 0,
        "gemeenten_met_datasets_ruim": len(ruim),
        "sha256_snapshot": raw_hash, "samenwerkingsverbanden": dict(samenwerking),
        "licenties_gemeente": Counter(p.get("license_id") for v in per.values() for p in v).most_common(),
    }
    Path(f"{prefix}-meta.json").write_text(json.dumps(meta, indent=2, ensure_ascii=False))
    print(json.dumps(meta, indent=2, ensure_ascii=False))


if __name__ == "__main__":
    main()
