#!/usr/bin/env python3
"""
Controleert een data-export van Appset (TRIN-934, Data Act) en laat zien hoe je hem inleest.

Gebruik:

    python controleer_export.py appset-export-<business>-<datum>.zip

Het script gebruikt alleen de standaardbibliotheek van Python 3.9 of nieuwer. Het controleert:

* dat elke tabel (<map>/<tabel>.json) geldige JSON is: een lijst van regels;
* dat het aantal regels per tabel klopt met manifest.json;
* dat elke tabel en elk veld in schema.json staat, met het juiste JSON-type;
* dat elk bestand onder bestanden/ bestaat, met de grootte en SHA-256 uit manifest.json.

Uitkomst: per controle OK of AFWIJKEND, en exitcode 0 als alles klopt, anders 1.

De opbouw van de export en de betekenis van elke tabel en elk veld staan op
https://docs.appset.nl/data-export/formaat.
"""
from __future__ import annotations

import hashlib
import json
import sys
import zipfile
from dataclasses import dataclass, field

VASTE_BESTANDEN = {"manifest.json", "schema.json", "README.md"}

JSON_TYPE = {
    str: "string",
    int: "number",
    float: "number",
    bool: "boolean",
    dict: "object",
    list: "array",
    type(None): "null",
}


@dataclass
class Uitkomst:
    tabellen: dict[str, int] = field(default_factory=dict)
    bestanden: int = 0
    afwijkingen: list[str] = field(default_factory=list)

    @property
    def in_orde(self) -> bool:
        return not self.afwijkingen


def json_type(waarde) -> str:
    # bool is in Python een subklasse van int: eerst op bool controleren.
    if isinstance(waarde, bool):
        return "boolean"
    return JSON_TYPE[type(waarde)]


def lees_tabellen(zip_bestand: zipfile.ZipFile) -> dict[str, list]:
    """Alle tabellen in de export: {"aanbod/products": [regel, ...], ...}."""
    tabellen = {}
    for naam in zip_bestand.namelist():
        if naam in VASTE_BESTANDEN or naam.startswith("bestanden/") or not naam.endswith(".json"):
            continue
        with zip_bestand.open(naam) as f:
            tabellen[naam[: -len(".json")]] = json.load(f)
    return tabellen


def controleer(pad: str) -> Uitkomst:
    uitkomst = Uitkomst()
    with zipfile.ZipFile(pad) as zip_bestand:
        namen = set(zip_bestand.namelist())
        for vast in VASTE_BESTANDEN:
            if vast not in namen:
                uitkomst.afwijkingen.append(f"{vast} ontbreekt")
        if uitkomst.afwijkingen:
            return uitkomst

        manifest = json.loads(zip_bestand.read("manifest.json"))
        schema = json.loads(zip_bestand.read("schema.json")).get("$defs", {})

        try:
            tabellen = lees_tabellen(zip_bestand)
        except json.JSONDecodeError as e:
            uitkomst.afwijkingen.append(f"Een tabel is geen geldige JSON: {e}")
            return uitkomst

        verwacht = manifest.get("tabellen", {})
        for naam in sorted(set(verwacht) | set(tabellen)):
            regels = tabellen.get(naam)
            if regels is None:
                uitkomst.afwijkingen.append(f"{naam}: staat in manifest.json, maar het bestand ontbreekt")
                continue
            if not isinstance(regels, list):
                uitkomst.afwijkingen.append(f"{naam}: geen lijst van regels")
                continue
            uitkomst.tabellen[naam] = len(regels)
            if naam not in verwacht:
                uitkomst.afwijkingen.append(f"{naam}: staat niet in manifest.json")
            elif verwacht[naam] != len(regels):
                uitkomst.afwijkingen.append(f"{naam}: {len(regels)} regels, manifest.json zegt {verwacht[naam]}")
            controleer_schema(naam, regels, schema, uitkomst)

        for bestand in manifest.get("bestanden", []):
            controleer_bestand(zip_bestand, bestand, uitkomst)

    return uitkomst


def controleer_schema(naam: str, regels: list, schema: dict, uitkomst: Uitkomst) -> None:
    if naam not in schema:
        uitkomst.afwijkingen.append(f"{naam}: staat niet in schema.json")
        return
    velden = schema[naam].get("items", {}).get("properties", {})
    for nummer, regel in enumerate(regels, start=1):
        if not isinstance(regel, dict):
            uitkomst.afwijkingen.append(f"{naam}, regel {nummer}: geen object")
            continue
        for veld, waarde in regel.items():
            if veld not in velden:
                uitkomst.afwijkingen.append(f"{naam}, regel {nummer}: veld {veld} staat niet in schema.json")
            elif json_type(waarde) not in velden[veld].get("type", []):
                uitkomst.afwijkingen.append(
                    f"{naam}, regel {nummer}: veld {veld} is {json_type(waarde)}, schema.json zegt {velden[veld].get('type')}")


def controleer_bestand(zip_bestand: zipfile.ZipFile, bestand: dict, uitkomst: Uitkomst) -> None:
    zip_pad = bestand.get("zip_pad", "")
    try:
        info = zip_bestand.getinfo(zip_pad)
    except KeyError:
        uitkomst.afwijkingen.append(f"{zip_pad}: staat in manifest.json, maar niet in de zip")
        return
    sha256 = hashlib.sha256()
    with zip_bestand.open(info) as f:
        for blok in iter(lambda: f.read(1024 * 1024), b""):
            sha256.update(blok)
    if info.file_size != bestand.get("grootte"):
        uitkomst.afwijkingen.append(f"{zip_pad}: {info.file_size} bytes, manifest.json zegt {bestand.get('grootte')}")
    if sha256.hexdigest() != bestand.get("sha256"):
        uitkomst.afwijkingen.append(f"{zip_pad}: SHA-256 klopt niet met manifest.json")
    uitkomst.bestanden += 1


def main(argumenten: list[str]) -> int:
    if len(argumenten) != 1:
        print(__doc__)
        return 2
    uitkomst = controleer(argumenten[0])
    for naam, aantal in sorted(uitkomst.tabellen.items()):
        print(f"{naam}: {aantal} regels")
    print(f"Bestanden gecontroleerd: {uitkomst.bestanden}")
    if uitkomst.in_orde:
        print("Uitkomst: OK")
        return 0
    print(f"Uitkomst: AFWIJKEND ({len(uitkomst.afwijkingen)})")
    for afwijking in uitkomst.afwijkingen:
        print(f"  - {afwijking}")
    return 1


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
