# -*- coding: utf-8 -*-
"""
STRAFDOSSIER DOWNLOADER - VERSIE 36
Just-on-Web (JustConsult) -> PDF -> originele dossierstructuur
Nelson Vanlocke©

Gebruik:
  1. Start het script en klik op "Chrome openen". Er opent een apart Chrome-venster.
  2. Meld u daarin aan (itsme, eID of MyGov), open JustConsult en open het dossier.
  3. Klik op "Dossier analyseren", kies de gewenste mappen en klik op "Download starten".

Nieuw in V36:
  - knop "Chrome openen" (geen cmd meer; vast Chrome-profiel, de login blijft bewaard);
  - drie live controles: Chrome verbonden / JustConsult geopend / dossier geopend;
  - Stop-knop, en analyseren en downloaden zijn gesplitst met een echte mappenboom;
  - resultaatscherm, "Mislukte opnieuw proberen" en een csv-rapport per download;
  - technische details staan achter een uitklapbaar paneel en in een logbestand;
  - VERTRAGING OP GROTE DOSSIERS OPGELOST: V35 herberekende bij elke documentselectie
    de mappenstructuur van het hele dossier (kwadratisch). Daardoor werd elk document
    trager naarmate het dossier groter was en leek de download vast te lopen.

Alles blijft lokaal. Er worden geen wachtwoorden of dossiers verzonden of bewaard.
"""

import base64
import csv
import ctypes
import hashlib
import json
import os
import queue
import re
import shutil
import subprocess
import sys
import threading
import time
import traceback
import urllib.request
from datetime import datetime
from pathlib import Path

import tkinter as tk
from tkinter import filedialog, messagebox, ttk

try:
    from playwright.sync_api import sync_playwright
    PLAYWRIGHT_IMPORT_ERROR = None
except ImportError as _exc:  # wordt vriendelijk gemeld in main()
    sync_playwright = None
    PLAYWRIGHT_IMPORT_ERROR = _exc

try:
    import winsound
except ImportError:
    winsound = None


# =====================================================================
# INSTELLINGEN
# =====================================================================

APP_NAME = "Strafdossier Downloader"
VERSION = "36"

CDP_PORT = int(os.environ.get("JOW_CDP_PORT", "9222"))
# Recente Chrome-versies luisteren soms enkel op IPv6 (::1) en soms enkel op IPv4,
# daarom worden alle drie de adressen geprobeerd (zie debug_port_info).
CDP_HOSTS = ("localhost", "127.0.0.1", "[::1]")
CDP_URL = f"http://localhost:{CDP_PORT}"
JUST_HOST = os.environ.get("JOW_HOST", "justonwebconsultonline.just.fgov.be")
START_URL = os.environ.get("JOW_START_URL", "https://justonweb.be/nl/judicial-file")

DATA_DIR = Path(os.environ.get("LOCALAPPDATA") or (Path.home() / ".strafdossier_downloader")) / "StrafdossierDownloader"
PROFILE_DIR = DATA_DIR / "ChromeProfile"
LOG_DIR = DATA_DIR / "logs"
SETTINGS_FILE = DATA_DIR / "settings.json"
DEFAULT_OUTPUT = Path.home() / "Desktop" / "JustOnWeb_PDF"

XHR_TIMEOUT = 60            # wachttijd (s) op de echte PDF-response
AFTER_CLICK_DELAY_MS = 200  # korte pauze zodat Angular de viewer kan bijwerken
MAX_ATTEMPTS = 2
STATUS_POLL_SECONDS = 2.0
IDLE_WARNING_SECONDS = 100  # na zoveel stilte tijdens een download verschijnt een waarschuwing

ICON_TOKENS = {
    "insert_drive_file", "folder", "expand_more", "chevron_right",
    "chevron_left", "expand_less", "collapse", "expand",
}


class Cancelled(Exception):
    """De gebruiker drukte op Stop."""


class UserError(Exception):
    """Fout met een boodschap die rechtstreeks aan de gebruiker getoond mag worden."""


# =====================================================================
# KLEINE HULPFUNCTIES
# =====================================================================

_RESERVED = {"CON", "PRN", "AUX", "NUL"} | {f"COM{i}" for i in range(1, 10)} | {f"LPT{i}" for i in range(1, 10)}


def clean_name(name, max_len=150):
    """Maak van een JustConsult-naam een geldige Windows-bestandsnaam."""
    name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", str(name))
    name = name.rstrip(" .") or "_"
    if len(name) > max_len:
        digest = hashlib.md5(name.encode("utf-8")).hexdigest()[:6]
        name = name[:max_len - 7].rstrip(" .") + "~" + digest
    if name.split(".")[0].upper() in _RESERVED:
        name = "_" + name
    return name


def fs_path(path):
    """Pad als string; op Windows met \\\\?\\-prefix zodat lange mappennamen werken (>260 tekens)."""
    s = os.path.abspath(str(path))
    if os.name == "nt" and len(s) >= 240 and not s.startswith("\\\\?\\"):
        s = ("\\\\?\\UNC\\" + s[2:]) if s.startswith("\\\\") else ("\\\\?\\" + s)
    return s


def valid_pdf_file(path):
    try:
        p = fs_path(path)
        if os.path.getsize(p) <= 0:
            return False
        with open(p, "rb") as fh:
            return fh.read(4) == b"%PDF"
    except OSError:
        return False


def human_time(seconds):
    seconds = max(0, int(seconds))
    h, rest = divmod(seconds, 3600)
    m, s = divmod(rest, 60)
    return f"{h}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}"


def format_bytes(n):
    n = float(n or 0)
    for unit in ("B", "KB", "MB", "GB"):
        if n < 1024 or unit == "GB":
            return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
        n /= 1024


def shorten(text, width=64):
    text = str(text)
    return text if len(text) <= width else text[:width - 1] + "…"


def load_settings():
    try:
        return json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
    except Exception:
        return {}


def save_settings(data):
    try:
        DATA_DIR.mkdir(parents=True, exist_ok=True)
        SETTINGS_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
    except Exception:
        pass


def open_path(path):
    try:
        if os.name == "nt":
            os.startfile(str(path))
        elif sys.platform == "darwin":
            subprocess.Popen(["open", str(path)])
        else:
            subprocess.Popen(["xdg-open", str(path)])
        return True
    except Exception:
        return False


def enable_dpi_awareness():
    """Voorkom wazige tekst op Windows-schermen met schaling."""
    if os.name != "nt":
        return
    try:
        ctypes.windll.shcore.SetProcessDpiAwareness(1)
    except Exception:
        try:
            ctypes.windll.user32.SetProcessDPIAware()
        except Exception:
            pass


def notify_sound(kind="done"):
    try:
        if winsound is not None:
            winsound.MessageBeep(winsound.MB_ICONEXCLAMATION if kind == "warn" else winsound.MB_ICONASTERISK)
        else:
            print("\a", end="", flush=True)
    except Exception:
        pass


# =====================================================================
# CHROME STARTEN EN CONTROLEREN
# =====================================================================

def find_browser():
    """Zoek chrome.exe (of Edge als reserve). Geeft (naam, pad) of None."""
    chrome, edge = [], []

    if os.name == "nt":
        try:
            import winreg
            for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
                for exe, bucket in (("chrome.exe", chrome), ("msedge.exe", edge)):
                    try:
                        with winreg.OpenKey(
                            hive, rf"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{exe}"
                        ) as key:
                            bucket.append(winreg.QueryValueEx(key, None)[0])
                    except OSError:
                        pass
        except ImportError:
            pass
        for env in ("ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"):
            base = os.environ.get(env)
            if base:
                chrome.append(os.path.join(base, "Google", "Chrome", "Application", "chrome.exe"))
                edge.append(os.path.join(base, "Microsoft", "Edge", "Application", "msedge.exe"))
    elif sys.platform == "darwin":
        chrome.append("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")
        edge.append("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge")
    else:
        for n in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser"):
            p = shutil.which(n)
            if p:
                chrome.append(p)
        p = shutil.which("microsoft-edge")
        if p:
            edge.append(p)

    for name, items in (("Google Chrome", chrome), ("Microsoft Edge", edge)):
        for path in items:
            if path and os.path.isfile(path):
                return name, path
    return None


def launch_browser(exe, url=START_URL):
    """Start Chrome met remote debugging en een vast, eigen profiel (login blijft bewaard)."""
    PROFILE_DIR.mkdir(parents=True, exist_ok=True)
    args = [
        exe,
        f"--remote-debugging-port={CDP_PORT}",
        f"--user-data-dir={PROFILE_DIR}",
        "--no-first-run",
        "--no-default-browser-check",
        # Chrome blijft vlot werken als het venster achter dit programma staat.
        "--disable-backgrounding-occluded-windows",
        "--disable-renderer-backgrounding",
        "--disable-background-timer-throttling",
        "--disable-features=CalculateNativeWinOcclusion",
        url,
    ]
    flags = 0x00000008 | 0x00000200 if os.name == "nt" else 0  # DETACHED_PROCESS | NEW_PROCESS_GROUP
    subprocess.Popen(
        args, creationflags=flags, close_fds=True,
        stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
    )


_NO_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))


def _cdp_bases():
    """Alle adressen waarop de debug-poort kan luisteren; het laatst werkende eerst."""
    bases = [f"http://{h}:{CDP_PORT}" for h in CDP_HOSTS]
    if CDP_URL in bases:
        bases.remove(CDP_URL)
        bases.insert(0, CDP_URL)
    return bases


def reachable_cdp_urls(timeout=1.0):
    """Lijst van (adres, antwoord) voor elk adres waarop Chrome in debug-modus antwoordt."""
    found = []
    for base in _cdp_bases():
        try:
            with _NO_PROXY_OPENER.open(base + "/json/version", timeout=timeout) as resp:
                found.append((base, json.loads(resp.read().decode("utf-8", "replace"))))
        except Exception:
            continue
    return found


def debug_port_info(timeout=1.0):
    """Antwoord van Chrome op de debug-poort, of None als Chrome niet (in debug-modus) draait."""
    global CDP_URL
    for base in _cdp_bases():
        try:
            with _NO_PROXY_OPENER.open(base + "/json/version", timeout=timeout) as resp:
                info = json.loads(resp.read().decode("utf-8", "replace"))
            CDP_URL = base
            return info
        except Exception:
            continue
    return None


# =====================================================================
# RAPPORTEREN VANUIT DE ENGINE NAAR DE GUI (en naar een logbestand)
# =====================================================================

class Reporter:
    """Thread-veilige brug: engine -> GUI-wachtrij + logbestand."""

    def __init__(self, events):
        self.events = events
        self.cancel = threading.Event()
        self._lock = threading.Lock()
        self._fh = None
        self.log_path = None
        try:
            LOG_DIR.mkdir(parents=True, exist_ok=True)
            old = sorted(LOG_DIR.glob("sessie_*.log"))
            for p in old[:-19]:
                try:
                    p.unlink()
                except OSError:
                    pass
            self.log_path = LOG_DIR / f"sessie_{datetime.now():%Y%m%d_%H%M%S}.log"
            self._fh = open(self.log_path, "a", encoding="utf-8", buffering=1)
        except Exception:
            self._fh = None

    def _write(self, level, text):
        if not self._fh:
            return
        with self._lock:
            try:
                self._fh.write(f"{datetime.now():%H:%M:%S} [{level}] {text}\n")
            except Exception:
                pass

    # Zichtbaar in het activiteitenvenster
    def info(self, text):
        self._write("INFO", text)
        self.events.put(("log", "info", text))

    def ok(self, text):
        self._write("OK", text)
        self.events.put(("log", "ok", text))

    def warn(self, text):
        self._write("WARN", text)
        self.events.put(("log", "warn", text))

    def error(self, text):
        self._write("FOUT", text)
        self.events.put(("log", "error", text))

    # Alleen in "Technische details" en in het logbestand
    def tech(self, text):
        self._write("TECH", text)
        self.events.put(("tech", text))

    def phase(self, text):
        self.events.put(("phase", text))

    def emit(self, *event):
        self.events.put(event)

    def cancelled(self):
        return self.cancel.is_set()

    def check_cancel(self):
        if self.cancel.is_set():
            raise Cancelled()


# =====================================================================
# JAVASCRIPT IN JUSTCONSULT
# =====================================================================
# Gemeenschappelijke hulpfuncties. BELANGRIJK: de eigen tekst en iconen van
# een node worden gelezen door de DOM te doorlopen (zonder geneste nodes), niet
# door de node te klonen. V35 klonete bij élke documentselectie de hele boom
# voor élk document, waardoor één klik bij een groot dossier tientallen seconden
# duurde.

JS_HELPERS = r"""
const clean = s => (s || '').replace(/\s+/g, ' ').trim();
const NODE_SELECTOR = 'mat-nested-tree-node, mat-tree-node, li.mat-tree-node';

function ownParts(el) {
    let text = '';
    const icons = [];
    (function rec(n) {
        for (let c = n.firstChild; c; c = c.nextSibling) {
            if (c.nodeType === 3) {
                text += c.nodeValue;
            } else if (c.nodeType === 1) {
                if (c.matches(NODE_SELECTOR)) continue;
                if (c.localName === 'mat-icon') {
                    const t = clean(c.textContent);
                    if (t) icons.push(t);
                    continue;
                }
                rec(c);
            }
        }
    })(el);
    return {text: clean(text), icons};
}

const partsCache = new Map();
function parts(el) {
    let v = partsCache.get(el);
    if (!v) { v = ownParts(el); partsCache.set(el, v); }
    return v;
}

function isFolderNode(el) {
    if (!el) return false;
    const icons = parts(el).icons;
    return !icons.includes('insert_drive_file') && (
        el.matches('mat-nested-tree-node') ||
        icons.includes('folder') ||
        icons.includes('chevron_right') ||
        icons.includes('expand_more') ||
        el.hasAttribute('mattreenodetoggle') ||
        el.hasAttribute('matTreeNodeToggle')
    );
}
"""


def _js(body):
    return body.replace("__HELPERS__", JS_HELPERS)


# Status voor de live controle: hoeveel tree-nodes zijn zichtbaar en hoe heet de eerste?
JS_TREE_STATUS = _js(r"""
() => {
    __HELPERS__
    const nodes = document.querySelectorAll(NODE_SELECTOR);
    let first = '';
    for (let i = 0; i < Math.min(nodes.length, 6) && !first; i++) {
        first = parts(nodes[i]).text;
    }
    return {count: nodes.length, first};
}
""")

# Gesloten mappen (documenten hebben ook aria-expanded=false, maar tellen niet mee).
JS_SCAN_CLOSED = _js(r"""
() => {
    __HELPERS__
    const nodeFor = c =>
        c.closest('mat-tree-node') || c.closest('li.mat-tree-node') ||
        c.closest('mat-nested-tree-node') || c;

    const controls = Array.from(document.querySelectorAll('[aria-expanded="false"]'));
    const result = [];
    const seen = new Set();

    for (const control of controls) {
        const node = nodeFor(control);
        if (!node) continue;
        const tag = node.tagName.toLowerCase();
        if (!(tag === 'mat-tree-node' || tag === 'li' || tag === 'mat-nested-tree-node')) continue;

        const icons = Array.from(node.querySelectorAll('mat-icon'))
            .map(x => clean(x.textContent)).filter(Boolean);
        if (icons.includes('insert_drive_file')) continue;

        const isFolder =
            tag === 'mat-nested-tree-node' ||
            icons.includes('folder') || icons.includes('chevron_right') ||
            icons.includes('expand_more') ||
            node.hasAttribute('aria-expanded') ||
            node.querySelector('button[aria-expanded]');
        if (!isFolder) continue;

        let label = parts(node).text;
        if (!label) label = clean(node.innerText || node.textContent || '');
        if (!label) continue;

        const rect = node.getBoundingClientRect();
        const aria = node.getAttribute('aria-level') || control.getAttribute('aria-level');
        const key = [label, aria || '', Math.round(rect.left)].join('|');
        if (seen.has(key)) continue;
        seen.add(key);

        result.push({
            label,
            ariaLevel: aria ? parseInt(aria, 10) : null,
            left: Math.round(rect.left),
            top: Math.round(rect.top)
        });
    }
    return result;
}
""")

JS_CLICK_FOLDER = _js(r"""
({wanted}) => {
    __HELPERS__
    const controls = Array.from(document.querySelectorAll('[aria-expanded="false"]'));
    const nodeFor = c =>
        c.closest('mat-tree-node') || c.closest('li.mat-tree-node') ||
        c.closest('mat-nested-tree-node') || c;
    const labelOf = node => parts(node).text || clean(node.innerText || node.textContent || '');

    const candidates = [];
    for (const control of controls) {
        const node = nodeFor(control);
        if (!node) continue;
        const icons = Array.from(node.querySelectorAll('mat-icon'))
            .map(x => clean(x.textContent)).filter(Boolean);
        // Veiligheidscheck: nooit een document klikken.
        if (icons.includes('insert_drive_file')) continue;
        if (labelOf(node) !== wanted.label) continue;

        const aria = node.getAttribute('aria-level') || control.getAttribute('aria-level');
        if (wanted.ariaLevel !== null && aria && parseInt(aria, 10) !== wanted.ariaLevel) continue;

        const rect = node.getBoundingClientRect();
        if (wanted.left !== null && Math.abs(Math.round(rect.left) - wanted.left) > 8) continue;
        candidates.push({control, node});
    }

    // Reserve: zelfde naam, ongeacht inspringing.
    if (!candidates.length) {
        for (const control of controls) {
            const node = nodeFor(control);
            if (node && labelOf(node) === wanted.label) candidates.push({control, node});
        }
    }

    for (const {control, node} of candidates) {
        node.scrollIntoView({block: 'center', inline: 'nearest'});

        const chevron = Array.from(node.querySelectorAll('mat-icon'))
            .find(x => clean(x.textContent) === 'chevron_right');
        let target = null;
        if (chevron) {
            target = chevron.closest('button') || chevron.parentElement || chevron;
        }
        if (!target) {
            target = control.closest('button') ||
                (control.querySelector && control.querySelector('[matTreeNodeToggle], [mattreenodetoggle]')) ||
                node.querySelector('button, [matTreeNodeToggle], [mattreenodetoggle]') ||
                control;
        }
        try {
            target.setAttribute('data-jow-expand-target', '1');
            target.click();
            return {ok: true, label: labelOf(node)};
        } catch (e) {
            return {ok: false, reason: String(e), label: labelOf(node)};
        }
    }
    return {ok: false, reason: 'closed-control-not-found', label: wanted.label};
}
""")

# De volledige boom: documenten met hun echte folderpad (via de DOM-ouders).
JS_TREE = _js(r"""
() => {
    __HELPERS__
    const elements = Array.from(document.querySelectorAll(NODE_SELECTOR));
    const elementSet = new Set(elements);
    const kindCache = new Map();

    function kindOf(el) {
        let k = kindCache.get(el);
        if (k !== undefined) return k;
        const icons = parts(el).icons;
        if (icons.includes('insert_drive_file')) k = 'document';
        else if (
            el.matches('mat-nested-tree-node') ||
            icons.includes('folder') || icons.includes('chevron_right') ||
            icons.includes('expand_more') ||
            el.hasAttribute('mattreenodetoggle') || el.hasAttribute('matTreeNodeToggle')
        ) k = 'folder';
        else k = 'unknown';
        kindCache.set(el, k);
        return k;
    }

    function ancestry(el) {
        const out = [];
        let p = el.parentElement;
        while (p) {
            if (elementSet.has(p) && kindOf(p) === 'folder') {
                const name = parts(p).text;
                if (name) out.push(name);
            }
            p = p.parentElement;
        }
        out.reverse();
        // Dezelfde map kan door Angular tweemaal voorkomen: alleen opeenvolgende duplicaten weg.
        const unique = [];
        for (const n of out) {
            if (!unique.length || unique[unique.length - 1] !== n) unique.push(n);
        }
        return unique;
    }

    const rows = [];
    elements.forEach((el, index) => {
        const kind = kindOf(el);
        const name = parts(el).text;
        if (!name || kind === 'unknown') return;
        rows.push({index, kind, name, folders: kind === 'document' ? ancestry(el) : []});
    });
    return rows;
}
""")

# Selecteer exact één document via de echte DOM-ouderstructuur.
# Snel: eerst filteren op naam (goedkoop), pas daarna de mappen van de kandidaten bepalen.
JS_CLICK_DOC = _js(r"""
({wanted, folders}) => {
    __HELPERS__
    const wn = clean(wanted);
    const wf = folders.map(clean);

    function ancestryOf(li) {
        const out = [];
        let p = li.parentElement;
        while (p) {
            if (p.matches && p.matches(NODE_SELECTOR) && isFolderNode(p)) {
                const name = parts(p).text;
                if (name) out.push(name);
            }
            p = p.parentElement;
        }
        return out.reverse();
    }

    const candidates = [];
    document.querySelectorAll('li.mat-tree-node').forEach((li, index) => {
        if (li.querySelector(NODE_SELECTOR)) return;   // heeft kinderen: is een map
        const p = parts(li);
        if (p.text !== wn || !p.icons.includes('insert_drive_file')) return;
        candidates.push({li, index, text: p.text, icons: p.icons});
    });
    candidates.forEach(c => { c.folders = ancestryOf(c.li); });

    let found = candidates.find(x =>
        x.folders.length === wf.length && x.folders.every((v, i) => v === wf[i]));
    if (!found && candidates.length === 1) found = candidates[0];

    if (!found) {
        return {
            ok: false, reason: 'document-not-found-exact-dom-path',
            wanted: wn, folders: wf, candidateCount: candidates.length,
            candidates: candidates.slice(0, 8).map(x => ({text: x.text, folders: x.folders}))
        };
    }

    const li = found.li;
    try { li.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); }
    catch (e) { try { li.scrollIntoView(true); } catch (_) {} }

    function dispatch(el, type) {
        if (!el) return false;
        try {
            el.dispatchEvent(new MouseEvent(type, {
                bubbles: true, cancelable: true, view: window, detail: 1, buttons: 1
            }));
            return true;
        } catch (e) { return false; }
    }

    // De click-listener zit afhankelijk van de rendering op de LI of op een tekst-child.
    const textTarget = Array.from(li.querySelectorAll('span, div, p, a')).find(el => {
        const t = clean(el.innerText || el.textContent || '');
        return t === wn || t.includes(wn);
    });

    let clicked = false;
    if (textTarget) {
        dispatch(textTarget, 'mousedown');
        dispatch(textTarget, 'mouseup');
        clicked = dispatch(textTarget, 'click');
    }
    if (!clicked) {
        dispatch(li, 'mousedown');
        dispatch(li, 'mouseup');
        clicked = dispatch(li, 'click');
    }
    return {ok: clicked, text: found.text, folders: found.folders, candidateCount: candidates.length};
}
""")

# Oudere, eenvoudige selectie (reserve voor poging 2).
JS_CLICK_DOC_LEGACY = r"""
({wanted, folders}) => {
    const nodes = Array.from(document.querySelectorAll("li.mat-tree-node"));

    function iconsOf(li) {
        return Array.from(li.querySelectorAll("mat-icon"))
            .map(x => (x.textContent || "").trim())
            .filter(Boolean);
    }
    function ownText(li) {
        const c = li.cloneNode(true);
        c.querySelectorAll("li.mat-tree-node").forEach(x => x.remove());
        c.querySelectorAll("mat-icon").forEach(x => x.remove());
        return (c.innerText || c.textContent || "").replace(/\s+/g, " ").trim();
    }
    function domLevel(li) {
        const aria = parseInt(li.getAttribute("aria-level") || "", 10);
        if (Number.isFinite(aria)) return aria;
        let a = 0, p = li.parentElement;
        while (p) {
            if (p.matches && p.matches("li.mat-tree-node")) a++;
            p = p.parentElement;
        }
        return a + 1;
    }

    const data = nodes.map(li => ({
        li, text: ownText(li), icons: iconsOf(li), level: domLevel(li),
        left: li.getBoundingClientRect().left
    }));

    if (!data.some(x => x.level > 1)) {
        const xs = [...new Set(data.map(x => Math.round(x.left * 10) / 10))].sort((a, b) => a - b);
        const clusters = [];
        for (const x of xs) {
            if (!clusters.length || x - clusters[clusters.length - 1][clusters[clusters.length - 1].length - 1] > 8) {
                clusters.push([x]);
            } else {
                clusters[clusters.length - 1].push(x);
            }
        }
        const centers = clusters.map(c => c.reduce((a, b) => a + b, 0) / c.length);
        for (const x of data) {
            x.level = centers.findIndex(c => Math.abs(c - x.left) < 9) + 1;
        }
    }

    const stack = {};
    const docs = [];
    for (const x of data) {
        for (const k of Object.keys(stack)) {
            if (Number(k) >= x.level) delete stack[k];
        }
        if (
            !x.icons.includes("insert_drive_file") &&
            (x.icons.includes("folder") || x.icons.includes("chevron_right") ||
             x.icons.includes("expand_more") || x.li.getAttribute("aria-expanded") !== null)
        ) {
            stack[x.level] = x.text;
        } else if (x.icons.includes("insert_drive_file")) {
            docs.push({
                x,
                folders: Object.keys(stack).sort((a, b) => Number(a) - Number(b))
                    .filter(k => Number(k) < x.level).map(k => stack[k])
            });
        }
    }

    const norm = s => String(s || "").replace(/\s+/g, " ").trim();
    const wantedNorm = norm(wanted);
    const folderNorm = folders.map(norm);

    let found = docs.find(d =>
        norm(d.x.text) === wantedNorm &&
        d.folders.length === folderNorm.length &&
        d.folders.every((v, i) => norm(v) === folderNorm[i])
    );
    if (!found) found = docs.find(d => norm(d.x.text) === wantedNorm);
    if (!found) return {ok: false};

    found.x.li.scrollIntoView({block: "center", inline: "center"});
    found.x.li.click();
    return {ok: true};
}
"""

# Aanwezigheidscontrole ("Kunt u bevestigen dat u ... bent?") in dialoog-containers.
JS_PRESENCE_OVERLAY = r"""
() => {
    const clean = s => (s || '').replace(/\s+/g, ' ').trim();
    const visible = el => {
        if (!el) return false;
        const r = el.getBoundingClientRect();
        const st = getComputedStyle(el);
        return r.width > 0 && r.height > 0 && st.visibility !== 'hidden' &&
               st.display !== 'none' && st.opacity !== '0';
    };
    const textOf = el => clean(el.innerText || el.textContent || '');
    const isPresence = t =>
        /kunt\s+u\s+bevestigen|veiligheidsredenen|seconden\s+afmelden|nog\s+aanwezig|aanwezig/i.test(t);
    const isYes = el => /^\s*(ja|yes)\s*$/i.test(textOf(el));

    const roots = Array.from(document.querySelectorAll(
        '[role="dialog"], mat-dialog-container, .mat-dialog-container, ' +
        '.cdk-overlay-pane, .cdk-dialog-container'
    )).filter(visible);

    for (const root of roots) {
        const text = textOf(root);
        if (!text || !isPresence(text)) continue;
        const yes = Array.from(root.querySelectorAll(
            'button, [role="button"], a, input[type="button"], input[type="submit"]'
        )).find(x => visible(x) && isYes(x));
        if (yes) { yes.click(); return true; }
    }
    return false;
}
"""

JS_BODY_TEXT = r"""
() => ((document.body && document.body.innerText) || '').replace(/\s+/g, ' ').trim().slice(0, 12000)
"""

# Beperkte browser-XHR-reserve: houdt hooguit één actuele Blob vast.
JS_XHR_FALLBACK_INSTALL = r"""
() => {
    if (window.__jow_single_pdf_xhr_installed) return "already";
    window.__jow_single_pdf_xhr_installed = true;
    window.__jow_single_pdf_xhr = null;
    const OriginalXHR = window.XMLHttpRequest;

    function WrappedXHR() {
        const xhr = new OriginalXHR();
        let requestUrl = "";
        const oldOpen = xhr.open;
        xhr.open = function(method, url) {
            requestUrl = String(url || "");
            return oldOpen.apply(this, arguments);
        };
        xhr.addEventListener("load", function() {
            try {
                const ct = xhr.getResponseHeader("content-type") || "";
                if (xhr.status !== 200 || xhr.responseType !== "blob") return;
                if (!(xhr.response instanceof Blob) || !xhr.response.size) return;
                if (!requestUrl.toLowerCase().includes("/download") &&
                    !ct.toLowerCase().includes("pdf") &&
                    !ct.toLowerCase().includes("octet-stream")) return;
                window.__jow_single_pdf_xhr = {
                    url: requestUrl, status: xhr.status, contentType: ct, blob: xhr.response
                };
            } catch (e) {}
        });
        return xhr;
    }
    window.XMLHttpRequest = WrappedXHR;
    return "installed";
}
"""

JS_XHR_FALLBACK_CLEAR = "() => { window.__jow_single_pdf_xhr = null; return true; }"

JS_XHR_FALLBACK_READ = r"""
async () => {
    const item = window.__jow_single_pdf_xhr;
    if (!item || !item.blob) return null;
    const buffer = await item.blob.arrayBuffer();
    const bytes = new Uint8Array(buffer);
    let binary = "";
    const CHUNK = 0x8000;
    for (let i = 0; i < bytes.length; i += CHUNK) {
        binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + CHUNK, bytes.length)));
    }
    window.__jow_single_pdf_xhr = null;
    return {url: item.url, status: item.status, contentType: item.contentType, base64: btoa(binary)};
}
"""


# =====================================================================
# BEWAKING: aanwezigheidscontrole en verlopen sessie
# =====================================================================

class Guard:
    """Bevestigt de aanwezigheidscontrole en detecteert een verlopen sessie.

    De zware controles (volledige paginatekst) worden getemporiseerd; bij een
    groot, volledig geopend dossier zijn die duur.
    """

    PRESENCE_RE = re.compile(
        r"kunt\s+u\s+bevestigen|veiligheidsredenen|seconden\s+afmelden|nog\s+aanwezig|aanwezig", re.I
    )
    SESSION_MARKERS = (
        "sessie is verlopen", "sessie verlopen", "session expired", "session has expired",
        "opnieuw aanmelden", "opnieuw inloggen", "please log in again", "please sign in again",
    )

    def __init__(self, page, ctx):
        self.page = page
        self.ctx = ctx
        self._t_body = 0.0
        self._t_session = 0.0
        self._lost = False

    def _confirmed(self, how):
        self.ctx.info("Aanwezigheidscontrole bevestigd (Ja)")
        self.ctx.tech(f"Aanwezigheidscontrole bevestigd via {how}")
        try:
            self.page.wait_for_timeout(500)
        except Exception:
            pass
        return True

    def presence(self):
        # 1. Goedkoop: alleen dialoog-containers (elke keer).
        try:
            if self.page.evaluate(JS_PRESENCE_OVERLAY):
                return self._confirmed("dialoog")
        except Exception:
            pass

        # 2. Playwright-route (werkt ook voor Shadow DOM), getemporiseerd.
        now = time.time()
        if now - self._t_body < 3.0:
            return False
        self._t_body = now
        try:
            body_text = self.page.locator("body").inner_text(timeout=1500)
            if not self.PRESENCE_RE.search(body_text or ""):
                return False
            for loc in (
                self.page.get_by_role("button", name=re.compile(r"^\s*ja\s*$", re.I)),
                self.page.get_by_text(re.compile(r"^\s*ja\s*$", re.I)),
            ):
                try:
                    for i in range(min(loc.count(), 10)):
                        item = loc.nth(i)
                        if item.is_visible(timeout=500):
                            item.click(timeout=2000)
                            return self._confirmed("playwright")
                except Exception:
                    continue
        except Exception:
            pass
        return False

    def session_lost(self):
        if self._lost:
            return True
        try:
            url = (self.page.url or "").lower()
            if any(x in url for x in ("login", "signin", "auth", "aanmelden")):
                self._lost = True
                return True
        except Exception:
            return False
        now = time.time()
        if now - self._t_session < 8.0:
            return False
        self._t_session = now
        try:
            text = (self.page.evaluate(JS_BODY_TEXT) or "").lower()
            if any(m in text for m in self.SESSION_MARKERS):
                self._lost = True
        except Exception:
            pass
        return self._lost


# =====================================================================
# DOSSIER ANALYSEREN
# =====================================================================

def expand_all(page, ctx, guard, max_seconds=3600):
    """Open alle gesloten mappen (ook lazy-loaded). Geeft (geopend, nog_gesloten) terug."""
    started = time.time()
    opened = 0
    failed = set()
    clicks = {}

    closed = page.evaluate(JS_SCAN_CLOSED)
    ctx.tech(f"Gesloten mappen bij start: {len(closed)}")

    while True:
        ctx.check_cancel()
        if time.time() - started >= max_seconds:
            ctx.warn(f"Tijdslimiet bereikt bij het openen van mappen ({max_seconds}s)")
            break
        guard.presence()

        closed = page.evaluate(JS_SCAN_CLOSED)
        if not closed:
            break

        node, key = None, None
        for cand in closed:
            k = (cand["label"], cand.get("ariaLevel"), cand.get("left"))
            if k not in failed:
                node, key = cand, k
                break
        if node is None:
            ctx.warn(f"{len(closed)} mappen konden niet geopend worden")
            break

        result = page.evaluate(JS_CLICK_FOLDER, {"wanted": node})
        if not result.get("ok"):
            failed.add(key)
            ctx.tech(f"Map niet klikbaar: {shorten(node['label'], 80)} ({result.get('reason')})")
            page.wait_for_timeout(300)
            continue

        page.wait_for_timeout(200)
        after = page.evaluate(JS_SCAN_CLOSED)
        still = any(
            x["label"] == node["label"]
            and (node.get("ariaLevel") is None or x.get("ariaLevel") == node.get("ariaLevel"))
            and abs((x.get("left") or 0) - (node.get("left") or 0)) <= 8
            for x in after
        )
        if not still:
            opened += 1
            failed.discard(key)
            clicks.pop(key, None)
        else:
            # Voorkom eindeloos herklikken van dezelfde map.
            clicks[key] = clicks.get(key, 0) + 1
            if clicks[key] >= 3:
                failed.add(key)
                ctx.tech(f"Map blijft gesloten na 3 pogingen: {shorten(node['label'], 80)}")

        ctx.phase(f"Mappen openen… {opened} geopend, nog {len(after)} gesloten  ({shorten(node['label'], 50)})")
        page.wait_for_timeout(400)

    remaining = page.evaluate(JS_SCAN_CLOSED)
    ctx.tech(f"Mappen geopend: {opened}; nog gesloten: {len(remaining)}; tijd: {human_time(time.time() - started)}")
    return opened, len(remaining)


def parse_tree(raw):
    """Zet de ruwe DOM-rijen om in documenten. Geeft (documenten, aantal_dubbels)."""
    documents, seen, duplicates = [], set(), 0
    for row in raw:
        if row["kind"] != "document":
            continue
        name = re.sub(r"\s+", " ", row["name"]).strip()
        # Alleen een naam die exact een icoontekst is, is een leesfout.
        if not name or name in ICON_TOKENS:
            continue
        folders = [re.sub(r"\s+", " ", f).strip() for f in row.get("folders", [])]
        key = tuple(folders) + (name,)
        if key in seen:
            duplicates += 1
            continue
        seen.add(key)
        documents.append({"name": name, "folders": folders})
    return documents, duplicates


def analyze(page, ctx, guard):
    ctx.info("Dossier analyseren: alle mappen worden geopend. Dit kan bij grote dossiers enkele minuten duren.")
    opened, remaining = expand_all(page, ctx, guard)
    ctx.check_cancel()
    page.wait_for_timeout(1800)

    ctx.phase("Dossierstructuur wordt gelezen…")
    t0 = time.time()
    raw = page.evaluate(JS_TREE)
    documents, duplicates = parse_tree(raw)
    ctx.tech(f"Boom gelezen in {time.time() - t0:.1f}s: {len(raw)} nodes, {len(documents)} documenten, {duplicates} dubbels")

    if not documents:
        raise UserError("Er werden geen documenten gevonden. Controleer of het dossier in JustConsult geopend is.")

    without_path = [d for d in documents if not d["folders"]]
    if without_path:
        raise UserError(
            "De dossierstructuur kon niet betrouwbaar worden uitgelezen "
            f"({len(without_path)} documenten zonder map). De download wordt niet gestart."
        )

    for d in documents[:10]:
        ctx.tech("  " + " > ".join(d["folders"] + [d["name"]]))

    try:
        status = page.evaluate(JS_TREE_STATUS)
    except Exception:
        status = {"first": ""}

    roots = {d["folders"][0] for d in documents}
    stats = {
        "documents": len(documents),
        "folders": len({tuple(d["folders"]) for d in documents}),
        "opened": opened,
        "remaining_closed": remaining,
        "duplicates": duplicates,
    }
    if remaining:
        ctx.warn(f"{remaining} mappen konden niet geopend worden. Documenten daarin ontbreken mogelijk.")
    if duplicates:
        ctx.warn(f"{duplicates} documenten met dezelfde naam in dezelfde map werden als één document gezien.")
    ctx.ok(f"Analyse klaar: {len(documents)} documenten in {stats['folders']} mappen")
    return {
        "documents": documents,
        "root": next(iter(roots)) if len(roots) == 1 else "",
        "page_root": (status or {}).get("first", ""),
        "stats": stats,
    }


# =====================================================================
# PDF'S ONDERSCHEPPEN (Chrome DevTools Protocol)
# =====================================================================

class PdfCapture:
    """Leest de echte download-response van JustConsult rechtstreeks uit Chrome."""

    def __init__(self, page, ctx):
        self.page = page
        self.ctx = ctx
        self.responses = []
        self.finished = set()
        self.activity = []
        self.session = page.context.new_cdp_session(page)
        self.session.on("Network.requestWillBeSent", self._on_request)
        self.session.on("Network.responseReceived", self._on_response)
        self.session.on("Network.loadingFinished", self._on_finished)
        try:
            self.session.send("Network.enable", {
                "maxResourceBufferSize": 64 * 1024 * 1024,
                "maxTotalBufferSize": 128 * 1024 * 1024,
            })
        except Exception:
            self.session.send("Network.enable")
        try:
            self.session.send("Network.setCacheDisabled", {"cacheDisabled": True})
        except Exception:
            pass
        try:
            self.page.evaluate(JS_XHR_FALLBACK_INSTALL)
        except Exception:
            pass

    def close(self):
        try:
            self.session.detach()
        except Exception:
            pass

    def clear(self):
        self.responses = []
        self.finished = set()
        self.activity = []
        try:
            self.page.evaluate(JS_XHR_FALLBACK_CLEAR)
        except Exception:
            pass

    # -- CDP-gebeurtenissen
    def _remember(self, entry):
        if len(self.activity) >= 60:
            self.activity.pop(0)
        self.activity.append(entry)

    def _on_request(self, params):
        try:
            req = params.get("request", {})
            url = str(req.get("url", ""))
            if "/api/" in url.lower() or "download" in url.lower():
                self._remember(f"REQ {req.get('method', '?')} {url}")
        except Exception:
            pass

    def _on_response(self, params):
        try:
            response = params.get("response", {})
            url = str(response.get("url", ""))
            mime = str(response.get("mimeType", ""))
            headers = {str(k).lower(): str(v).lower() for k, v in (response.get("headers") or {}).items()}
            disposition = headers.get("content-disposition", "")
            low = url.lower()
            if "/api/" in low or "download" in low or "pdf" in mime.lower():
                self._remember(f"RES {response.get('status', '?')} {mime} {url}")
            if (
                "/download" in low or "pdf" in mime.lower()
                or "application/octet-stream" in mime.lower() or "attachment" in disposition
            ):
                self.responses.append({
                    "requestId": params.get("requestId"), "url": url,
                    "status": response.get("status"), "contentType": mime,
                })
        except Exception:
            pass

    def _on_finished(self, params):
        try:
            rid = params.get("requestId")
            if rid:
                self.finished.add(rid)
        except Exception:
            pass

    # -- lezen
    def _read_cdp(self, item):
        body = self.session.send("Network.getResponseBody", {"requestId": item["requestId"]})
        raw = body.get("body", "")
        if body.get("base64Encoded"):
            return base64.b64decode(raw)
        return raw.encode("latin-1", "replace")

    def _read_browser_xhr(self):
        try:
            item = self.page.evaluate(JS_XHR_FALLBACK_READ)
        except Exception:
            return None
        if item and item.get("status") == 200 and item.get("base64"):
            return {
                "data": base64.b64decode(item["base64"]), "status": 200,
                "url": item.get("url"), "contentType": item.get("contentType"),
            }
        return None

    def wait(self, timeout, guard):
        """Wacht op de PDF. Geeft een dict met 'data' of met een foutsleutel."""
        deadline = time.time() + timeout
        while time.time() < deadline:
            self.ctx.check_cancel()
            guard.presence()
            if guard.session_lost():
                return {"session_lost": True}

            for item in list(self.responses):
                rid = item.get("requestId")
                if rid and rid not in self.finished:
                    continue
                try:
                    self.responses.remove(item)
                except ValueError:
                    pass
                status = item.get("status")
                if status == 200:
                    try:
                        return {"data": self._read_cdp(item), "status": 200,
                                "url": item.get("url"), "contentType": item.get("contentType")}
                    except Exception as exc:
                        fb = self._read_browser_xhr()
                        if fb:
                            return fb
                        return {"body_error": repr(exc)}
                return {"http_error": True, "status": status,
                        "contentType": item.get("contentType"), "url": item.get("url")}

            fb = self._read_browser_xhr()
            if fb:
                return fb
            self.page.wait_for_timeout(200)
        return {"network_timeout": True, "activity": list(self.activity)[-12:]}


# =====================================================================
# DOWNLOADEN
# =====================================================================

def doc_target(output_root, doc):
    rel = Path(*[clean_name(x) for x in doc["folders"]]) / (clean_name(doc["name"]) + ".pdf")
    return Path(output_root) / rel


def common_dossier_dir(output_root, docs):
    roots = {d["folders"][0] for d in docs if d["folders"]}
    if len(roots) == 1:
        return Path(output_root) / clean_name(next(iter(roots)))
    return Path(output_root)


def save_pdf(data, target):
    """Schrijf een PDF atomisch weg (eerst .part, dan hernoemen)."""
    if not data.startswith(b"%PDF"):
        raise RuntimeError(f"Response is geen PDF (begint met {data[:12]!r})")
    final = fs_path(target)
    os.makedirs(os.path.dirname(final), exist_ok=True)
    part = final + ".part"
    with open(part, "wb") as fh:
        fh.write(data)
    os.replace(part, final)
    return len(data)


def write_report(dossier_dir, rows, ctx):
    """Schrijf het csv-rapport (Excel-vriendelijk: puntkomma en UTF-8 met BOM)."""
    try:
        os.makedirs(fs_path(dossier_dir), exist_ok=True)
        path = Path(dossier_dir) / f"_rapport_{datetime.now():%Y%m%d_%H%M%S}.csv"
        with open(fs_path(path), "w", encoding="utf-8-sig", newline="") as fh:
            w = csv.writer(fh, delimiter=";")
            w.writerow(["Nr", "Map", "Document", "Status", "Grootte (bytes)", "Pogingen", "Opmerking", "Tijdstip"])
            for r in rows:
                w.writerow([r["nr"], " > ".join(r["folders"]), r["name"], r["status"],
                            r.get("bytes", ""), r.get("attempts", ""), r.get("error", ""), r.get("time", "")])
        return path
    except Exception as exc:
        ctx.warn(f"Het rapport kon niet worden opgeslagen: {exc}")
        return None


def download_documents(page, docs, output_root, ctx, guard, bring_to_front=False):
    total = len(docs)
    rows = []
    failed = []
    success = skipped = 0
    total_bytes = 0
    interrupted = None
    started = time.time()
    capture = PdfCapture(page, ctx)
    ctx.emit("download_started", {"total": total})
    ctx.info(f"Download gestart: {total} documenten")

    def row(nr, doc, status, **extra):
        rows.append({"nr": nr, "folders": doc["folders"], "name": doc["name"], "status": status,
                     "time": f"{datetime.now():%Y-%m-%d %H:%M:%S}", **extra})

    try:
        for i, doc in enumerate(docs, 1):
            ctx.check_cancel()
            ctx.emit("doc_start", {"i": i, "total": total, "name": doc["name"], "folder": " > ".join(doc["folders"])})
            guard.presence()
            if guard.session_lost():
                interrupted = "session"
                break

            target = doc_target(output_root, doc)
            if valid_pdf_file(target):
                skipped += 1
                row(i, doc, "Overgeslagen (bestond al)", bytes=os.path.getsize(fs_path(target)), attempts=0)
                ctx.emit("doc_done", {"i": i, "total": total, "status": "skipped", "success": success,
                                      "skipped": skipped, "failed": len(failed), "bytes": total_bytes})
                continue

            got, last_error, attempts = False, "", 0
            t_doc = time.time()
            for attempt in range(1, MAX_ATTEMPTS + 1):
                attempts = attempt
                ctx.check_cancel()
                if attempt > 1:
                    ctx.warn(f"Herpoging {attempt}/{MAX_ATTEMPTS}: {shorten(doc['name'], 60)}")
                    page.wait_for_timeout((attempt - 1) * 1000)
                if bring_to_front:
                    try:
                        page.bring_to_front()
                    except Exception:
                        pass
                guard.presence()
                if guard.session_lost():
                    interrupted = "session"
                    break

                capture.clear()
                t_sel = time.time()
                if attempt == 1:
                    result = page.evaluate(JS_CLICK_DOC, {"wanted": doc["name"], "folders": doc["folders"]})
                    selected = bool(result.get("ok"))
                    sel_error = result.get("reason", "document niet gevonden in de boom")
                else:
                    result = page.evaluate(JS_CLICK_DOC_LEGACY, {"wanted": doc["name"], "folders": doc["folders"]})
                    selected = bool(result and result.get("ok"))
                    sel_error = "document niet gevonden (reserve-selectie)"
                sel_time = time.time() - t_sel
                if sel_time > 3:
                    ctx.tech(f"Documentselectie duurde {sel_time:.1f}s: {shorten(doc['name'], 60)}")
                if not selected:
                    last_error = sel_error
                    ctx.tech(f"Selectie mislukt: {last_error}")
                    continue

                page.wait_for_timeout(AFTER_CLICK_DELAY_MS)
                guard.presence()
                event = capture.wait(XHR_TIMEOUT, guard)

                if event.get("session_lost"):
                    interrupted = "session"
                    break
                if event.get("network_timeout"):
                    last_error = f"geen PDF-response binnen {XHR_TIMEOUT}s"
                    ctx.warn(f"Geen PDF-response binnen {XHR_TIMEOUT}s: {shorten(doc['name'], 60)}")
                    ctx.tech("Netwerkactiviteit: " + repr(event.get("activity")))
                    continue
                if event.get("http_error"):
                    last_error = f"HTTP {event.get('status')} ({event.get('contentType') or 'onbekend type'})"
                    continue
                if event.get("body_error"):
                    last_error = "response kon niet gelezen worden: " + event["body_error"]
                    continue
                try:
                    size = save_pdf(event["data"], target)
                    total_bytes += size
                    success += 1
                    got = True
                    row(i, doc, "Gedownload", bytes=size, attempts=attempts)
                    ctx.tech(f"OK {i}/{total} {size} bytes in {time.time() - t_doc:.1f}s: {shorten(doc['name'], 60)}")
                    break
                except Exception as exc:
                    last_error = f"opslaan mislukt: {exc}"

            if interrupted:
                break
            if not got:
                failed.append({"doc": doc, "error": last_error})
                row(i, doc, "Mislukt", error=last_error, attempts=attempts)
                ctx.warn(f"Mislukt: {shorten(doc['name'], 70)} ({last_error})")
            ctx.emit("doc_done", {"i": i, "total": total, "status": "ok" if got else "failed",
                                  "success": success, "skipped": skipped, "failed": len(failed),
                                  "bytes": total_bytes})

            if success and success % 50 == 0 and got:
                ctx.tech(f"{success} documenten gedownload na {human_time(time.time() - started)}")
                page.wait_for_timeout(2000)
            else:
                page.wait_for_timeout(200)
    except Cancelled:
        interrupted = "cancelled"
    finally:
        capture.close()

    # Wat niet aan bod kwam, staat als "Niet verwerkt" in het rapport.
    handled = {r["nr"] for r in rows}
    for i, doc in enumerate(docs, 1):
        if i not in handled:
            row(i, doc, "Niet verwerkt", error="download onderbroken")

    rows.sort(key=lambda r: r["nr"])
    dossier_dir = common_dossier_dir(output_root, docs)
    report = write_report(dossier_dir, rows, ctx)

    processed = success + skipped
    return {
        "total": total, "success": success, "skipped": skipped, "failed": failed,
        "bytes": total_bytes, "interrupted": interrupted, "report": str(report) if report else "",
        "dossier_dir": str(dossier_dir), "duration": time.time() - started,
        "complete": (processed == total and not failed),
    }


# =====================================================================
# ENGINE-THREAD (bezit Playwright; de GUI praat alleen via wachtrijen)
# =====================================================================

def friendly_error(exc):
    if isinstance(exc, UserError):
        return str(exc)
    text = str(exc)
    if "ECONNREFUSED" in text or "connect_over_cdp" in text or "ERR_CONNECTION" in text:
        return "Chrome is niet bereikbaar. Klik op 'Chrome openen' en probeer opnieuw."
    if "Target closed" in text or "has been closed" in text:
        return "Het Chrome-tabblad werd gesloten. Open JustConsult opnieuw en probeer opnieuw."
    return f"Onverwachte fout: {text}"


class Engine(threading.Thread):
    def __init__(self, events, ctx):
        super().__init__(daemon=True, name="engine")
        self.events = events
        self.ctx = ctx
        self.cmds = queue.Queue()
        self.pw = None
        self.browser = None
        self.busy = False
        self._last_status = None
        self._last_poll_error = ""

    def send(self, *cmd):
        self.cmds.put(cmd)

    # -- verbinding
    def _connect(self):
        if self.browser is not None:
            try:
                if self.browser.is_connected():
                    return self.browser
            except Exception:
                pass
            self.browser = None
        errors = []
        for base, _info in reachable_cdp_urls(1.5):
            try:
                self.browser = self.pw.chromium.connect_over_cdp(base, timeout=8000)
                self.ctx.tech("Verbonden met Chrome via " + base)
                return self.browser
            except Exception as exc:
                errors.append(f"{base}: {exc!r}")
        raise RuntimeError("Geen verbinding met Chrome mogelijk: " + " | ".join(errors or ["geen antwoord"]))

    def _just_pages(self):
        pages = []
        for context in self.browser.contexts:
            for p in context.pages:
                if JUST_HOST in (p.url or ""):
                    pages.append(p)
        return pages

    def _best_page(self):
        """Voorkeur: het JustConsult-tabblad waarin een dossier open staat."""
        pages = self._just_pages()
        best = None
        for p in pages:
            try:
                st = p.evaluate(JS_TREE_STATUS)
            except Exception:
                st = {"count": 0, "first": ""}
            if st.get("count"):
                return p, st
            if best is None:
                best = (p, st)
        return best if best else (None, None)

    def _poll(self):
        status = {"chrome": False, "tab": False, "dossier": False, "root": "", "nodes": 0}
        try:
            if debug_port_info(1.0):
                status["chrome"] = True
                self._connect()
                page, st = self._best_page()
                if page is not None:
                    status["tab"] = True
                    status["nodes"] = int((st or {}).get("count", 0))
                    status["root"] = (st or {}).get("first", "")
                    status["dossier"] = status["nodes"] > 0
            else:
                self.browser = None
        except Exception as exc:
            self.browser = None
            msg = f"Statuscontrole: {exc!r}"
            if msg != self._last_poll_error:
                self._last_poll_error = msg
                self.ctx.tech(msg)
        if status != self._last_status:
            self._last_status = status
            self.events.put(("status", status))

    def _require_page(self):
        if not debug_port_info(1.5):
            raise UserError("Chrome staat niet open in debug-modus. Klik op 'Chrome openen'.")
        self._connect()
        page, st = self._best_page()
        if page is None:
            raise UserError("Er is geen JustConsult-tabblad gevonden. Meld u aan en open JustConsult in het geopende Chrome-venster.")
        if not (st or {}).get("count"):
            raise UserError("Er is nog geen dossier geopend in JustConsult. Open eerst het gewenste dossier.")
        return page

    # -- opdrachten
    def _do_analyze(self):
        page = self._require_page()
        guard = Guard(page, self.ctx)
        try:
            result = analyze(page, self.ctx, guard)
            self.events.put(("analysis", result))
        except Cancelled:
            self.ctx.warn("Analyse gestopt")
            self.events.put(("analysis_cancelled",))

    def _do_download(self, docs, output_root, bring_to_front):
        page = self._require_page()
        guard = Guard(page, self.ctx)
        summary = download_documents(page, docs, output_root, self.ctx, guard, bring_to_front)
        self.events.put(("download_done", summary))

    def _handle(self, cmd):
        self.busy = True
        try:
            if cmd[0] == "analyze":
                self._do_analyze()
            elif cmd[0] == "download":
                self._do_download(cmd[1], cmd[2], cmd[3])
        except Exception as exc:
            self.ctx.tech(traceback.format_exc())
            msg = friendly_error(exc)
            self.ctx.error(msg)
            self.events.put(("command_failed", cmd[0], msg))
        finally:
            self.busy = False
            self._last_status = None

    def run(self):
        if sync_playwright is None:
            self.events.put(("fatal", "Playwright is niet geïnstalleerd."))
            return
        try:
            with sync_playwright() as pw:
                self.pw = pw
                while True:
                    try:
                        cmd = self.cmds.get(timeout=STATUS_POLL_SECONDS)
                    except queue.Empty:
                        self._poll()
                        continue
                    if cmd[0] == "quit":
                        break
                    self._handle(cmd)
        except Exception:
            self.events.put(("fatal", traceback.format_exc()))


# =====================================================================
# GUI: MAPPENBOOM MET KEUZEVAKJES
# =====================================================================

class FolderTree(ttk.Frame):
    BOX = {0: "☐", 1: "☑", 2: "◪"}

    def __init__(self, parent, on_change):
        super().__init__(parent)
        self.on_change = on_change
        self.documents = []
        self.nodes = {}
        self.order = []
        self.doc_node = []

        self.tv = ttk.Treeview(self, columns=("n",), show="tree", selectmode="browse")
        self.tv.column("#0", width=520, stretch=True)
        self.tv.column("n", width=130, anchor="e", stretch=False)
        vsb = ttk.Scrollbar(self, orient="vertical", command=self.tv.yview)
        self.tv.configure(yscrollcommand=vsb.set)
        self.tv.pack(side="left", fill="both", expand=True)
        vsb.pack(side="right", fill="y")
        self.tv.bind("<Button-1>", self._click)
        self.tv.bind("<space>", self._space)

        self.placeholder = ttk.Label(self.tv, text="Nog geen dossier geanalyseerd.", foreground="#6b6b6b")
        self.placeholder.place(relx=0.5, rely=0.5, anchor="center")

    def clear(self):
        self.tv.delete(*self.tv.get_children())
        self.documents, self.nodes, self.order, self.doc_node = [], {}, [], []
        self.placeholder.place(relx=0.5, rely=0.5, anchor="center")
        self.on_change()

    def load(self, documents):
        self.tv.delete(*self.tv.get_children())
        self.documents = documents
        self.nodes, self.order, self.doc_node = {}, [], []
        by_path = {}
        for doc in documents:
            path = tuple(doc["folders"])
            for depth in range(1, len(path) + 1):
                sub = path[:depth]
                if sub not in by_path:
                    parent = by_path.get(sub[:-1], "")
                    iid = self.tv.insert(parent, "end", text=sub[-1], open=(depth <= 2))
                    by_path[sub] = iid
                    self.nodes[iid] = {"name": sub[-1], "parent": parent, "children": [], "own": 0,
                                       "on": True, "total": 0, "sel": 0}
                    self.order.append(iid)
                    if parent:
                        self.nodes[parent]["children"].append(iid)
            iid = by_path[path]
            self.nodes[iid]["own"] += 1
            self.doc_node.append(iid)
        self.placeholder.place_forget()
        self._refresh()

    def _refresh(self):
        for iid in reversed(self.order):          # kinderen vóór ouders
            n = self.nodes[iid]
            n["total"] = n["own"] + sum(self.nodes[c]["total"] for c in n["children"])
            n["sel"] = (n["own"] if n["on"] else 0) + sum(self.nodes[c]["sel"] for c in n["children"])
        for iid in self.order:
            n = self.nodes[iid]
            state = 1 if n["sel"] == n["total"] else (0 if n["sel"] == 0 else 2)
            self.tv.item(iid, text=f"{self.BOX[state]}  {n['name']}")
            self.tv.set(iid, "n", f"{n['sel']} / {n['total']}" if state == 2 else f"{n['total']}")
        self.on_change()

    def _set_subtree(self, iid, value):
        stack = [iid]
        while stack:
            cur = stack.pop()
            self.nodes[cur]["on"] = value
            stack.extend(self.nodes[cur]["children"])

    def toggle(self, iid):
        n = self.nodes[iid]
        self._set_subtree(iid, n["sel"] != n["total"])
        self._refresh()

    def _click(self, event):
        if self.tv.identify("element", event.x, event.y) == "Treeitem.indicator":
            return None
        iid = self.tv.identify_row(event.y)
        if iid:
            self.tv.selection_set(iid)
            self.tv.focus(iid)
            self.toggle(iid)
            return "break"
        return None

    def _space(self, _event):
        iid = self.tv.focus()
        if iid:
            self.toggle(iid)
        return "break"

    def select_all(self, value):
        for iid in self.order:
            self.nodes[iid]["on"] = value
        self._refresh()

    def expand_all(self, value):
        for iid in self.order:
            self.tv.item(iid, open=value)

    def selected_documents(self):
        return [d for d, iid in zip(self.documents, self.doc_node) if self.nodes[iid]["on"]]

    def counts(self):
        selected = sum(1 for iid in self.doc_node if self.nodes[iid]["on"])
        return selected, len(self.documents)


# =====================================================================
# GUI: HOOFDVENSTER
# =====================================================================

COLOR_OK = "#1a7f37"
COLOR_WAIT = "#8a8a8a"
COLOR_WARN = "#b45309"
COLOR_ERR = "#c62828"
COLOR_DIM = "#5f6368"


class App:
    def __init__(self, root):
        self.root = root
        root.title(f"{APP_NAME} V{VERSION}")
        root.geometry("1040x860")
        root.minsize(900, 700)
        root.protocol("WM_DELETE_WINDOW", self.close)

        self.settings = load_settings()
        self.events = queue.Queue()
        self.ctx = Reporter(self.events)
        self.engine = Engine(self.events, self.ctx)

        self.status = {"chrome": False, "tab": False, "dossier": False, "root": "", "nodes": 0}
        self.analysis = None
        self.busy = None                 # None | "analyze" | "download"
        self.failed_docs = []
        self.last_summary = None
        self.chrome_launching_until = 0.0
        self.chrome_launched = False
        self.t_busy_start = 0.0
        self.t_last_activity = time.time()
        self.t_doc_start = None
        self.current = {"i": 0, "total": 0, "name": "", "folder": ""}
        self.counts = {"success": 0, "skipped": 0, "failed": 0, "bytes": 0}
        self.new_downloads = 0

        self.output_var = tk.StringVar(value=self.settings.get("output_dir", str(DEFAULT_OUTPUT)))
        self.front_var = tk.BooleanVar(value=False)
        self.tech_open = False

        self._style()
        self._build()
        self._refresh_ui()
        self.engine.start()
        root.after(80, self._poll_events)
        root.after(1000, self._tick)

    # ------------------------------------------------------------ opbouw
    def _style(self):
        style = ttk.Style()
        try:
            style.theme_use("vista" if "vista" in style.theme_names() else "clam")
        except tk.TclError:
            pass
        import tkinter.font as tkfont
        for name in ("TkDefaultFont", "TkTextFont", "TkMenuFont", "TkHeadingFont"):
            try:
                tkfont.nametofont(name).configure(family="Segoe UI", size=10)
            except tk.TclError:
                pass
        style.configure("Title.TLabel", font=("Segoe UI", 20, "bold"))
        style.configure("Sub.TLabel", foreground=COLOR_DIM)
        style.configure("Card.TLabelframe", padding=12)
        style.configure("Card.TLabelframe.Label", font=("Segoe UI", 10, "bold"))
        style.configure("Big.TButton", font=("Segoe UI", 11, "bold"), padding=(16, 8))
        style.configure("Stat.TLabel", font=("Segoe UI", 12, "bold"))
        style.configure("Treeview", rowheight=24)

    def _build(self):
        outer = ttk.Frame(self.root, padding=(18, 14, 18, 10))
        outer.pack(fill="both", expand=True)
        outer.columnconfigure(0, weight=1)
        outer.rowconfigure(2, weight=1)

        head = ttk.Frame(outer)
        head.grid(row=0, column=0, sticky="ew", pady=(0, 10))
        ttk.Label(head, text=APP_NAME, style="Title.TLabel").pack(side="left")
        ttk.Label(head, text=f"  Just-on-Web → PDF → originele dossierstructuur   ·   V{VERSION}",
                  style="Sub.TLabel").pack(side="left", pady=(10, 0))
        self.stop_btn = ttk.Button(head, text="■  Stop", style="Big.TButton", command=self.stop)
        self.stop_btn.pack(side="right")

        # ---- Stap 1: verbinding
        card = ttk.LabelFrame(outer, text="  1 · Verbinding met Just-on-Web  ", style="Card.TLabelframe")
        card.grid(row=1, column=0, sticky="ew", pady=(0, 10))
        card.columnconfigure(1, weight=1)
        self.step_icon, self.step_title, self.step_hint = [], [], []
        titles = ["Chrome staat open (debug-modus)", "JustConsult is geopend", "Dossier is geopend"]
        for i, t in enumerate(titles):
            icon = ttk.Label(card, text="○", font=("Segoe UI", 14, "bold"), foreground=COLOR_WAIT, width=2)
            icon.grid(row=i, column=0, sticky="w")
            title = ttk.Label(card, text=t, font=("Segoe UI", 10, "bold"))
            title.grid(row=i, column=1, sticky="w")
            hint = ttk.Label(card, text="", style="Sub.TLabel")
            hint.grid(row=i, column=2, sticky="w", padx=(12, 0))
            self.step_icon.append(icon)
            self.step_title.append(title)
            self.step_hint.append(hint)
        self.chrome_btn = ttk.Button(card, text="Chrome openen", style="Big.TButton", command=self.open_chrome)
        self.chrome_btn.grid(row=0, column=3, rowspan=3, sticky="e", padx=(20, 0))
        card.columnconfigure(2, weight=1)

        # ---- Tabs
        self.nb = ttk.Notebook(outer)
        self.nb.grid(row=2, column=0, sticky="nsew")
        self._build_tab_dossier()
        self._build_tab_download()

        # ---- Technische details
        tech_bar = ttk.Frame(outer)
        tech_bar.grid(row=3, column=0, sticky="ew", pady=(8, 0))
        self.tech_btn = ttk.Button(tech_bar, text="▸ Technische details", command=self.toggle_tech)
        self.tech_btn.pack(side="left")
        ttk.Button(tech_bar, text="Logmap openen", command=self.open_logs).pack(side="left", padx=8)
        self.tech_frame = ttk.Frame(outer)
        self.tech_text = tk.Text(self.tech_frame, height=9, wrap="word", state="disabled",
                                 font=("Consolas", 9), relief="flat", background="#f6f6f6")
        sb = ttk.Scrollbar(self.tech_frame, orient="vertical", command=self.tech_text.yview)
        self.tech_text.configure(yscrollcommand=sb.set)
        self.tech_text.pack(side="left", fill="both", expand=True)
        sb.pack(side="right", fill="y")
        outer.rowconfigure(4, weight=0)

        foot = ttk.Frame(outer)
        foot.grid(row=5, column=0, sticky="ew", pady=(8, 0))
        ttk.Label(foot, text="Lokale verwerking · geen wachtwoorden opgeslagen · Chrome blijft open",
                  style="Sub.TLabel").pack(side="left")
        ttk.Label(foot, text="Nelson Vanlocke©", style="Sub.TLabel").pack(side="right")

    def _build_tab_dossier(self):
        tab = ttk.Frame(self.nb, padding=12)
        self.nb.add(tab, text="  2 · Dossier kiezen  ")
        tab.columnconfigure(0, weight=1)
        tab.rowconfigure(1, weight=1)

        bar = ttk.Frame(tab)
        bar.grid(row=0, column=0, sticky="ew", pady=(0, 8))
        self.analyze_btn = ttk.Button(bar, text="🔍  Dossier analyseren", style="Big.TButton", command=self.analyze)
        self.analyze_btn.pack(side="left")
        self.analyze_info = ttk.Label(bar, text="Open eerst een dossier in JustConsult.", style="Sub.TLabel")
        self.analyze_info.pack(side="left", padx=14)
        self.analyze_progress = ttk.Progressbar(bar, mode="indeterminate", length=160)

        self.tree = FolderTree(tab, on_change=self._on_tree_change)
        self.tree.grid(row=1, column=0, sticky="nsew")

        tools = ttk.Frame(tab)
        tools.grid(row=2, column=0, sticky="ew", pady=(8, 0))
        self.tree_btns = [
            ttk.Button(tools, text="Alles selecteren", command=lambda: self.tree.select_all(True)),
            ttk.Button(tools, text="Niets selecteren", command=lambda: self.tree.select_all(False)),
            ttk.Button(tools, text="Alles uitklappen", command=lambda: self.tree.expand_all(True)),
            ttk.Button(tools, text="Alles inklappen", command=lambda: self.tree.expand_all(False)),
        ]
        for b in self.tree_btns:
            b.pack(side="left", padx=(0, 6))
        self.selection_label = ttk.Label(tools, text="", style="Stat.TLabel")
        self.selection_label.pack(side="right")

    def _build_tab_download(self):
        tab = ttk.Frame(self.nb, padding=12)
        self.nb.add(tab, text="  3 · Downloaden  ")
        tab.columnconfigure(0, weight=1)

        out = ttk.Frame(tab)
        out.grid(row=0, column=0, sticky="ew")
        out.columnconfigure(1, weight=1)
        ttk.Label(out, text="Opslagmap:").grid(row=0, column=0, sticky="w")
        self.output_entry = ttk.Entry(out, textvariable=self.output_var)
        self.output_entry.grid(row=0, column=1, sticky="ew", padx=8)
        self.browse_btn = ttk.Button(out, text="Bladeren…", command=self.choose_folder)
        self.browse_btn.grid(row=0, column=2)
        ttk.Button(out, text="Open map", command=self.open_output).grid(row=0, column=3, padx=(6, 0))
        ttk.Label(out, text="Elk dossier komt in een eigen submap. Bestaande PDF's worden overgeslagen.",
                  style="Sub.TLabel").grid(row=1, column=1, sticky="w", padx=8, pady=(2, 0))

        btns = ttk.Frame(tab)
        btns.grid(row=1, column=0, sticky="ew", pady=(12, 8))
        self.start_btn = ttk.Button(btns, text="▶  Download starten", style="Big.TButton", command=self.start_download)
        self.start_btn.pack(side="left")
        self.front_chk = ttk.Checkbutton(btns, text="Chrome op de voorgrond houden (enkel als downloads haperen)",
                                         variable=self.front_var)
        self.front_chk.pack(side="left", padx=14)

        prog = ttk.LabelFrame(tab, text="  Voortgang  ", style="Card.TLabelframe")
        prog.grid(row=2, column=0, sticky="ew")
        prog.columnconfigure(0, weight=1)
        self.status_var = tk.StringVar(value="Nog niet gestart")
        self.detail_var = tk.StringVar(value="")
        self.folder_var = tk.StringVar(value="")
        self.count_var = tk.StringVar(value="")
        self.idle_var = tk.StringVar(value="")
        ttk.Label(prog, textvariable=self.status_var, style="Stat.TLabel").grid(row=0, column=0, sticky="w")
        ttk.Label(prog, textvariable=self.detail_var).grid(row=1, column=0, sticky="w")
        ttk.Label(prog, textvariable=self.folder_var, style="Sub.TLabel").grid(row=2, column=0, sticky="w")
        self.progress = ttk.Progressbar(prog, mode="determinate", maximum=100)
        self.progress.grid(row=3, column=0, sticky="ew", pady=(8, 4))
        ttk.Label(prog, textvariable=self.count_var).grid(row=4, column=0, sticky="w")
        self.idle_label = ttk.Label(prog, textvariable=self.idle_var, foreground=COLOR_WARN)
        self.idle_label.grid(row=5, column=0, sticky="w")

        # Resultaat (verschijnt na een download)
        self.result_frame = ttk.LabelFrame(tab, text="  Resultaat  ", style="Card.TLabelframe")
        self.result_frame.grid(row=3, column=0, sticky="ew", pady=(10, 0))
        self.result_frame.columnconfigure(0, weight=1)
        self.result_title = ttk.Label(self.result_frame, text="", style="Stat.TLabel")
        self.result_title.grid(row=0, column=0, sticky="w")
        self.result_detail = ttk.Label(self.result_frame, text="", justify="left")
        self.result_detail.grid(row=1, column=0, sticky="w", pady=(2, 6))
        self.failed_tv = ttk.Treeview(self.result_frame, columns=("map", "reden"), show="tree headings", height=5)
        self.failed_tv.heading("#0", text="Mislukt document")
        self.failed_tv.heading("map", text="Map")
        self.failed_tv.heading("reden", text="Reden")
        self.failed_tv.column("#0", width=300)
        self.failed_tv.column("map", width=300)
        self.failed_tv.column("reden", width=200)
        self.failed_tv.grid(row=2, column=0, sticky="ew")
        rb = ttk.Frame(self.result_frame)
        rb.grid(row=3, column=0, sticky="ew", pady=(8, 0))
        self.retry_btn = ttk.Button(rb, text="↻  Mislukte opnieuw proberen", command=self.retry_failed)
        self.retry_btn.pack(side="left")
        self.report_btn = ttk.Button(rb, text="Rapport openen (csv)", command=self.open_report)
        self.report_btn.pack(side="left", padx=8)
        ttk.Button(rb, text="Dossiermap openen", command=self.open_dossier_dir).pack(side="left")
        self.result_frame.grid_remove()

        # Verloop (eigen tabblad, zodat het resultaat altijd ruimte heeft)
        act = ttk.Frame(self.nb, padding=12)
        self.nb.add(act, text="  Verloop  ")
        act.columnconfigure(0, weight=1)
        act.rowconfigure(0, weight=1)
        self.act_text = tk.Text(act, height=6, wrap="word", state="disabled", relief="flat",
                                font=("Segoe UI", 9), background="#fafafa")
        sb = ttk.Scrollbar(act, orient="vertical", command=self.act_text.yview)
        self.act_text.configure(yscrollcommand=sb.set)
        self.act_text.grid(row=0, column=0, sticky="nsew")
        sb.grid(row=0, column=1, sticky="ns")
        for tag, colour in (("ok", COLOR_OK), ("warn", COLOR_WARN), ("error", COLOR_ERR), ("info", "#202124")):
            self.act_text.tag_configure(tag, foreground=colour)

    # ------------------------------------------------------------ toestand
    def _selected_docs(self):
        return self.tree.selected_documents() if self.analysis else []

    def _refresh_ui(self):
        st = self.status
        if time.time() < self.chrome_launching_until:
            chrome_alt = "Chrome wordt gestart…"
        elif self.chrome_launched:
            chrome_alt = ("Chrome antwoordt niet. Sluit het Chrome-venster dat dit programma opende "
                          "en klik opnieuw op 'Chrome openen'.")
        else:
            chrome_alt = None
        steps = [
            (st["chrome"], "Klik op 'Chrome openen'.", chrome_alt),
            (st["tab"], "Meld u aan (itsme, eID of MyGov) en open JustConsult in het Chrome-venster.", None),
            (st["dossier"], "Open in JustConsult het dossier dat u wilt downloaden.", None),
        ]
        first_pending = True
        for i, (ok, hint, alt) in enumerate(steps):
            if ok:
                self.step_icon[i].configure(text="✓", foreground=COLOR_OK)
                extra = ""
                if i == 2 and st.get("root"):
                    extra = shorten(st["root"], 60)
                self.step_hint[i].configure(text=extra, foreground=COLOR_DIM)
            else:
                self.step_icon[i].configure(text="●" if first_pending else "○",
                                            foreground=COLOR_WARN if first_pending else COLOR_WAIT)
                self.step_hint[i].configure(text=(alt or hint) if first_pending else "",
                                           foreground=COLOR_WARN)
                first_pending = False

        busy = self.busy is not None
        self.chrome_btn.configure(
            text="Just-on-Web openen" if st["chrome"] else "Chrome openen",
            state="disabled" if busy else "normal")
        self.analyze_btn.configure(state="normal" if (st["dossier"] and not busy) else "disabled")
        n_sel = len(self._selected_docs())
        self.start_btn.configure(state="normal" if (self.analysis and n_sel and not busy
                                                    and st["dossier"]) else "disabled")
        self.stop_btn.configure(state="normal" if busy else "disabled")
        self.output_entry.configure(state="disabled" if busy else "normal")
        self.browse_btn.configure(state="disabled" if busy else "normal")
        self.retry_btn.configure(state="normal" if (self.failed_docs and not busy and st["dossier"]) else "disabled")
        for b in self.tree_btns:
            b.configure(state="disabled" if busy else "normal")

    def _on_tree_change(self):
        sel, total = self.tree.counts()
        self.selection_label.configure(text=f"{sel} van {total} documenten geselecteerd" if total else "")
        if hasattr(self, "start_btn"):
            self._refresh_ui()

    # ------------------------------------------------------------ acties
    def open_chrome(self):
        found = find_browser()
        if not found:
            messagebox.showerror(
                APP_NAME,
                "Google Chrome werd niet gevonden op deze computer.\n\n"
                "Installeer Chrome via google.com/chrome en probeer opnieuw.",
                parent=self.root)
            return
        name, exe = found
        try:
            launch_browser(exe)
        except Exception as exc:
            messagebox.showerror(APP_NAME, f"{name} kon niet gestart worden:\n{exc}", parent=self.root)
            return
        self.chrome_launching_until = time.time() + 20
        self.chrome_launched = True
        self.ctx.tech(f"{name} gestart: {exe}")
        self._log("info", f"{name} wordt gestart. Meld u aan in het nieuwe venster en open het dossier.")
        self._refresh_ui()

    def analyze(self):
        if self.busy:
            return
        self.ctx.cancel.clear()
        self.busy = "analyze"
        self.t_busy_start = time.time()
        self.t_last_activity = time.time()
        self.analysis = None
        self.tree.clear()
        self.analyze_info.configure(text="Bezig met analyseren…", foreground=COLOR_DIM)
        self.analyze_progress.pack(side="right", fill="x", expand=True, padx=(14, 0))
        self.analyze_progress.start(12)
        self._refresh_ui()
        self.engine.send("analyze")

    def choose_folder(self):
        folder = filedialog.askdirectory(title="Kies de opslagmap", parent=self.root)
        if folder:
            self.output_var.set(folder)
            self._save_output()

    def _save_output(self):
        self.settings["output_dir"] = self.output_var.get()
        save_settings(self.settings)

    def _output_root(self):
        text = self.output_var.get().strip()
        if not text:
            raise ValueError("Kies eerst een opslagmap.")
        path = Path(text).expanduser()
        path.mkdir(parents=True, exist_ok=True)
        return path

    def start_download(self, docs=None):
        if self.busy:
            return
        docs = docs if docs is not None else self._selected_docs()
        if not docs:
            messagebox.showinfo(APP_NAME, "Er zijn geen documenten geselecteerd.", parent=self.root)
            return
        try:
            out = self._output_root()
        except Exception as exc:
            messagebox.showerror(APP_NAME, f"Ongeldige opslagmap:\n{exc}", parent=self.root)
            return
        self._save_output()
        self.ctx.cancel.clear()
        self.busy = "download"
        self.t_busy_start = time.time()
        self.t_last_activity = time.time()
        self.t_doc_start = None
        self.counts = {"success": 0, "skipped": 0, "failed": 0, "bytes": 0}
        self.new_downloads = 0
        self.failed_docs = []
        self.result_frame.grid_remove()
        self.progress.configure(mode="determinate", value=0)
        self.status_var.set("Download gestart…")
        self.detail_var.set("")
        self.folder_var.set("")
        self.count_var.set("")
        self.idle_var.set("")
        self.nb.select(1)
        self._refresh_ui()
        self.engine.send("download", docs, str(out), self.front_var.get())

    def retry_failed(self):
        if self.failed_docs:
            self.start_download([f["doc"] for f in self.failed_docs])

    def stop(self):
        if self.busy:
            self.ctx.cancel.set()
            self.stop_btn.configure(state="disabled")
            self.status_var.set("Stoppen…")
            self._log("warn", "Stop gevraagd: het huidige document wordt nog afgewerkt.")

    def open_output(self):
        try:
            path = self._output_root()
        except Exception as exc:
            messagebox.showerror(APP_NAME, str(exc), parent=self.root)
            return
        open_path(path)

    def open_report(self):
        if self.last_summary and self.last_summary.get("report"):
            open_path(self.last_summary["report"])

    def open_dossier_dir(self):
        if self.last_summary:
            open_path(self.last_summary["dossier_dir"])

    def open_logs(self):
        LOG_DIR.mkdir(parents=True, exist_ok=True)
        open_path(LOG_DIR)

    def toggle_tech(self):
        self.tech_open = not self.tech_open
        if self.tech_open:
            self.tech_frame.grid(row=4, column=0, sticky="ew", pady=(4, 0))
            self.tech_btn.configure(text="▾ Technische details")
        else:
            self.tech_frame.grid_remove()
            self.tech_btn.configure(text="▸ Technische details")

    # ------------------------------------------------------------ logs
    def _log(self, level, text):
        self.act_text.configure(state="normal")
        self.act_text.insert("end", f"{datetime.now():%H:%M:%S}  {text}\n", level)
        self.act_text.see("end")
        self.act_text.configure(state="disabled")

    def _tech(self, text):
        self.tech_text.configure(state="normal")
        self.tech_text.insert("end", text + "\n")
        n = int(self.tech_text.index("end-1c").split(".")[0])
        if n > 2000:
            self.tech_text.delete("1.0", f"{n - 2000}.0")
        self.tech_text.see("end")
        self.tech_text.configure(state="disabled")

    # ------------------------------------------------------------ gebeurtenissen
    def _poll_events(self):
        try:
            for _ in range(400):
                self._handle_event(self.events.get_nowait())
        except queue.Empty:
            pass
        self.root.after(80, self._poll_events)

    def _handle_event(self, ev):
        kind = ev[0]
        if kind == "log":
            self.t_last_activity = time.time()
            self._log(ev[1], ev[2])
        elif kind == "tech":
            self._tech(ev[1])
        elif kind == "phase":
            self.t_last_activity = time.time()
            if self.busy == "analyze":
                self.analyze_info.configure(text=ev[1], foreground=COLOR_DIM)
            else:
                self.status_var.set(ev[1])
        elif kind == "status":
            self.status = ev[1]
            if (self.analysis and not self.busy and ev[1]["dossier"]
                    and self.analysis.get("page_root") and ev[1]["root"]
                    and ev[1]["root"] != self.analysis["page_root"]):
                self.analysis = None
                self.tree.clear()
                self.analyze_info.configure(
                    text="Er is een ander dossier geopend. Analyseer opnieuw.", foreground=COLOR_WARN)
            self._refresh_ui()
        elif kind == "analysis":
            self._on_analysis(ev[1])
        elif kind == "analysis_cancelled":
            self._end_busy()
            self.status_var.set("Analyse gestopt")
            self.analyze_info.configure(text="Analyse gestopt. Klik opnieuw op 'Dossier analyseren'.",
                                        foreground=COLOR_WARN)
        elif kind == "download_started":
            self.status_var.set(f"0 van {ev[1]['total']} documenten")
        elif kind == "doc_start":
            self._on_doc_start(ev[1])
        elif kind == "doc_done":
            self._on_doc_done(ev[1])
        elif kind == "download_done":
            self._on_download_done(ev[1])
        elif kind == "command_failed":
            self._end_busy()
            self.status_var.set("Gestopt door een fout")
            if ev[1] == "analyze":
                self.analyze_info.configure(text=ev[2], foreground=COLOR_ERR)
            notify_sound("warn")
            messagebox.showwarning(APP_NAME, ev[2], parent=self.root)
        elif kind == "fatal":
            self._tech(ev[1])
            self._end_busy()
            messagebox.showerror(APP_NAME, "De achtergrondverwerking is gestopt door een fout.\n\n"
                                 "Bekijk 'Technische details'.\n\n" + ev[1][-400:], parent=self.root)

    def _end_busy(self):
        self.busy = None
        self.analyze_progress.stop()
        self.analyze_progress.pack_forget()
        self.t_doc_start = None
        self.idle_var.set("")
        self._refresh_ui()

    def _on_analysis(self, result):
        self.analysis = result
        self._end_busy()
        self.progress.configure(value=0)
        self.tree.load(result["documents"])
        s = result["stats"]
        text = f"{s['documents']} documenten in {s['folders']} mappen"
        colour = COLOR_OK
        if s["remaining_closed"]:
            text += f"  ·  let op: {s['remaining_closed']} mappen konden niet geopend worden"
            colour = COLOR_WARN
        self.analyze_info.configure(text=text, foreground=colour)
        self.status_var.set("Analyse klaar. Kies de mappen en start de download.")
        notify_sound("done")
        self._refresh_ui()

    def _on_doc_start(self, d):
        self.t_last_activity = time.time()
        self.t_doc_start = time.time()
        self.current = d
        self.status_var.set(f"Document {d['i']} van {d['total']}")
        self.detail_var.set(shorten(d["name"], 110))
        self.folder_var.set(shorten(d["folder"], 130))

    def _on_doc_done(self, d):
        self.t_last_activity = time.time()
        self.t_doc_start = None
        if d["status"] == "ok":
            self.new_downloads += 1
        self.counts = {"success": d["success"], "skipped": d["skipped"], "failed": d["failed"], "bytes": d["bytes"]}
        done = d["i"]
        total = d["total"]
        self.progress.configure(value=(done / total * 100) if total else 0)
        elapsed = time.time() - self.t_busy_start
        eta = ""
        if self.new_downloads >= 5 and done < total:
            eta = f"  ·  nog ± {human_time(elapsed / self.new_downloads * (total - done))}"
        self.count_var.set(
            f"✓ {d['success']} gedownload   ⏭ {d['skipped']} overgeslagen   ✗ {d['failed']} mislukt"
            f"   ·   {format_bytes(d['bytes'])}   ·   verstreken {human_time(elapsed)}{eta}")

    def _on_download_done(self, s):
        self.last_summary = s
        self.failed_docs = s["failed"]
        self._end_busy()
        self.detail_var.set("")
        self.folder_var.set("")
        processed = s["success"] + s["skipped"]
        self.progress.configure(value=(processed / s["total"] * 100) if s["total"] else 0)

        if s["interrupted"] == "cancelled":
            title, colour = "Download gestopt", COLOR_WARN
        elif s["interrupted"] == "session":
            title, colour = "Sessie onderbroken: opnieuw aanmelden in Chrome", COLOR_ERR
        elif s["complete"]:
            title, colour = "✓ Dossier volledig gedownload", COLOR_OK
        else:
            title, colour = "Download klaar, maar niet volledig", COLOR_WARN
        self.status_var.set(title)

        missing = s["total"] - processed
        lines = [
            f"Gevonden: {s['total']}    Gedownload: {s['success']}    Overgeslagen (bestond al): {s['skipped']}"
            f"    Mislukt: {len(s['failed'])}    Niet verwerkt: {max(0, missing - len(s['failed']))}",
            f"Totaal nieuw gedownload: {format_bytes(s['bytes'])}    Duur: {human_time(s['duration'])}",
            f"Opgeslagen in: {s['dossier_dir']}",
        ]
        if s["interrupted"] == "session":
            lines.append("Meld opnieuw aan in Chrome en klik opnieuw op 'Download starten': "
                         "bestaande PDF's worden overgeslagen.")
        elif s["interrupted"] == "cancelled":
            lines.append("Klik opnieuw op 'Download starten' om verder te gaan: bestaande PDF's worden overgeslagen.")
        elif s["complete"]:
            lines.append(f"Controle: alle {s['total']} documenten uit de selectie zijn aanwezig.")
        self.result_title.configure(text=title, foreground=colour)
        self.result_detail.configure(text="\n".join(lines))
        self.failed_tv.delete(*self.failed_tv.get_children())
        for f in s["failed"]:
            self.failed_tv.insert("", "end", text=f["doc"]["name"], values=(" > ".join(f["doc"]["folders"]), f["error"]))
        if s["failed"]:
            self.failed_tv.grid()
        else:
            self.failed_tv.grid_remove()
        self.report_btn.configure(state="normal" if s["report"] else "disabled")
        self.result_frame.grid()
        self._refresh_ui()

        self._log("ok" if s["complete"] else "warn", title)
        notify_sound("done" if s["complete"] else "warn")
        self.root.bell()
        try:
            self.root.lift()
            self.root.attributes("-topmost", True)
            self.root.after(600, lambda: self.root.attributes("-topmost", False))
        except tk.TclError:
            pass
        if s["interrupted"] == "session":
            messagebox.showwarning(APP_NAME, title + "\n\n" + lines[-1], parent=self.root)
        else:
            messagebox.showinfo(APP_NAME, title + "\n\n" + "\n".join(lines[:2]), parent=self.root)

    # ------------------------------------------------------------ klok
    def _tick(self):
        now = time.time()
        if not self.status["chrome"] and self.chrome_launched:
            self._refresh_ui()        # laat de hint "Chrome wordt gestart…" tijdig veranderen
        if self.busy == "download":
            if self.t_doc_start:
                busy_for = now - self.t_doc_start
                self.detail_var.set(
                    f"{shorten(self.current.get('name', ''), 90)}   (bezig sinds {int(busy_for)} s)")
            idle = now - self.t_last_activity
            if idle > IDLE_WARNING_SECONDS:
                self.idle_var.set(
                    f"⚠ Al {int(idle)} s zonder voortgang. Controleer in Chrome of er een melding of "
                    "aanmeldscherm openstaat.")
            else:
                self.idle_var.set("")
        self.root.after(1000, self._tick)

    # ------------------------------------------------------------ afsluiten
    def close(self):
        if self.busy:
            if not messagebox.askyesno(
                APP_NAME, "Er loopt nog een verwerking.\n\nWilt u het programma toch sluiten?\n"
                "Reeds opgeslagen PDF's blijven bewaard.", parent=self.root):
                return
        self.ctx.cancel.set()
        self.engine.send("quit")
        self._save_output()
        self.root.destroy()


def main():
    enable_dpi_awareness()
    root = tk.Tk()
    if PLAYWRIGHT_IMPORT_ERROR is not None:
        messagebox.showerror(
            APP_NAME,
            "Playwright is nog niet geïnstalleerd.\n\n"
            "Open het Opdrachtprompt (cmd) en typ:\n\n    python -m pip install playwright\n\n"
            "Start daarna dit programma opnieuw.")
        root.destroy()
        return
    App(root)
    root.mainloop()


if __name__ == "__main__":
    main()
