#!/usr/bin/env python3
"""
Nástroj na overenie integrity exportu z CHDÚ (FR SR Certifikácia v1.9, bod 3.B.13)
a na prevod tlačových výstupov exportu do čitateľnej podoby (v1.10, kap. 4 bod 3).

Samostatný offline nástroj (výhradne štandardná knižnica Pythonu, bez prístupu
k databáze a sieti). Vstupom je exportný ZIP archív z CHDÚ SORP
(`{kód pokladnice} {YYMMDD-hhmmss}.zip`), výstupom výsledok overenia:

1. Overenie súborov .512 — pre KAŽDÝ súbor transakcie sa prepočíta SHA-512
   kontrolný súčet a porovná s obsahom prislúchajúceho .512 súboru
   (formát: SHA512 hex malým písmom + dve medzery + názov súboru + LF).
   Kontrola je nezávislá pre každý súbor, preto sa ohlási každý zmenený súbor.
2. Overenie súborov .SYNC — prepočíta sa reťazený synchronizačný kontrolný
   kód od 128-bajtového nemenného reťazca PPPEK cez všetky súbory všetkých
   transakcií v chronologickom poradí (vrátane súborov .512 a .SYNC)
   a porovná s obsahom každého .SYNC súboru. Ako posledná transakcia sa
   overí audit.csv + audit.512 + audit.SYNC v roote archívu.
3. Kontrola úplnosti — chýbajúci dátový súbor, chýbajúci .512 alebo .SYNC,
   cudzí súbor v archíve, poškodený člen archívu (CRC).

Nástroj NEKONČÍ pri prvom náleze: prejde celý export a vypíše KAŽDÉ narušenie
s presnou lokalizáciou (názov súboru a druh narušenia). Pri prerušení reťaze
SYNC sa overovanie „znovu ukotví“ na hodnotu uloženú v .SYNC súbore, takže
každý ďalší nezávislý zlom reťaze sa ohlási samostatne a nie ako dôsledok
prvého. Na konci je súhrn (počet narušení, dotknuté súbory, počet overených
súborov). Návratový kód: 0 = integrita OK, 1 = narušenie alebo chyba.

Prevod tlačových výstupov do čitateľnej podoby a index transakcií (od verzie 1.3.0):

4. --render ADRESÁR — každý súbor .PRN a .BIN exportu sa prevedie do čitateľnej
   podoby a uloží do ADRESÁRA pod názvom `<názov súboru>.png`, `.pdf` alebo `.bin`:
   - ESC/POS rastrový prúd (tlač originálu a kópie dokladu na termotlačiareň,
     skúšobná tlač; vzniká v app/receipt/escpos_renderer.py) sa dekóduje na
     bitmapu a zapíše ako PNG (8-bitové odtiene sivej, 1 bod rastra = 1 pixel),
   - PDF dokument (tlačový výstup z app/receipt/pdf_renderer.py) sa skopíruje
     bez zmeny ako .pdf,
   - obsah iného formátu sa skopíruje bez zmeny ako .bin a v indexe sa označí
     „neznámy formát“.
   Prevod je informatívny: nemení výsledok overenia integrity ani jeho hlásenia;
   vykonáva sa aj pri narušenom exporte, aby bol obsah posúditeľný.
5. Index transakcií (ADRESÁR/index.txt pre čitateľa, ADRESÁR/index.json strojovo;
   samostatne prepínačom --index SÚBOR): pre každý transakčný adresár zoznam
   súborov s druhom obsahu. Dátové správy .XML sa rozoberú (menný priestor
   http://financnasprava.sk/ekasa/schema/v2): RegisterReceiptRequest → číslo
   a typ dokladu, DIČ, kód pokladnice, suma, IssueDate, CreateDate, SwId;
   RegisterReceiptResponse → identifikátor dokladu; RegisterLocationRequest →
   správa o polohe; SOAP Fault → odmietnutá dátová správa; .AUTH/.IDENT →
   autentifikačné/identifikačné údaje. Tlačové výstupy: .PRN v transakcii
   s dátovou správou = tlačový výstup originálu dokladu č. N (typ T); .BIN
   v transakcii s dátovou správou = kópia dokladu č. N (ostatný tlačový výstup);
   .BIN bez dátovej správy = ostatný tlačový výstup. Ak audit.csv obsahuje
   riadok s `txn=<číslo transakcie>` (formát auditu od verzie SORP 1.0.6, napr.
   RECEIPT_COPY_ISSUED doklad=14 typ=PD format=pdf txn=31 subor=4 ext=BIN
   bytes=...), cituje sa pri súbore (podľa `subor=`), resp. pri transakcii.
   Pri každom .PRN/.BIN je uvedený zistený formát (PDF; ESC/POS raster so
   šírkou v bodoch a výškou v riadkoch; neznámy formát).

Použitie:
    chdu_export_verify <export.zip>
    chdu_export_verify --json <export.zip>              (strojovo čitateľný výstup)
    chdu_export_verify --render ADRESÁR <export.zip>    (prevod tlačových výstupov + index)
    chdu_export_verify --index SÚBOR <export.zip>       (len index transakcií, bez prevodu)
    chdu_export_verify --version
    python3 chdu_export_verify.py <export.zip>

Algoritmus reťazenia (zhodný s app/chdu/export_format.py):
    state = SHA512(seed).hexdigest()
    pre každý súbor F v poradí (číslo transakcie, poradie súboru):
        state = SHA512(state + SHA512(F).hexdigest())
        state = SHA512(state + SHA512(F.512).hexdigest())
        F.SYNC musí obsahovať aktuálny state
        state = SHA512(state + SHA512(F.SYNC).hexdigest())
    audit.csv, audit.512, audit.SYNC = posledná transakcia

Dekódovanie ESC/POS rastra (dokumentácia prevodu; zhodné s _image_to_escpos
v app/receipt/escpos_renderer.py):
    prúd začína ESC @ (0x1B 0x40, inicializácia tlačiarne);
    nasledujú pásy GS v 0 m xL xH yL yH + dáta (0x1D 0x76 0x30 m xL xH yL yH):
        šírka pásu = xL + 256*xH bajtov (384 bodov = 48 bajtov, 576 bodov = 72 bajtov),
        výška pásu = yL + 256*yH riadkov, dáta = šírka*výška bajtov po riadkoch zhora nadol,
        v každom bajte je najvyšší bit (MSB) bod najviac vľavo, bit 1 = čierny bod,
        m: bit 0 = dvojnásobná šírka, bit 1 = dvojnásobná výška (renderer SORP používa m = 0);
    pásy sa skladajú pod seba v poradí v prúde (výška obrázka = súčet výšok pásov,
    šírka = najväčšia šírka pásu * 8 bodov; užšie pásy sa doplnia bielou vpravo);
    ostatné príkazy ESC/POS medzi pásmi (ESC a, ESC E, GS V, posuny papiera LF,
    ESC d, ESC J a ďalšie) sa preskočia podľa dĺžky príkazu, bez vplyvu na obraz;
    prúd končí posunom papiera (LF LF LF LF).
    PNG: 8-bitové odtiene sivej (0 = čierna, 255 = biela), bez filtra riadkov,
    IDAT komprimovaný zlib, kontrolné súčty CRC-32 (moduly zlib a struct);
    1 pixel = 1 bod rastra (203 dpi: 384 bodov = 48 mm, 576 bodov = 72 mm).
"""

from __future__ import annotations

import hashlib
import json
import os
import re
import struct
import sys
import zipfile
import zlib
from xml.etree import ElementTree

TOOL_VERSION = "1.3.0"

# 128-bajtový nemenný reťazec PPPEK (obfuskovaný XOR poliami) — totožný
# s app/chdu/sync_seed.py; schvaľuje sa počas konania o certifikácii.
_MASK = bytes.fromhex(
    "49dca14fc34abd2f566e13864c6522a9718e303d4cbcb9366905db249aceedfa"
    "2b108694d6b5fbf507bdc9539bd94adf2d5810ff128cfd230bbde780daf92cbf"
    "75f5a64f01a8aca65f4ace02040f9e846a6780ac6fd0cdb393a07a00ef7b40a2"
    "4382c1936f7c5d02ecd058db5bc56df59d78d7e13a7ff12616574618f3ab1635"
)
_XORED = bytes.fromhex(
    "ee2b863534640ddde75a6aab19f19906c280eb7566c2266c67ff8618a5bc9d92"
    "68e8b15d185ce7c87a8c1da3b7abd11ae417ce6a548b1221210665f8731a67d9"
    "5b0f3edb53a3da4de3c86a954200fc6eb8cd4b72bc2418986070d16d3a30a111"
    "0b6c796cedd4449d36fb5d46adce5fd176b6f8f48a295fcd64abf9e20eec85f6"
)

# YY/MM/YYMMDD-IIII-IIII-IIII/YYMMDD-IIII-IIII-IIII-XXXX.EXT
_FILE_RE = re.compile(
    r"^(?P<yy>\d{2})/(?P<mm>\d{2})/"
    r"(?P<dir>\d{6}-\d{4}-\d{4}-\d{4})/"
    r"(?P=dir)-(?P<fileseq>\d{4})\.(?P<ext>JSON|XML|PRN|BIN|AUTH|IDENT|512|SYNC)$"
)
_HEX128_RE = re.compile(r"^[0-9a-f]{128}$")
_ROOT_FILES = ("audit.csv", "audit.512", "audit.SYNC")

# Maximálny počet súbežne sledovaných kandidátov stavu reťaze po zlome
# (pôvodný prepočet + znovuukotvenie na uloženú hodnotu + očakávaný .SYNC).
# Bráni kaskáde falošných hlásení po jednom zásahu a drží výpočet lineárny.
_MAX_CANDIDATES = 6

# Druhy narušení (pole `kind` v štruktúrovanom výstupe)
KIND_CORRUPT = "poskodeny_clen_archivu"
KIND_UNEXPECTED = "cudzi_subor"
KIND_MISSING_DATA = "chyba_datovy_subor"
KIND_MISSING_512 = "chyba_512"
KIND_MISSING_SYNC = "chyba_sync"
KIND_HASH = "kontrolny_sucet_nesuhlasi"
KIND_SYNC = "zlom_retaze_sync"
KIND_MISSING_AUDIT = "chyba_audit"


def _seed() -> bytes:
    return bytes(a ^ b for a, b in zip(_MASK, _XORED))


def _sha512(data: bytes) -> str:
    return hashlib.sha512(data).hexdigest()


def _chain(state: str, file_hash: str) -> str:
    return hashlib.sha512((state + file_hash).encode("ascii")).hexdigest()


def _dedupe(states: list[str]) -> list[str]:
    seen: set[str] = set()
    out: list[str] = []
    for s in states:
        if s not in seen:
            seen.add(s)
            out.append(s)
    return out[:_MAX_CANDIDATES]


def _read_zip_members(zf: zipfile.ZipFile) -> tuple[dict[str, bytes], list[tuple[str, str]]]:
    """
    Prečítaj všetky súbory archívu po jednom. Bajt zmenený priamo v archíve
    (hexeditorom) sa prejaví chybou CRC alebo dekompresie pri čítaní člena;
    taký člen sa vráti osobitne ako poškodený a ďalej sa berie ako chýbajúci.
    """
    contents: dict[str, bytes] = {}
    damaged: list[tuple[str, str]] = []
    for info in zf.infolist():
        if info.is_dir():
            continue
        try:
            contents[info.filename] = zf.read(info.filename)
        except (zipfile.BadZipFile, zlib.error, OSError, RuntimeError) as e:
            damaged.append((info.filename, str(e)))
    return contents, damaged


class _Report:
    """Zbiera narušenia a hlásenia; nikdy neprerušuje overovanie."""

    def __init__(self) -> None:
        self.violations: list[dict] = []
        self.messages: list[str] = []
        self.checked_files = 0
        self.transactions: set[int] = set()

    def add(self, kind: str, path: str, message: str) -> None:
        self.violations.append({"kind": kind, "path": path, "message": message})
        self.messages.append(f"NARUŠENIE: {message}")

    @property
    def ok(self) -> bool:
        return not self.violations


def _verify_sync(
    report: _Report,
    states: list[str],
    sync_bytes: bytes,
    sync_path: str,
    local_violation: bool,
    subject: str,
) -> list[str]:
    """
    Porovnaj uložený .SYNC s kandidátmi stavu reťaze a vráť kandidátov po tomto
    súbore. Pri nezhode zaznamená narušenie a reťaz znovu ukotví na uloženú
    hodnotu, aby sa ďalšie zlomy lokalizovali samostatne.
    """
    for st in states:
        if sync_bytes == (st + "\n").encode("ascii"):
            return [_chain(st, _sha512(sync_bytes))]

    stored = sync_bytes.decode("ascii", "replace").rstrip("\n")
    if local_violation:
        detail = (
            f"synchronizačný kontrolný kód nesúhlasí pre {subject} (súbor {sync_path}); "
            "dôsledok zmeny obsahu alebo kontrolného súčtu tohto súboru"
        )
    elif not _HEX128_RE.match(stored):
        detail = f"súbor {sync_path} nemá tvar synchronizačného kontrolného kódu (128 hex znakov)"
    else:
        detail = (
            f"synchronizačný kontrolný kód nesúhlasí — reťaz integrity je prerušená pri súbore "
            f"{sync_path} (zmenený .SYNC, alebo obsah a .512 súboru {subject} zmenené konzistentne)"
        )
    report.add(KIND_SYNC, sync_path, detail)

    # Znovuukotvenie: pokračuj (a) od uloženej hodnoty, (b) od pôvodných kandidátov
    # so skutočným obsahom .SYNC, (c) od pôvodných kandidátov s očakávaným obsahom
    # .SYNC (prípad: zmenený bol len tento .SYNC súbor, zvyšok exportu je pôvodný),
    # aby sa neporušený zvyšok reťaze nehlásil ako kaskáda falošných zlomov.
    nxt: list[str] = []
    if _HEX128_RE.match(stored):
        nxt.append(_chain(stored, _sha512(sync_bytes)))
    nxt.extend(_chain(st, _sha512(sync_bytes)) for st in states)
    nxt.extend(_chain(st, _sha512((st + "\n").encode("ascii"))) for st in states)
    return _dedupe(nxt)


def verify_export_report(zip_path: str) -> dict:
    """
    Over integritu exportného ZIP a vráť štruktúrovanú správu:
    {ok, violations[], messages[], checked_files, transactions, summary}.
    """
    report = _Report()

    try:
        zf = zipfile.ZipFile(zip_path)
    except (OSError, zipfile.BadZipFile) as e:
        msg = f"CHYBA: archív sa nedá otvoriť: {e}"
        return {
            "ok": False, "violations": [{"kind": "chyba_archivu", "path": zip_path, "message": msg}],
            "messages": [msg], "checked_files": 0, "transactions": 0,
            "summary": "archív sa nedá otvoriť", "tool_version": TOOL_VERSION,
        }

    # Čítanie po súboroch — poškodený člen sa ohlási a ďalej sa berie
    # ako chýbajúci, overovanie pokračuje.
    with zf:
        contents, damaged = _read_zip_members(zf)
    for path, err in damaged:
        report.add(KIND_CORRUPT, path, f"poškodený obsah súboru {path} v archíve ({err})")

    # Rozparsuj transakčné súbory: {(txn_seq, file_seq): {data/512/SYNC: (cesta, obsah)}}
    entries: dict[tuple[int, int], dict[str, tuple[str, bytes]]] = {}
    root_files: dict[str, bytes] = {}

    for path, data in contents.items():
        if path in _ROOT_FILES:
            root_files[path] = data
            continue
        m = _FILE_RE.match(path)
        if not m:
            report.add(KIND_UNEXPECTED, path, f"neočakávaný súbor v archíve: {path}")
            continue
        txn_seq = int(m.group("dir")[7:].replace("-", ""))
        file_seq = int(m.group("fileseq"))
        ext = m.group("ext")
        slot = entries.setdefault((txn_seq, file_seq), {})
        key = ext if ext in ("512", "SYNC") else "data"
        slot[key] = (path, data)

    for required in _ROOT_FILES:
        if required not in root_files:
            report.add(KIND_MISSING_AUDIT, required, f"v roote archívu chýba {required}")

    states: list[str] = [_sha512(_seed())]

    for (txn_seq, file_seq) in sorted(entries):
        slot = entries[(txn_seq, file_seq)]
        report.transactions.add(txn_seq)
        local_violation = False

        # Názov dátového súboru: z dátového súboru, inak odvodený z .512/.SYNC
        if "data" in slot:
            name = slot["data"][0].rsplit("/", 1)[-1]
        else:
            any_path = (slot.get("512") or slot.get("SYNC"))[0]
            name = any_path.rsplit("/", 1)[-1].rsplit(".", 1)[0] + ".?"
            report.add(KIND_MISSING_DATA, any_path,
                       f"chýba dátový súbor transakcie {txn_seq:012d}, poradie {file_seq:04d} "
                       f"(k súboru {any_path})")
            local_violation = True

        # 1) .512 — kontrolný súčet obsahu (nezávisle pre každý súbor)
        if "data" in slot:
            content_hash = _sha512(slot["data"][1])
            expected_512 = f"{content_hash}  {name}\n".encode("ascii")
        else:
            content_hash = None
            expected_512 = None

        if "512" not in slot:
            report.add(KIND_MISSING_512, slot["data"][0] if "data" in slot else name,
                       f"chýba súbor .512 pre {name}")
            local_violation = True
            actual_512 = None
        else:
            actual_512 = slot["512"][1]
            if expected_512 is not None and actual_512 != expected_512:
                report.add(KIND_HASH, slot["data"][0],
                           f"kontrolný súčet nesúhlasí pre {name} (súbor {slot['512'][0]})")
                local_violation = True

        # Posun reťaze: podľa obsahu (ak je), potom podľa skutočného .512 (ak je)
        if content_hash is not None:
            states = [_chain(st, content_hash) for st in states]
        elif actual_512 is not None:
            # dátový súbor chýba: skús pokračovať s hashom uloženým v .512
            stored_hash = actual_512.split(b"  ", 1)[0].decode("ascii", "replace")
            if _HEX128_RE.match(stored_hash):
                states = [_chain(st, stored_hash) for st in states]
        if actual_512 is not None:
            states = [_chain(st, _sha512(actual_512)) for st in states]
        elif expected_512 is not None:
            states = [_chain(st, _sha512(expected_512)) for st in states]

        # 2) .SYNC — synchronizačný kontrolný kód
        if "SYNC" not in slot:
            report.add(KIND_MISSING_SYNC, slot["data"][0] if "data" in slot else name,
                       f"chýba súbor .SYNC pre {name}")
            # reťaz pokračuje z očakávaných stavov (SYNC by obsahoval práve tie)
            states = _dedupe([_chain(st, _sha512((st + "\n").encode("ascii"))) for st in states])
        else:
            states = _verify_sync(report, states, slot["SYNC"][1], slot["SYNC"][0],
                                  local_violation, name)

        if "data" in slot and "512" in slot and "SYNC" in slot:
            report.checked_files += 1

    # audit.csv ako posledná transakcia
    if "audit.csv" in root_files:
        audit_csv = root_files["audit.csv"]
        audit_hash = _sha512(audit_csv)
        states = [_chain(st, audit_hash) for st in states]
        expected_audit_512 = f"{audit_hash}  audit.csv\n".encode("ascii")
        local_violation = False
        if "audit.512" in root_files:
            if root_files["audit.512"] != expected_audit_512:
                report.add(KIND_HASH, "audit.csv",
                           "kontrolný súčet nesúhlasí pre audit.csv (audit.512)")
                local_violation = True
            states = [_chain(st, _sha512(root_files["audit.512"])) for st in states]
        else:
            states = [_chain(st, _sha512(expected_audit_512)) for st in states]
        if "audit.SYNC" in root_files:
            _verify_sync(report, states, root_files["audit.SYNC"], "audit.SYNC",
                         local_violation, "audit.csv")

    n_viol = len(report.violations)
    affected = sorted({v["path"] for v in report.violations})
    if report.ok:
        summary = (f"overených {report.checked_files} súborov transakcií + audit.csv — "
                   f"integrita neporušená")
        report.messages.append("OK: " + summary)
    else:
        summary = (f"{n_viol} narušení v {len(affected)} súboroch; overených "
                   f"{report.checked_files} súborov transakcií ({len(report.transactions)} transakcií)"
                   f" + audit.csv")
        report.messages.append("SÚHRN: " + summary)

    return {
        "ok": report.ok,
        "violations": report.violations,
        "messages": report.messages,
        "checked_files": report.checked_files,
        "transactions": len(report.transactions),
        "affected_files": affected,
        "summary": summary,
        "tool_version": TOOL_VERSION,
    }


def verify_export(zip_path: str) -> tuple[bool, list[str]]:
    """Over integritu exportného ZIP. Vracia (ok, zoznam hlásení) — každé narušenie samostatne."""
    rep = verify_export_report(zip_path)
    return rep["ok"], rep["messages"]


# --- Prevod tlačových výstupov do čitateľnej podoby (od 1.3.0) --------------------------

FORMAT_PDF = "pdf"
FORMAT_ESCPOS_RASTER = "escpos_raster"
FORMAT_UNKNOWN = "neznamy"
FORMAT_CSV_TEXT = "csv_text"

_ESC, _GS, _FS, _DLE = 0x1B, 0x1D, 0x1C, 0x10
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"

# Počet bajtov argumentov príkazov ESC/POS za dvojbajtovou predponou. Renderer
# SORP vkladá do prúdu len ESC @ a posuny papiera; tabuľky pokrývajú bežné
# príkazy formátovania, rezu a posunu, aby sa raster dekódoval aj z prúdu
# doplneného tlačovým agentom alebo ovládačom tlačiarne.
_ESC_ARGS = {
    0x40: 0, 0x21: 1, 0x2D: 1, 0x32: 0, 0x33: 1, 0x3D: 1, 0x45: 1, 0x47: 1,
    0x4A: 1, 0x4D: 1, 0x52: 1, 0x56: 1, 0x61: 1, 0x63: 2, 0x64: 1, 0x69: 0,
    0x6D: 0, 0x70: 3, 0x74: 1, 0x7B: 1, 0x20: 1, 0x24: 2, 0x5C: 2, 0x4C: 0,
    0x53: 0, 0x54: 1, 0x57: 8, 0x42: 2, 0x43: 1,
}
_GS_ARGS = {
    0x21: 1, 0x42: 1, 0x45: 1, 0x48: 1, 0x49: 1, 0x4C: 2, 0x50: 2, 0x54: 1,
    0x57: 2, 0x61: 1, 0x62: 1, 0x66: 1, 0x68: 1, 0x72: 1, 0x77: 1, 0x3A: 0, 0x5E: 3,
}
_FS_ARGS = {0x2E: 0, 0x26: 0, 0x2D: 1, 0x70: 2, 0x21: 1, 0x43: 1, 0x53: 2, 0x54: 2, 0x57: 1}
_DLE_ARGS = {0x04: 1, 0x05: 1, 0x14: 3}
_CONTROL_BYTES = (0x00, 0x09, 0x0A, 0x0C, 0x0D, 0x18)

# Bajt rastra → 8 pixelov v odtieňoch sivej (bit 1 = čierna = 0, bit 0 = biela = 255)
_GRAY_ROWS = [bytes(0 if (b >> (7 - k)) & 1 else 255 for k in range(8)) for b in range(256)]


def _double_bits_table() -> list[bytes]:
    table: list[bytes] = []
    for b in range(256):
        v = 0
        for bit in range(8):
            if b & (0x80 >> bit):
                v |= 0b11 << (14 - 2 * bit)
        table.append(v.to_bytes(2, "big"))
    return table


_DOUBLE_BITS = _double_bits_table()


def _pl(n: int, one: str, few: str, many: str) -> str:
    """Slovenské počítané podstatné meno: 1 súbor, 2 až 4 súbory, 5 a viac súborov."""
    form = one if n == 1 else few if 2 <= n <= 4 else many
    return f"{n} {form}"


class EscPosRaster:
    """Bitmapa dekódovaná z ESC/POS prúdu (pásy GS v 0 poskladané pod seba)."""

    def __init__(self) -> None:
        self.width_bytes = 0
        self.rows: list[bytes] = []
        self.bands: list[tuple[int, int]] = []  # (šírka v bajtoch, výška v riadkoch) každého pásu
        self.skipped_bytes = 0  # bajty mimo rozpoznaných príkazov (text, neznáme príkazy)
        self.truncated = False  # posledný pás presahuje koniec prúdu

    @property
    def width_dots(self) -> int:
        return self.width_bytes * 8

    @property
    def height_rows(self) -> int:
        return len(self.rows)


def decode_escpos_raster(data: bytes) -> EscPosRaster:
    """
    Dekóduj pásy GS v 0 z ESC/POS prúdu na riadky bitovej mapy (bit 1 = čierna,
    MSB = ľavý bod). Ostatné príkazy ESC/POS sa preskočia podľa dĺžky, text
    a neznáme bajty sa spočítajú do `skipped_bytes`. Postup je opísaný
    v dokumentácii modulu (Dekódovanie ESC/POS rastra).
    """
    out = EscPosRaster()
    n = len(data)

    def at(idx: int) -> int:
        return data[idx] if idx < n else 0

    i = 0
    while i < n:
        b = data[i]
        if b == _GS and at(i + 1) == 0x76 and at(i + 2) == 0x30:
            if i + 8 > n:
                out.truncated = True
                break
            m, xl, xh, yl, yh = data[i + 3:i + 8]
            width, height = xl + 256 * xh, yl + 256 * yh
            start = i + 8
            avail_rows = min(height, (n - start) // width) if width else 0
            if avail_rows < height:
                out.truncated = True
            rows = [data[start + r * width:start + (r + 1) * width] for r in range(avail_rows)]
            band_width = width
            if m & 1:  # dvojnásobná šírka: každý bod dvakrát
                rows = [b"".join(_DOUBLE_BITS[x] for x in row) for row in rows]
                band_width = width * 2
            if m & 2:  # dvojnásobná výška: každý riadok dvakrát
                rows = [row for row in rows for _ in (0, 1)]
            out.bands.append((band_width, len(rows)))
            out.rows.extend(rows)
            out.width_bytes = max(out.width_bytes, band_width)
            i = min(n, start + width * height)
            continue
        if b == _GS:
            c = at(i + 1)
            if c == 0x28:  # GS ( fn pL pH + dáta (QR, GS ( L raster a i.) — preskočiť
                i += 5 + at(i + 3) + 256 * at(i + 4)
            elif c == 0x38 and at(i + 2) == 0x4C:  # GS 8 L p1 p2 p3 p4 + dáta
                i += 7 + (at(i + 3) | at(i + 4) << 8 | at(i + 5) << 16 | at(i + 6) << 24)
            elif c == 0x56:  # GS V m [n] — rez papiera
                i += 3 + (1 if at(i + 2) in (65, 66, 97, 98, 103, 104) else 0)
            elif c == 0x6B:  # GS k — čiarový kód (ukončený NUL alebo s dĺžkou)
                if at(i + 2) <= 6:
                    end = data.find(b"\x00", i + 3)
                    i = n if end < 0 else end + 1
                else:
                    i += 4 + at(i + 3)
            elif c == 0x2A:  # GS * x y + x*y*8 bajtov — definícia bitového obrazu
                i += 4 + at(i + 2) * at(i + 3) * 8
            elif c in _GS_ARGS:
                i += 2 + _GS_ARGS[c]
            else:
                out.skipped_bytes += 1
                i += 1
            continue
        if b == _ESC:
            c = at(i + 1)
            if c == 0x2A:  # ESC * m nL nH + dáta — bitový obraz po stĺpcoch
                cols = at(i + 3) + 256 * at(i + 4)
                i += 5 + cols * (3 if at(i + 2) >= 32 else 1)
            elif c == 0x44:  # ESC D tabulátory ... NUL
                end = data.find(b"\x00", i + 2)
                i = n if end < 0 else end + 1
            elif c in _ESC_ARGS:
                i += 2 + _ESC_ARGS[c]
            else:
                out.skipped_bytes += 1
                i += 1
            continue
        if b == _FS:
            c = at(i + 1)
            if c in _FS_ARGS:
                i += 2 + _FS_ARGS[c]
            else:
                out.skipped_bytes += 1
                i += 1
            continue
        if b == _DLE:
            c = at(i + 1)
            if c in _DLE_ARGS:
                i += 2 + _DLE_ARGS[c]
            else:
                out.skipped_bytes += 1
                i += 1
            continue
        if b not in _CONTROL_BYTES:
            out.skipped_bytes += 1
        i += 1

    if out.width_bytes:
        out.rows = [row if len(row) == out.width_bytes else row.ljust(out.width_bytes, b"\x00")
                    for row in out.rows]
    return out


def _png_chunk(kind: bytes, payload: bytes) -> bytes:
    crc = zlib.crc32(kind + payload) & 0xFFFFFFFF
    return struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", crc)


def encode_png_gray8(width: int, height: int, rows: list[bytes]) -> bytes:
    """PNG v 8-bitových odtieňoch sivej z riadkov bitovej mapy (bit 1 = čierna, MSB vľavo)."""
    raw = bytearray()
    for row in rows:
        raw.append(0)  # typ filtra riadku 0 (bez filtra)
        raw += b"".join(_GRAY_ROWS[b] for b in row)[:width]
    ihdr = struct.pack(">IIBBBBB", width, height, 8, 0, 0, 0, 0)
    return (
        _PNG_SIGNATURE
        + _png_chunk(b"IHDR", ihdr)
        + _png_chunk(b"IDAT", zlib.compress(bytes(raw), 9))
        + _png_chunk(b"IEND", b"")
    )


def raster_to_png(raster: EscPosRaster) -> bytes:
    return encode_png_gray8(raster.width_dots, raster.height_rows, raster.rows)


def _csv_text(data: bytes) -> str | None:
    """Uzávierka a zoznam dokladov sú textové CSV (UTF-8, prípadne s BOM, oddeľovač `;`),
    bez riadiacich bajtov okrem TAB/CR/LF; taký výstup je čitateľný sám o sebe."""
    if not data:
        return None
    try:
        text = data.decode("utf-8-sig")
    except UnicodeDecodeError:
        return None
    if any(ord(ch) < 32 and ch not in "\t\r\n" for ch in text):
        return None
    return text if ";" in text else None


def inspect_print_output(data: bytes) -> dict:
    """
    Zisti formát tlačového výstupu (.PRN/.BIN). Vracia {format, ext, description,
    public, raster}; `public` je strojovo čitateľný opis formátu pre index,
    `raster` dekódovaný ESC/POS raster (len pri FORMAT_ESCPOS_RASTER).
    """
    if data[:4] == b"%PDF":
        header = data[:16].split(b"\n", 1)[0].split(b"\r", 1)[0].decode("ascii", "replace").strip()
        version = header[1:] if header.startswith("%") else "PDF"
        return {
            "format": FORMAT_PDF, "ext": "pdf", "raster": None,
            "description": f"PDF dokument ({version}), {len(data)} B; skopírovaný bez zmeny ako .pdf",
            "public": {"format": FORMAT_PDF, "version": version, "source_bytes": len(data)},
        }
    raster = decode_escpos_raster(data)
    if raster.bands and raster.height_rows:
        desc = (
            f"ESC/POS raster (GS v 0), šírka {raster.width_dots} bodov ({raster.width_bytes} bajtov na riadok), "
            f"výška {_pl(raster.height_rows, 'riadok', 'riadky', 'riadkov')}, "
            f"{_pl(len(raster.bands), 'pás', 'pásy', 'pásov')}, {len(data)} B; vykresliteľný do PNG"
        )
        if raster.truncated:
            desc += "; prúd je skrátený (posledný pás presahuje koniec súboru)"
        return {
            "format": FORMAT_ESCPOS_RASTER, "ext": "png", "raster": raster, "description": desc,
            "public": {
                "format": FORMAT_ESCPOS_RASTER, "width_dots": raster.width_dots,
                "width_bytes": raster.width_bytes, "height_rows": raster.height_rows,
                "bands": len(raster.bands), "truncated": raster.truncated, "source_bytes": len(data),
            },
        }
    text = _csv_text(data)
    if text is not None:
        rows = text.count("\n") + (0 if text.endswith("\n") else 1)
        return {
            "format": FORMAT_CSV_TEXT, "ext": "csv", "raster": None,
            "description": (
                f"textový dokument CSV (uzávierka alebo zoznam dokladov), {_pl(rows, 'riadok', 'riadky', 'riadkov')}, "
                f"{len(data)} B; skopírovaný bez zmeny ako .csv"
            ),
            "public": {"format": FORMAT_CSV_TEXT, "rows": rows, "source_bytes": len(data)},
        }
    return {
        "format": FORMAT_UNKNOWN, "ext": "bin", "raster": None,
        "description": (
            f"neznámy formát (nie PDF, ESC/POS raster ani textový CSV), {len(data)} B; skopírovaný bez zmeny ako .bin"
        ),
        "public": {"format": FORMAT_UNKNOWN, "source_bytes": len(data), "note": "neznámy formát"},
    }


def render_print_output(data: bytes, inspection: dict | None = None) -> tuple[str, bytes, dict]:
    """Čitateľná podoba tlačového výstupu: (prípona, obsah, inspection)."""
    info = inspection or inspect_print_output(data)
    if info["format"] == FORMAT_ESCPOS_RASTER:
        return "png", raster_to_png(info["raster"]), info
    return info["ext"], data, info


# --- Index transakcií (od 1.3.0) ---------------------------------------------------------

_RECEIPT_TYPE_NAMES = {
    "PD": "pokladničný doklad", "UF": "úhrada faktúry", "ND": "neplatný doklad",
    "VK": "vklad hotovosti", "VY": "výber hotovosti",
}
_AUDIT_ACTION_NAMES = {
    "RECEIPT_ORIGINAL_STORED": "originál dokladu",
    "RECEIPT_COPY_ISSUED": "kópia dokladu",
    "TEST_PRINT_ISSUED": "skúšobná tlač",
    "CLOSING_STORED": "uzávierka",
}
_REQUEST_FIELDS = (
    "ReceiptNumber", "ReceiptType", "Dic", "CashRegisterCode", "Amount", "IssueDate", "CreateDate",
    "Paragon", "ParagonNumber", "InvoiceNumber", "IcDph", "Ico", "CustomerId",
)


def _local(tag: object) -> str:
    return tag.rsplit("}", 1)[-1] if isinstance(tag, str) else ""


def _find_local(root: ElementTree.Element | None, name: str) -> ElementTree.Element | None:
    """Prvý prvok s daným lokálnym názvom bez ohľadu na menný priestor (aj v SOAP obálke)."""
    if root is None:
        return None
    for el in root.iter():
        if _local(el.tag) == name:
            return el
    return None


def _value(el: ElementTree.Element | None, name: str) -> str | None:
    """Hodnota atribútu `name` (tvar podľa XSD v2), inak text podprvku s týmto lokálnym názvom."""
    if el is None:
        return None
    v = el.get(name)
    if v is not None:
        return v.strip()
    for child in el:
        if _local(child.tag) == name and child.text:
            return child.text.strip()
    return None


def _parse_xml(data: bytes) -> tuple[ElementTree.Element | None, str | None]:
    if b"<!DOCTYPE" in data or b"<!ENTITY" in data:
        return None, "XML obsahuje DTD alebo entity, z bezpečnostných dôvodov sa nerozoberá"
    try:
        return ElementTree.fromstring(data), None
    except ElementTree.ParseError as e:
        return None, f"XML sa nedá rozobrať: {e}"


def _receipt_phrase(number: str | None, rtype: str | None, genitive: bool = False) -> str:
    noun = "dokladu" if genitive else "doklad"
    s = f"{noun} č. {number}" if number else f"{noun} (číslo neuvedené)"
    if rtype:
        name = _RECEIPT_TYPE_NAMES.get(rtype)
        s += f" (typ {rtype}, {name})" if name else f" (typ {rtype})"
    return s


def describe_xml(data: bytes) -> dict:
    """Druh a opis dátovej správy .XML: {kind, description, details}."""
    root, err = _parse_xml(data)
    if root is None:
        return {"kind": "datova_sprava_nerozpoznana", "description": f"dátová správa ({err})", "details": {}}

    req = _find_local(root, "RegisterReceiptRequest")
    if req is not None:
        header = _find_local(req, "Header")
        rd = _find_local(req, "ReceiptData")
        d = {k: _value(rd, k) for k in _REQUEST_FIELDS}
        for k in ("SwId", "Uuid", "RequestDate", "SendingCount", "Exception"):
            d[k] = _value(header, k)
        items = _find_local(req, "Items")
        d["Items"] = sum(1 for e in items if _local(e.tag) == "Item") if items is not None else 0
        details = {k: v for k, v in d.items() if v is not None}
        parts = [_receipt_phrase(d["ReceiptNumber"], d["ReceiptType"])]
        for label, key in (("suma", "Amount"), ("DIČ", "Dic"), ("kód pokladnice", "CashRegisterCode"),
                           ("IssueDate", "IssueDate"), ("CreateDate", "CreateDate"), ("SwId", "SwId")):
            if d.get(key):
                parts.append(f"{label} {d[key]}")
        if d.get("Paragon") == "true":
            parts.append(f"paragón č. {d.get('ParagonNumber') or '?'}")
        if d.get("InvoiceNumber"):
            parts.append(f"faktúra {d['InvoiceNumber']}")
        if d.get("Exception") == "true":
            parts.append("doevidovanie (Exception=true)")
        parts.append(_pl(d["Items"], "položka", "položky", "položiek"))
        return {
            "kind": "datova_sprava_ziadost",
            "description": "dátová správa: žiadosť o registráciu dokladu (RegisterReceiptRequest): " + ", ".join(parts),
            "details": details,
        }

    resp = _find_local(root, "RegisterReceiptResponse")
    if resp is not None:
        header = _find_local(resp, "Header")
        rd = _find_local(resp, "ReceiptData")
        doc_id = _value(rd, "Id") or _value(resp, "ReceiptId")
        warning = _find_local(resp, "Warning")
        d = {
            "Id": doc_id, "ProcessDate": _value(header, "ProcessDate"), "Uuid": _value(header, "Uuid"),
            "RequestUuid": _value(header, "RequestUuid"), "ReceiptNumber": _value(resp, "ReceiptNumber"),
            "WarningCode": warning.get("Code") if warning is not None else None,
        }
        details = {k: v for k, v in d.items() if v is not None}
        s = "odpoveď servera e-kasa (RegisterReceiptResponse): "
        s += f"identifikátor dokladu {doc_id}" if doc_id else "bez identifikátora dokladu"
        if d["ReceiptNumber"]:
            s += f", doklad č. {d['ReceiptNumber']}"
        if d["ProcessDate"]:
            s += f", ProcessDate {d['ProcessDate']}"
        if d["WarningCode"]:
            s += f", varovanie kód {d['WarningCode']}"
        return {"kind": "datova_sprava_odpoved", "description": s, "details": details}

    loc = _find_local(root, "RegisterLocationRequest")
    if loc is not None:
        header = _find_local(loc, "Header")
        ld = _find_local(loc, "LocationData")
        d = {
            "Dic": _value(ld, "Dic"), "CashRegisterCode": _value(ld, "CashRegisterCode"),
            "CreateDate": _value(ld, "CreateDate"), "SwId": _value(header, "SwId"),
        }
        addr = _find_local(loc, "PhysicalAddress")
        gps = _find_local(loc, "Gps")
        other = _find_local(loc, "Other")
        if addr is not None:
            d["Location"] = " ".join(v for v in (addr.get("StreetName"), addr.get("BuildingNumber"),
                                                 addr.get("Municipality"), addr.get("PostalCode")) if v)
        elif gps is not None:
            d["Location"] = f"GPS {gps.get('AxisX')}, {gps.get('AxisY')}"
        elif other is not None and other.text:
            d["Location"] = other.text.strip()
        details = {k: v for k, v in d.items() if v is not None}
        parts = [f"{label} {d[key]}" for label, key in (
            ("DIČ", "Dic"), ("kód pokladnice", "CashRegisterCode"), ("CreateDate", "CreateDate"),
            ("poloha", "Location"), ("SwId", "SwId")) if d.get(key)]
        return {
            "kind": "sprava_o_polohe",
            "description": "správa o polohe (RegisterLocationRequest)" + (": " + ", ".join(parts) if parts else ""),
            "details": details,
        }

    if _find_local(root, "RegisterLocationResponse") is not None:
        header = _find_local(root, "Header")
        details = {k: v for k, v in {"ProcessDate": _value(header, "ProcessDate")}.items() if v}
        return {
            "kind": "sprava_o_polohe_odpoved",
            "description": "odpoveď servera e-kasa na správu o polohe (RegisterLocationResponse)",
            "details": details,
        }

    fault = _find_local(root, "Fault")
    if fault is not None:
        code = next((v for k, v in fault.attrib.items() if _local(k).lower() == "ekasaerrorcode"), None)
        text_el = _find_local(fault, "Text")
        text = (text_el.text or "").strip() if text_el is not None else ""
        s = "odpoveď servera e-kasa: dátová správa odmietnutá (SOAP Fault"
        if code:
            s += f", kód {code}"
        if text:
            s += f", {text}"
        details = {k: v for k, v in {"EKasaErrorCode": code, "Text": text}.items() if v}
        return {"kind": "datova_sprava_chyba", "description": s + ")", "details": details}

    return {
        "kind": "datova_sprava_ina",
        "description": f"dátová správa (koreňový prvok {_local(root.tag)})",
        "details": {"root": _local(root.tag)},
    }


def describe_auth(data: bytes) -> dict:
    root, _ = _parse_xml(data)
    store = _value(_find_local(root, "AuthData"), "KeyStoreType") if root is not None else None
    s = "autentifikačné údaje (autentifikačný balíček"
    s += f", úložisko {store})" if store else ")"
    return {"kind": "autentifikacne_udaje", "description": f"{s}, {len(data)} B",
            "details": {"KeyStoreType": store} if store else {}}


def describe_ident(data: bytes) -> dict:
    root, _ = _parse_xml(data)
    d: dict[str, str | None] = {}
    if root is not None:
        for key in ("Dic", "Ico", "IcDph", "CorporateBodyFullName", "CashRegisterCode", "CashRegisterType"):
            el = _find_local(root, key)
            d[key] = el.text.strip() if el is not None and el.text else None
    details = {k: v for k, v in d.items() if v}
    parts = [f"{label} {d[key]}" for label, key in (
        ("DIČ", "Dic"), ("IČO", "Ico"), ("IČ DPH", "IcDph"), ("kód pokladnice", "CashRegisterCode"),
        ("typ", "CashRegisterType")) if d.get(key)]
    if d.get("CorporateBodyFullName"):
        parts.append(d["CorporateBodyFullName"])
    s = "identifikačné údaje (identifikačný balíček)"
    return {"kind": "identifikacne_udaje",
            "description": s + (": " + ", ".join(parts) if parts else "") + f", {len(data)} B",
            "details": details}


def parse_audit_csv(data: bytes) -> list[dict]:
    """
    Riadky audit.csv (TAB, UTF-8 s BOM; stĺpce datetime, user, sorp_code,
    client_software, source_ip, action). Z poľa action sa vyberú tokeny
    `txn=`, `subor=`, `doklad=`, `typ=`, `ext=`, `format=` (formát auditu SORP 1.0.6).
    """
    lines = data.decode("utf-8-sig", "replace").splitlines()
    if not lines:
        return []
    header = lines[0].split("\t")
    rows: list[dict] = []
    for raw in lines[1:]:
        if not raw.strip():
            continue
        cols = raw.split("\t")
        row: dict = {h: (cols[j] if j < len(cols) else "") for j, h in enumerate(header)}
        if len(cols) > len(header) and header:
            row[header[-1]] = "\t".join(cols[len(header) - 1:])
        action = row.get("action", raw)
        row["_raw"] = raw
        row["_action"] = action
        row["_token"] = action.split(" ", 1)[0] if action else ""
        for key in ("txn", "subor", "doklad"):
            m = re.search(rf"\b{key}=(\d+)\b", action)
            row[f"_{key}"] = int(m.group(1)) if m else None
        for key in ("typ", "ext", "format"):
            m = re.search(rf"\b{key}=(\S+)", action)
            row[f"_{key}"] = m.group(1) if m else None
        rows.append(row)
    return rows


def _audit_quote(row: dict) -> str:
    when = row.get("datetime", "")
    user = row.get("user", "")
    return f"{when} {user}: {row['_action']}".strip()


def _hint_row(rows: list[dict]) -> dict | None:
    """Riadok audit.csv, ktorý najlepšie opisuje súbor/transakciu: prednosť má akcia
    pokladničného programu (RECEIPT_ORIGINAL_STORED, RECEIPT_COPY_ISSUED, TEST_PRINT_ISSUED,
    CLOSING_STORED, …) pred technickým záznamom úložiska CHDU_STORE, ktorý ju sprevádza."""
    if not rows:
        return None
    for row in rows:
        if row["_token"] != "CHDU_STORE" and row["_token"] in _AUDIT_ACTION_NAMES:
            return row
    for row in rows:
        if row["_token"] != "CHDU_STORE":
            return row
    return rows[0]


def _audit_phrase(row: dict) -> str:
    name = _AUDIT_ACTION_NAMES.get(row["_token"], f"akcia {row['_token']}")
    if row["_token"] == "CLOSING_STORED" and row["_typ"] == "report":
        name = "zoznam dokladov"
    if row["_doklad"] is not None:
        name += f" č. {row['_doklad']}"
    if row["_typ"]:
        name += f" (typ {row['_typ']})"
    return name + " podľa audit.csv"


def _transaction_summary(request: dict | None, response: dict | None, fault: dict | None,
                         location: dict | None, kinds: list[str], exts: list[str],
                         txn_audit: list[dict]) -> str:
    if request is not None:
        number, rtype = request.get("ReceiptNumber"), request.get("ReceiptType")
        type_name = _RECEIPT_TYPE_NAMES.get(rtype or "", "doklad")
        s = f"{type_name} č. {number or '?'} (typ {rtype or '?'})"
        if request.get("Amount"):
            s += f", suma {request['Amount']} EUR"
        for label, key in (("DIČ", "Dic"), ("kód pokladnice", "CashRegisterCode")):
            if request.get(key):
                s += f", {label} {request[key]}"
        when = request.get("IssueDate") or request.get("CreateDate")
        if when:
            s += f", vystavený {when}"
        if request.get("SwId"):
            s += f", SwId {request['SwId']}"
        if response is not None and response.get("Id"):
            s += f"; identifikátor dokladu {response['Id']}"
        elif fault is not None:
            s += "; odpoveď servera: dátová správa odmietnutá"
            if fault.get("EKasaErrorCode"):
                s += f" (kód {fault['EKasaErrorCode']})"
        elif response is None:
            s += "; bez odpovede servera v tejto transakcii"
        return s
    if response is not None:
        s = "odpoveď servera e-kasa"
        if response.get("ReceiptNumber"):
            s += f" k dokladu č. {response['ReceiptNumber']}"
        if response.get("Id"):
            s += f", identifikátor dokladu {response['Id']}"
        return s
    if location is not None:
        return "správa o polohe (RegisterLocationRequest)"
    init_parts = []
    if "autentifikacne_udaje" in kinds:
        init_parts.append("autentifikačné údaje")
    if "identifikacne_udaje" in kinds:
        init_parts.append("identifikačné údaje")
    if init_parts:
        return "inicializácia pokladnice: " + " a ".join(init_parts)
    if "datova_sprava_chyba" in kinds:
        return "dátová správa odmietnutá serverom e-kasa (SOAP Fault)"
    if exts and all(e in ("PRN", "BIN") for e in exts):
        base = "tlačový výstup bez dátovej správy" if "PRN" in exts else "ostatný tlačový výstup"
        hint = _hint_row(txn_audit)
        if hint is not None:
            base += ": " + _audit_phrase(hint)
        return base
    return "iný obsah (pozri súbory)"


def _build_index(contents: dict[str, bytes], damaged: list[tuple[str, str]], zip_path: str,
                 ) -> tuple[dict, dict[str, tuple[bytes, dict]]]:
    damaged_paths = {p for p, _ in damaged}
    slots: dict[tuple[int, int], dict] = {}
    other_files: list[str] = []
    for path in list(contents) + sorted(damaged_paths):
        if path in _ROOT_FILES:
            continue
        m = _FILE_RE.match(path)
        if not m:
            other_files.append(path)
            continue
        txn_seq = int(m.group("dir")[7:].replace("-", ""))
        file_seq = int(m.group("fileseq"))
        slot = slots.setdefault((txn_seq, file_seq), {
            "dir": f"{m.group('yy')}/{m.group('mm')}/{m.group('dir')}", "dirname": m.group("dir"),
        })
        ext = m.group("ext")
        if ext in ("512", "SYNC"):
            continue
        slot["path"] = path
        slot["ext"] = ext
        slot["data"] = contents.get(path)

    audit_rows = parse_audit_csv(contents["audit.csv"]) if "audit.csv" in contents else None
    audit_by_txn: dict[int, list[dict]] = {}
    for row in audit_rows or []:
        if row["_txn"] is not None:
            audit_by_txn.setdefault(row["_txn"], []).append(row)

    transactions: list[dict] = []
    blobs: dict[str, tuple[bytes, dict]] = {}
    n_files = n_print = 0
    for txn_seq in sorted({k[0] for k in slots}):
        seqs = sorted(k[1] for k in slots if k[0] == txn_seq)
        first = slots[(txn_seq, seqs[0])]
        request = response = fault = location = None
        kinds: list[str] = []
        exts: list[str] = []
        files: list[tuple[dict, dict]] = []
        for file_seq in seqs:
            slot = slots[(txn_seq, file_seq)]
            entry: dict = {
                "name": None, "path": slot.get("path"), "seq": file_seq, "ext": slot.get("ext"),
                "size": None, "kind": "", "description": "", "details": {}, "format": None,
                "rendered": None, "audit": [],
            }
            if "path" not in slot:
                entry["name"] = f"{first['dirname']}-{file_seq:04d}.?"
                entry["kind"] = "chyba_datovy_subor"
                entry["description"] = "dátový súbor chýba (v archíve je k nemu len .512 alebo .SYNC)"
            elif slot["data"] is None:
                entry["name"] = slot["path"].rsplit("/", 1)[-1]
                entry["kind"] = "poskodeny_subor"
                entry["description"] = "obsah sa nedá prečítať (poškodený člen archívu)"
            else:
                data = slot["data"]
                ext = slot["ext"]
                entry["name"] = slot["path"].rsplit("/", 1)[-1]
                entry["size"] = len(data)
                n_files += 1
                exts.append(ext)
                if ext == "XML":
                    d = describe_xml(data)
                    if d["kind"] == "datova_sprava_ziadost" and request is None:
                        request = d["details"]
                    elif d["kind"] == "datova_sprava_odpoved" and response is None:
                        response = d["details"]
                    elif d["kind"] == "datova_sprava_chyba" and fault is None:
                        fault = d["details"]
                    elif d["kind"] == "sprava_o_polohe" and location is None:
                        location = d["details"]
                elif ext == "AUTH":
                    d = describe_auth(data)
                elif ext == "IDENT":
                    d = describe_ident(data)
                elif ext == "JSON":
                    d = {"kind": "udaje_json", "description": f"údaje vo formáte JSON, {len(data)} B", "details": {}}
                else:
                    d = {"kind": "_tlacovy_vystup", "description": "", "details": {}}
                    n_print += 1
                entry["kind"], entry["description"], entry["details"] = d["kind"], d["description"], d["details"]
                kinds.append(d["kind"])
            files.append((slot, entry))

        txn_audit = audit_by_txn.get(txn_seq, [])
        number = request.get("ReceiptNumber") if request else (response.get("ReceiptNumber") if response else None)
        rtype = request.get("ReceiptType") if request else None
        has_xml = "XML" in exts
        for slot, entry in files:
            if entry["kind"] != "_tlacovy_vystup":
                continue
            data = slot["data"]
            ext = slot["ext"]
            file_audit = [r for r in txn_audit if r["_subor"] == entry["seq"]]
            entry["audit"] = [_audit_quote(r) for r in file_audit]
            info = inspect_print_output(data)
            entry["format"] = info["public"]
            if ext == "PRN" and has_xml:
                kind = "tlacovy_vystup_original"
                desc = f"tlačový výstup originálu {_receipt_phrase(number, rtype, genitive=True)}"
            elif ext == "PRN":
                kind, desc = "tlacovy_vystup", "tlačový výstup (v transakcii nie je dátová správa)"
            elif has_xml:
                kind, desc = "kopia_dokladu", f"kópia dokladu č. {number or '?'} (ostatný tlačový výstup)"
            else:
                kind, desc = "ostatny_tlacovy_vystup", "ostatný tlačový výstup"
            hint = _hint_row(file_audit) or _hint_row(txn_audit)
            if hint is not None and not has_xml:
                desc += ": " + _audit_phrase(hint)
            entry["kind"] = kind
            entry["description"] = f"{desc}; formát: {info['description']}"
            blobs[slot["path"]] = (data, info)

        file_seqs = {e["seq"] for _, e in files}
        receipt = None
        if request is not None or response is not None:
            req = request or {}
            receipt = {
                "number": req.get("ReceiptNumber") or (response or {}).get("ReceiptNumber"),
                "type": req.get("ReceiptType"),
                "type_name": _RECEIPT_TYPE_NAMES.get(req.get("ReceiptType") or ""),
                "dic": req.get("Dic"), "cash_register_code": req.get("CashRegisterCode"),
                "amount": req.get("Amount"), "issue_date": req.get("IssueDate"),
                "create_date": req.get("CreateDate"), "sw_id": req.get("SwId"),
                "document_id": (response or {}).get("Id"),
                "rejected": fault is not None,
            }
        transactions.append({
            "seq": txn_seq, "dir": first["dir"], "dirname": first["dirname"],
            "summary": _transaction_summary(request, response, fault, location, kinds, exts, txn_audit),
            "receipt": receipt,
            "files": [e for _, e in files],
            "audit": [_audit_quote(r) for r in txn_audit if r["_subor"] is None or r["_subor"] not in file_seqs],
        })

    index = {
        "tool": "chdu_export_verify", "tool_version": TOOL_VERSION, "export": zip_path,
        "counts": {"transactions": len(transactions), "files": n_files, "print_outputs": n_print},
        "audit_csv": None if audit_rows is None else {
            "rows": len(audit_rows), "rows_with_txn": sum(1 for r in audit_rows if r["_txn"] is not None),
        },
        "transactions": transactions,
        "other_files": sorted(other_files),
        "damaged_files": sorted(damaged_paths),
        "render_dir": None,
        "rendered": [],
    }
    return index, blobs


def build_transaction_index(contents: dict[str, bytes], damaged: list[tuple[str, str]] | None = None,
                            zip_path: str = "") -> dict:
    """Index transakcií exportu (strojová štruktúra; text dáva format_index_text)."""
    index, _ = _build_index(contents, damaged or [], zip_path)
    return index


def format_index_text(index: dict) -> str:
    """Čitateľný index transakcií (slovensky) zo štruktúry build_transaction_index / render_export."""
    c = index["counts"]
    out = [
        "Index transakcií exportu z CHDÚ",
        f"Nástroj: chdu_export_verify {index['tool_version']}",
        f"Export: {index['export']}",
        f"Transakcií: {c['transactions']}; dátových súborov: {c['files']}; "
        f"tlačových výstupov (.PRN/.BIN): {c['print_outputs']}",
    ]
    if index.get("render_dir") is not None:
        out.append(
            f"Vykreslené tlačové výstupy: {_pl(len(index['rendered']), 'súbor', 'súbory', 'súborov')} "
            f"v adresári {index['render_dir']} (ESC/POS raster ako PNG, 1 pixel = 1 bod rastra pri 203 dpi; "
            f"PDF a neznáme formáty skopírované bez zmeny)"
        )
    if index.get("audit_csv"):
        a = index["audit_csv"]
        out.append(f"audit.csv: {_pl(a['rows'], 'riadok', 'riadky', 'riadkov')}, "
                   f"z toho {a['rows_with_txn']} s odkazom na transakciu (txn=)")
    else:
        out.append("audit.csv: v archíve chýba alebo sa nedá prečítať")
    out.append("Index je informatívny prehľad obsahu exportu; výsledok overenia integrity "
               "je vo výstupe nástroja (riadok VÝSLEDOK).")
    for txn in index["transactions"]:
        out.append("")
        out.append(f"Transakcia {txn['seq']:012d}  (adresár {txn['dir']}, "
                   f"{_pl(len(txn['files']), 'súbor', 'súbory', 'súborov')})")
        out.append(f"  Obsah: {txn['summary']}")
        for f in txn["files"]:
            line = f"  {f['name']}  {f['description']}"
            if f.get("rendered"):
                line += f"; vykreslené: {f['rendered']}"
            out.append(line)
            for q in f["audit"]:
                out.append(f"      audit.csv: {q}")
        for q in txn["audit"]:
            out.append(f"  audit.csv (transakcia): {q}")
    if index.get("damaged_files"):
        out.append("")
        out.append("Poškodené členy archívu (obsah sa nedá prečítať):")
        out.extend(f"  {p}" for p in index["damaged_files"])
    if index.get("other_files"):
        out.append("")
        out.append("Súbory mimo transakčnej štruktúry (neočakávané):")
        out.extend(f"  {p}" for p in index["other_files"])
    out.append("")
    return "\n".join(out) + "\n"


def _write_index(index: dict, txt_path: str, json_path: str) -> None:
    with open(txt_path, "w", encoding="utf-8", newline="\n") as fh:
        fh.write(format_index_text(index))
    with open(json_path, "w", encoding="utf-8", newline="\n") as fh:
        fh.write(json.dumps(index, ensure_ascii=False, indent=1))
        fh.write("\n")


def _index_paths(index_path: str) -> tuple[str, str]:
    base, ext = os.path.splitext(index_path)
    if ext.lower() == ".json":
        return base + ".txt", index_path
    return index_path, base + ".json"


def render_export(zip_path: str, render_dir: str | None = None, index_path: str | None = None) -> dict:
    """
    Preveď tlačové výstupy exportu do čitateľnej podoby (render_dir) a zostav
    index transakcií (render_dir/index.txt + index.json, resp. index_path).
    Nemení výsledok overenia integrity. Vracia {ok, render_dir, rendered_files,
    unknown_format, index_txt, index_json, index, messages, errors}.
    """
    result: dict = {
        "ok": True, "render_dir": render_dir, "rendered_files": [], "unknown_format": 0,
        "index_txt": None, "index_json": None, "index": None, "messages": [], "errors": [],
    }

    def fail(msg: str) -> dict:
        result["ok"] = False
        result["errors"].append(msg)
        result["messages"].append("CHYBA: " + msg)
        return result

    try:
        zf = zipfile.ZipFile(zip_path)
    except (OSError, zipfile.BadZipFile) as e:
        return fail(f"prevod tlačových výstupov nie je možný, archív sa nedá otvoriť: {e}")
    with zf:
        contents, damaged = _read_zip_members(zf)
    index, blobs = _build_index(contents, damaged, zip_path)

    if render_dir is not None:
        try:
            os.makedirs(render_dir, exist_ok=True)
        except OSError as e:
            return fail(f"adresár na vykreslenie {render_dir} sa nedá vytvoriť: {e}")
        for txn in index["transactions"]:
            for entry in txn["files"]:
                if entry["path"] not in blobs:
                    continue
                data, info = blobs[entry["path"]]
                ext, payload, info = render_print_output(data, info)
                out_name = f"{entry['name']}.{ext}"
                try:
                    with open(os.path.join(render_dir, out_name), "wb") as fh:
                        fh.write(payload)
                except OSError as e:
                    return fail(f"súbor {out_name} sa nedá zapísať do {render_dir}: {e}")
                entry["rendered"] = out_name
                if info["format"] == FORMAT_UNKNOWN:
                    result["unknown_format"] += 1
                result["rendered_files"].append({
                    "source": entry["path"], "output": out_name, "output_bytes": len(payload), **info["public"],
                })
        index["render_dir"] = render_dir
        index["rendered"] = result["rendered_files"]
        txt_path, json_path = os.path.join(render_dir, "index.txt"), os.path.join(render_dir, "index.json")
        try:
            _write_index(index, txt_path, json_path)
        except OSError as e:
            return fail(f"index sa nedá zapísať do {render_dir}: {e}")
        result["index_txt"], result["index_json"] = txt_path, json_path
        n = len(result["rendered_files"])
        result["messages"].append(
            f"Vykreslené tlačové výstupy: {_pl(n, 'súbor', 'súbory', 'súborov')} do {render_dir}; "
            f"index: {txt_path}, {json_path}"
        )
        if result["unknown_format"]:
            result["messages"].append(
                f"Tlačové výstupy neznámeho formátu (skopírované bez zmeny ako .bin): {result['unknown_format']}"
            )

    if index_path is not None:
        txt_path, json_path = _index_paths(index_path)
        parent = os.path.dirname(os.path.abspath(txt_path))
        try:
            os.makedirs(parent, exist_ok=True)
            _write_index(index, txt_path, json_path)
        except OSError as e:
            return fail(f"index sa nedá zapísať do {txt_path}: {e}")
        result["index_txt"], result["index_json"] = txt_path, json_path
        result["messages"].append(f"Index transakcií: {txt_path} (JSON: {json_path})")

    result["index"] = index
    return result


_USAGE = "Použitie: chdu_export_verify [--json] [--render ADRESÁR] [--index SÚBOR] <export.zip>"


def main() -> int:
    # Windows konzola môže bežať v cp1252, ktorá nepozná slovenské znaky —
    # bez tohto by výpis (ť, š, á…) skončil UnicodeEncodeError.
    for stream in (sys.stdout, sys.stderr):
        if hasattr(stream, "reconfigure"):
            stream.reconfigure(encoding="utf-8", errors="replace")
    args = sys.argv[1:]
    if args and args[0] in ("--version", "-V"):
        print(f"chdu_export_verify {TOOL_VERSION}")
        return 0
    as_json = False
    render_dir: str | None = None
    index_path: str | None = None
    positional: list[str] = []
    i = 0
    while i < len(args):
        arg = args[i]
        if arg == "--json":
            as_json = True
        elif arg in ("--render", "--index"):
            if i + 1 >= len(args):
                print(_USAGE, file=sys.stderr)
                return 1
            if arg == "--render":
                render_dir = args[i + 1]
            else:
                index_path = args[i + 1]
            i += 1
        elif arg.startswith("--"):
            print(_USAGE, file=sys.stderr)
            return 1
        else:
            positional.append(arg)
        i += 1
    if len(positional) != 1:
        print(_USAGE, file=sys.stderr)
        return 1
    zip_path = positional[0]

    rep = verify_export_report(zip_path)
    render_ok = True
    if render_dir is not None or index_path is not None:
        # Prevod a index nemenia verdikt ani hlásenia overenia; len dopĺňajú riadky.
        rendered = render_export(zip_path, render_dir, index_path)
        render_ok = rendered["ok"]
        rep["messages"] = rep["messages"] + rendered["messages"]
        rep["render"] = {
            "ok": rendered["ok"], "render_dir": rendered["render_dir"],
            "rendered_files": len(rendered["rendered_files"]), "unknown_format": rendered["unknown_format"],
            "index_txt": rendered["index_txt"], "index_json": rendered["index_json"],
            "errors": rendered["errors"],
        }
    if as_json:
        print(json.dumps(rep, ensure_ascii=False, indent=1))
    else:
        for msg in rep["messages"]:
            print(msg)
        print("VÝSLEDOK: INTEGRITA OK" if rep["ok"] else "VÝSLEDOK: INTEGRITA NARUŠENÁ")
    return 0 if rep["ok"] and render_ok else 1


if __name__ == "__main__":
    sys.exit(main())
