from __future__ import annotations

import argparse
import base64
import ctypes
import html
import json
import os
import re
import shutil
import socket
import subprocess
import sys
import threading
import time
import traceback
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import asdict, dataclass
from datetime import datetime
from pathlib import Path

from strato_log_assistant import (
    DEFAULT_LOGIN_URL,
    _ws_connect,
    _ws_read_text,
    _ws_send_text,
    chrome_debug_available,
    chrome_debug_base,
    chrome_debug_tabs,
    chrome_page_text,
    cdp_evaluate,
    choose_strato_tab,
    config_path,
    dedupe_strato_packages,
    default_browser_profile_dir,
    default_chrome_path,
    http_json,
    launch_controlled_chrome,
    load_json_file,
    open_url_in_debug_chrome,
    package_from_dict,
    parse_strato_packages,
    user_config_path,
)


PARTIAL_SUFFIXES = {".crdownload", ".tmp", ".download"}
LOG_PATH: Path | None = None
CDP_LOCK = threading.RLock()
CDP_EVALUATE_TIMEOUT_SECONDS = 30.0
SCREENSHOT_INTERVAL_SECONDS = 1.0
SCREENSHOT_MAX_FRAMES = 600
SCREENSHOT_VIDEO_FPS = 10
FAILURE_REPORT_DIR_NAME = "strato_fehlerberichte"
WINDOW_KEEPALIVE_INTERVAL_SECONDS = 0.8
CREDENTIAL_FILE_NAME = "strato_login_credentials.dpapi.json"
CREDENTIAL_ENTROPY = b"STRATO Log Batch credentials v1"
BATCH_FALLBACK_PROFILE_DIR_NAME = "browser-profile-batch"
BATCH_RUN_PROFILE_DIR_NAME = "browser-profile-batch-runs"
RUN_CHECK_JSON_NAME = "strato_morgenlauf_pruefung.json"
RUN_CHECK_TXT_NAME = "strato_morgenlauf_pruefung.txt"
SENSITIVE_INPUT_ACTIVE = threading.Event()
BROWSER_WINDOW_LEFT = 80
BROWSER_WINDOW_TOP = 40
BROWSER_WINDOW_WIDTH = 1280
BROWSER_WINDOW_HEIGHT = 900
RUN_HEALTH: dict[str, object] = {
    "started_at": datetime.now().isoformat(timespec="seconds"),
    "browser_started": False,
    "used_existing_browser": False,
    "browser_profile_label": "",
    "browser_profile_path": "",
    "fresh_batch_profile_used": False,
    "window_maximize_attempted": False,
    "window_maximized": False,
    "logfiles_click_count": 0,
    "download_page_ready_count": 0,
    "download_form_count": 0,
    "package_count": 0,
}

if sys.platform == "win32":
    from ctypes import wintypes

    HWND_TOPMOST = wintypes.HWND(-1)
    HWND_NOTOPMOST = wintypes.HWND(-2)
    SW_RESTORE = 9
    SW_SHOW = 5
    SWP_NOMOVE = 0x0002
    SWP_NOSIZE = 0x0001
    SWP_NOACTIVATE = 0x0010
    SWP_SHOWWINDOW = 0x0040


def default_root() -> Path:
    if getattr(sys, "frozen", False):
        base = Path(sys.executable).resolve().parent
        if base.name.lower() == "dist":
            base = base.parent
        return base
    return Path(__file__).resolve().parent


DEFAULT_PORT = 9223
DEFAULT_ROOT = default_root()
LOGIN_REPAIR_HINT = (
    "Die lokal verschluesselten STRATO-Zugangsdaten wurden nicht akzeptiert. "
    "Bitte die Zugangsdaten im angezeigten Fenster neu eingeben. Sie werden "
    "mit Windows-DPAPI nur fuer diesen Windows-Benutzer verschluesselt gespeichert."
)


@dataclass
class PackageResult:
    package: str
    order_number: str
    folder: str
    status: str
    detail: str = ""
    files: list[str] | None = None


@dataclass
class ControlCheckResult:
    package: str
    order_number: str
    status: str
    detail: str = ""
    statistic: str = ""
    logfiles: str = ""
    download_form: str = ""
    controls: list[str] | None = None


def health_set(key: str, value: object) -> None:
    RUN_HEALTH[key] = value


def health_increment(key: str, amount: int = 1) -> None:
    try:
        RUN_HEALTH[key] = int(RUN_HEALTH.get(key) or 0) + amount
    except (TypeError, ValueError):
        RUN_HEALTH[key] = amount


@dataclass
class LoginCredentials:
    identity: str
    password: str


def log(message: str) -> None:
    line = f"{time.strftime('[%H:%M:%S]')} {message}"
    try:
        if sys.stdout is not None:
            print(line, flush=True)
    except (OSError, ValueError):
        pass
    if LOG_PATH is not None:
        try:
            LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
            with LOG_PATH.open("a", encoding="utf-8") as fh:
                fh.write(line + "\n")
        except OSError:
            pass


def write_fatal_error(exc: BaseException) -> None:
    line = f"{time.strftime('[%H:%M:%S]')} FATAL: {exc!r}"
    paths = []
    if LOG_PATH is not None:
        paths.append(LOG_PATH)
    paths.append(DEFAULT_ROOT / "strato_batch_run.log")
    for path in dict.fromkeys(paths):
        try:
            path.parent.mkdir(parents=True, exist_ok=True)
            with path.open("a", encoding="utf-8") as fh:
                fh.write(line + "\n")
                fh.write(traceback.format_exc() + "\n")
            break
        except OSError:
            continue


if sys.platform == "win32":
    class DATA_BLOB(ctypes.Structure):
        _fields_ = [
            ("cbData", wintypes.DWORD),
            ("pbData", ctypes.POINTER(ctypes.c_ubyte)),
        ]


def credential_path(root: Path) -> Path:
    return root / CREDENTIAL_FILE_NAME


def _make_blob(data: bytes) -> tuple[DATA_BLOB, ctypes.Array]:
    buffer = ctypes.create_string_buffer(data)
    blob = DATA_BLOB(
        len(data),
        ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte)),
    )
    return blob, buffer


def dpapi_protect(data: bytes) -> bytes:
    if sys.platform != "win32":
        raise RuntimeError("Verschluesselte Zugangsdaten werden derzeit nur unter Windows unterstuetzt.")
    crypt32 = ctypes.windll.crypt32
    kernel32 = ctypes.windll.kernel32
    in_blob, in_buffer = _make_blob(data)
    entropy_blob, entropy_buffer = _make_blob(CREDENTIAL_ENTROPY)
    out_blob = DATA_BLOB()
    ok = crypt32.CryptProtectData(
        ctypes.byref(in_blob),
        "STRATO Log Batch",
        ctypes.byref(entropy_blob),
        None,
        None,
        0,
        ctypes.byref(out_blob),
    )
    # Keep the input buffers alive until CryptProtectData returned.
    _ = (in_buffer, entropy_buffer)
    if not ok:
        raise ctypes.WinError()
    try:
        return ctypes.string_at(out_blob.pbData, out_blob.cbData)
    finally:
        kernel32.LocalFree(out_blob.pbData)


def dpapi_unprotect(data: bytes) -> bytes:
    if sys.platform != "win32":
        raise RuntimeError("Verschluesselte Zugangsdaten werden derzeit nur unter Windows unterstuetzt.")
    crypt32 = ctypes.windll.crypt32
    kernel32 = ctypes.windll.kernel32
    in_blob, in_buffer = _make_blob(data)
    entropy_blob, entropy_buffer = _make_blob(CREDENTIAL_ENTROPY)
    out_blob = DATA_BLOB()
    ok = crypt32.CryptUnprotectData(
        ctypes.byref(in_blob),
        None,
        ctypes.byref(entropy_blob),
        None,
        None,
        0,
        ctypes.byref(out_blob),
    )
    _ = (in_buffer, entropy_buffer)
    if not ok:
        raise ctypes.WinError()
    try:
        return ctypes.string_at(out_blob.pbData, out_blob.cbData)
    finally:
        kernel32.LocalFree(out_blob.pbData)


def load_login_credentials(root: Path) -> LoginCredentials | None:
    path = credential_path(root)
    if not path.exists():
        return None
    try:
        wrapper = json.loads(path.read_text(encoding="utf-8"))
        encrypted = base64.b64decode(str(wrapper.get("data") or ""), validate=True)
        data = json.loads(dpapi_unprotect(encrypted).decode("utf-8"))
        identity = str(data.get("identity") or "").strip()
        password = str(data.get("password") or "")
    except Exception as exc:
        log(f"Gespeicherte STRATO-Zugangsdaten konnten nicht gelesen werden: {exc}")
        return None
    if not identity or not password:
        return None
    return LoginCredentials(identity=identity, password=password)


def save_login_credentials(root: Path, credentials: LoginCredentials) -> None:
    payload = json.dumps(
        {
            "identity": credentials.identity,
            "password": credentials.password,
        },
        ensure_ascii=False,
        separators=(",", ":"),
    ).encode("utf-8")
    encrypted = dpapi_protect(payload)
    wrapper = {
        "version": 1,
        "encryption": "win32-dpapi-current-user",
        "updated_at": datetime.now().isoformat(timespec="seconds"),
        "data": base64.b64encode(encrypted).decode("ascii"),
    }
    path = credential_path(root)
    path.write_text(json.dumps(wrapper, indent=2), encoding="utf-8")


def prompt_for_login_credentials(root: Path, reason: str = "", default_identity: str = "") -> LoginCredentials:
    try:
        import tkinter as tk
        from tkinter import messagebox, ttk
    except Exception as exc:
        raise RuntimeError(f"Zugangsdatenfenster konnte nicht geladen werden: {exc}") from exc

    result: dict[str, LoginCredentials] = {}
    window = tk.Tk()
    window.title("STRATO Zugangsdaten")
    window.resizable(False, False)
    window.attributes("-topmost", True)

    frame = ttk.Frame(window, padding=18)
    frame.grid(row=0, column=0, sticky="nsew")
    ttk.Label(frame, text="STRATO Zugangsdaten", font=("Segoe UI", 12, "bold")).grid(row=0, column=0, columnspan=2, sticky="w")
    info = (
        reason
        or "Bitte Benutzername/Kundennummer und Passwort eingeben. Die Daten werden lokal per Windows-DPAPI verschluesselt."
    )
    ttk.Label(frame, text=info, wraplength=460).grid(row=1, column=0, columnspan=2, sticky="w", pady=(8, 14))

    identity_var = tk.StringVar(value=default_identity)
    password_var = tk.StringVar()
    ttk.Label(frame, text="Benutzername oder Kundennummer").grid(row=2, column=0, sticky="w")
    identity_entry = ttk.Entry(frame, textvariable=identity_var, width=44)
    identity_entry.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(2, 10))
    ttk.Label(frame, text="Passwort").grid(row=4, column=0, sticky="w")
    password_entry = ttk.Entry(frame, textvariable=password_var, show="*", width=44)
    password_entry.grid(row=5, column=0, columnspan=2, sticky="ew", pady=(2, 14))

    def submit() -> None:
        identity = identity_var.get().strip()
        password = password_var.get()
        if not identity or not password:
            messagebox.showerror("STRATO Zugangsdaten", "Bitte Benutzername/Kundennummer und Passwort eingeben.")
            return
        result["credentials"] = LoginCredentials(identity=identity, password=password)
        window.destroy()

    def cancel() -> None:
        window.destroy()

    buttons = ttk.Frame(frame)
    buttons.grid(row=6, column=0, columnspan=2, sticky="e")
    ttk.Button(buttons, text="Abbrechen", command=cancel).grid(row=0, column=0, padx=(0, 8))
    ttk.Button(buttons, text="Verschluesselt speichern", command=submit).grid(row=0, column=1)
    window.bind("<Return>", lambda _event: submit())
    window.bind("<Escape>", lambda _event: cancel())
    frame.columnconfigure(0, weight=1)

    window.update_idletasks()
    width = window.winfo_width()
    height = window.winfo_height()
    screen_width = window.winfo_screenwidth()
    screen_height = window.winfo_screenheight()
    window.geometry(f"+{max(0, (screen_width - width) // 2)}+{max(0, (screen_height - height) // 3)}")
    window.lift()
    window.focus_force()
    if default_identity:
        password_entry.focus_set()
    else:
        identity_entry.focus_set()
    window.mainloop()

    credentials = result.get("credentials")
    if not credentials:
        raise RuntimeError("STRATO-Zugangsdaten wurden nicht eingegeben.")
    save_login_credentials(root, credentials)
    log(f"STRATO-Zugangsdaten lokal verschluesselt gespeichert: {credential_path(root)}")
    return credentials


def load_or_prompt_login_credentials(root: Path, reason: str = "", force_prompt: bool = False) -> LoginCredentials:
    saved = load_login_credentials(root)
    default_identity = saved.identity if saved else ""
    if saved and not force_prompt:
        return saved
    return prompt_for_login_credentials(root, reason=reason, default_identity=default_identity)


class FailureCapture:
    def __init__(
        self,
        root: Path,
        debug_port: int,
        interval_seconds: float = SCREENSHOT_INTERVAL_SECONDS,
        max_frames: int = SCREENSHOT_MAX_FRAMES,
        video_fps: int = SCREENSHOT_VIDEO_FPS,
    ) -> None:
        self.root = root
        self.debug_port = debug_port
        self.interval_seconds = interval_seconds
        self.max_frames = max_frames
        self.video_fps = video_fps
        self.started_at = datetime.now()
        self.report_dir = root / FAILURE_REPORT_DIR_NAME / self.started_at.strftime("%Y%m%d-%H%M%S")
        self.frames_dir = self.report_dir / "screenshots"
        self._stop = threading.Event()
        self._thread: threading.Thread | None = None
        self.frame_count = 0
        self.capture_errors: list[str] = []
        self.fullscreen_available = True
        self.capture_disabled = False
        self.browser_fallback_used = False
        self.browser_window_control_used = False
        self._topmost_hwnds: set[int] = set()
        self._last_window_keepalive = 0.0

    def start(self) -> None:
        try:
            self.frames_dir.mkdir(parents=True, exist_ok=True)
        except OSError as exc:
            self.capture_errors.append(f"Aufnahmeordner konnte nicht angelegt werden: {exc}")
            return
        self._thread = threading.Thread(target=self._loop, name="strato-failure-capture", daemon=True)
        self._thread.start()

    def stop(self) -> None:
        self._stop.set()
        if self._thread and self._thread.is_alive():
            self._thread.join(timeout=5.0)
        self._release_topmost_windows()

    def discard(self) -> None:
        self.stop()
        try:
            shutil.rmtree(self.report_dir, ignore_errors=True)
        except OSError:
            pass
        try:
            reports_root = self.root / FAILURE_REPORT_DIR_NAME
            if reports_root.exists() and not any(reports_root.iterdir()):
                reports_root.rmdir()
        except OSError:
            pass

    def finalize_failure(self, title: str, detail: str, log_path: Path | None, extra_paths: list[Path] | None = None) -> Path:
        self.stop()
        try:
            self.report_dir.mkdir(parents=True, exist_ok=True)
        except OSError:
            return self.report_dir / "index.html"

        if self.frame_count == 0 and self.capture_disabled:
            try:
                target = self.frames_dir / "screen_000001.jpg"
                self._capture_browser_tab(target)
                self.frame_count = 1
                self.browser_fallback_used = True
            except Exception as exc:
                self.capture_errors.append(f"Letzter Browser-Screenshot fehlgeschlagen: {exc}")

        video_path, video_note = self._build_video()
        index_path = self._write_html(title, detail, log_path, extra_paths or [], video_path, video_note)
        try:
            os.startfile(str(index_path))  # noqa: S606 - intended local browser launch on failure
        except Exception as exc:
            log(f"Fehlerbericht konnte nicht automatisch geoeffnet werden: {exc}")
        return index_path

    def _loop(self) -> None:
        while not self._stop.is_set() and self.frame_count < self.max_frames:
            if SENSITIVE_INPUT_ACTIVE.is_set():
                if self._stop.wait(self.interval_seconds):
                    break
                continue
            started = time.monotonic()
            try:
                if self._keep_browser_visible_for_capture() or self.frame_count > 0:
                    self._capture_frame()
            except Exception as exc:
                if len(self.capture_errors) < 5:
                    self.capture_errors.append(f"Screenshot fehlgeschlagen: {exc}")
            elapsed = time.monotonic() - started
            wait_for = max(0.0, self.interval_seconds - elapsed)
            if self._stop.wait(wait_for):
                break

    def _keep_browser_visible_for_capture(self) -> bool:
        if time.monotonic() - self._last_window_keepalive < WINDOW_KEEPALIVE_INTERVAL_SECONDS:
            return chrome_debug_available(self.debug_port)
        self._last_window_keepalive = time.monotonic()
        if not chrome_debug_available(self.debug_port):
            if sys.platform == "win32":
                try:
                    controlled = self._pin_strato_browser_window()
                    if controlled:
                        self.browser_window_control_used = True
                    return controlled
                except Exception as exc:
                    if len(self.capture_errors) < 5:
                        self.capture_errors.append(f"Browserfenster konnte ohne Debug-Port nicht topmost gesetzt werden: {exc}")
            return False
        controlled = False
        try:
            controlled = self._bring_debug_target_to_front()
        except Exception as exc:
            if len(self.capture_errors) < 5:
                self.capture_errors.append(f"Browserfenster konnte nicht nach vorn geholt werden: {exc}")
        if sys.platform == "win32":
            try:
                controlled = self._pin_strato_browser_window() or controlled
            except Exception as exc:
                if len(self.capture_errors) < 5:
                    self.capture_errors.append(f"Browserfenster konnte nicht topmost gesetzt werden: {exc}")
        if controlled:
            self.browser_window_control_used = True
        return controlled

    def _bring_debug_target_to_front(self) -> bool:
        tab = choose_strato_tab(chrome_debug_tabs(self.debug_port))
        if not tab:
            return False
        try:
            normalize_debug_browser_window(self.debug_port)
        except Exception:
            pass
        return True

    def _pin_strato_browser_window(self) -> bool:
        if sys.platform != "win32":
            return False
        user32 = ctypes.windll.user32
        matches: list[int] = []

        enum_proc_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)

        def enum_proc(hwnd, _lparam):
            if not user32.IsWindowVisible(hwnd):
                return True
            length = user32.GetWindowTextLengthW(hwnd)
            if length <= 0:
                return True
            buffer = ctypes.create_unicode_buffer(length + 1)
            user32.GetWindowTextW(hwnd, buffer, length + 1)
            title = buffer.value.casefold()
            if "strato" in title and ("chrome" in title or "google chrome" in title):
                matches.append(self._hwnd_to_int(hwnd))
            return True

        callback = enum_proc_type(enum_proc)
        user32.EnumWindows(callback, 0)
        if not matches:
            return False

        hwnd = wintypes.HWND(matches[0])
        if user32.IsIconic(hwnd):
            user32.ShowWindow(hwnd, SW_RESTORE)
        else:
            user32.ShowWindow(hwnd, SW_SHOW)
        user32.SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW)
        user32.SetForegroundWindow(hwnd)
        self._topmost_hwnds.add(self._hwnd_to_int(hwnd))
        return True

    @staticmethod
    def _hwnd_to_int(hwnd) -> int:
        value = getattr(hwnd, "value", hwnd)
        if isinstance(value, bytes):
            return int.from_bytes(value, byteorder=sys.byteorder, signed=False)
        return int(value or 0)

    def _release_topmost_windows(self) -> None:
        if sys.platform != "win32" or not self._topmost_hwnds:
            return
        user32 = ctypes.windll.user32
        for hwnd_int in list(self._topmost_hwnds):
            hwnd = wintypes.HWND(hwnd_int)
            try:
                if user32.IsWindow(hwnd):
                    user32.SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE)
            except Exception:
                pass
        self._topmost_hwnds.clear()

    def _capture_frame(self) -> None:
        if self.capture_disabled:
            return
        next_index = self.frame_count + 1
        target = self.frames_dir / f"screen_{next_index:06d}.jpg"
        try:
            if chrome_debug_available(self.debug_port):
                self._capture_browser_tab(target)
                self.browser_fallback_used = True
            else:
                self._capture_fullscreen(target)
        except Exception as exc:
            self.fullscreen_available = False
            self.capture_disabled = True
            self.capture_errors.append(
                f"Browser-/Vollbild-Screenshot fehlgeschlagen ({exc}); laufende Aufnahme deaktiviert, damit der STRATO-Browser nicht gestoert wird."
            )
            return
        self.frame_count = next_index

    def _capture_fullscreen(self, target: Path) -> None:
        from PIL import ImageGrab

        image = ImageGrab.grab(all_screens=True)
        if image.mode != "RGB":
            image = image.convert("RGB")
        image.save(target, "JPEG", quality=82, optimize=True)

    def _capture_browser_tab(self, target: Path) -> None:
        with CDP_LOCK:
            tab = choose_strato_tab(chrome_debug_tabs(self.debug_port))
            if not tab:
                raise RuntimeError("Kein STRATO-Browsertab fuer Screenshot-Fallback gefunden.")
            ws_url = str(tab.get("webSocketDebuggerUrl") or "")
            if not ws_url:
                raise RuntimeError("STRATO-Browsertab hat keine WebSocket-Debug-URL.")
            command = {
                "id": 1,
                "method": "Page.captureScreenshot",
                "params": {
                    "format": "jpeg",
                    "quality": 82,
                    "fromSurface": True,
                    "captureBeyondViewport": False,
                },
            }
            with _ws_connect(ws_url, timeout=5.0) as sock:
                _ws_send_text(sock, json.dumps(command))
                while True:
                    response = json.loads(_ws_read_text(sock))
                    if response.get("id") != 1:
                        continue
                    if "error" in response:
                        raise RuntimeError(response["error"])
                    data = response.get("result", {}).get("data")
                    if not data:
                        raise RuntimeError("Chrome lieferte keinen Screenshot.")
                    target.write_bytes(base64.b64decode(data))
                    return

    def _build_video(self) -> tuple[Path | None, str]:
        if self.frame_count <= 0:
            return None, "Kein Screenshot wurde erzeugt."

        ffmpeg = self._find_ffmpeg()
        if not ffmpeg:
            return None, "ffmpeg wurde nicht gefunden; der Fehlerbericht enthaelt die Einzelbilder."

        video_path = self.report_dir / "strato_fehlerfilm.mp4"
        command = [
            ffmpeg,
            "-hide_banner",
            "-loglevel",
            "error",
            "-y",
            "-framerate",
            str(self.video_fps),
            "-start_number",
            "1",
            "-i",
            str(self.frames_dir / "screen_%06d.jpg"),
            "-vf",
            "scale=trunc(iw/2)*2:trunc(ih/2)*2",
            "-pix_fmt",
            "yuv420p",
            str(video_path),
        ]
        creationflags = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
        try:
            result = subprocess.run(
                command,
                cwd=self.report_dir,
                capture_output=True,
                text=True,
                timeout=300,
                creationflags=creationflags,
                check=False,
            )
        except Exception as exc:
            return None, f"Film konnte nicht erzeugt werden: {exc}"
        if result.returncode != 0:
            message = (result.stderr or result.stdout or "").strip()
            return None, f"ffmpeg endete mit Exitcode {result.returncode}: {message}"
        return video_path, f"Film erzeugt mit {self.video_fps} Bildern pro Sekunde."

    @staticmethod
    def _find_ffmpeg() -> str | None:
        candidates: list[str] = []
        if sys.platform == "win32":
            candidates.extend(
                [
                    r"C:\Tools\ffmpeg\bin\ffmpeg.exe",
                    r"C:\ProgramData\chocolatey\bin\ffmpeg.exe",
                    r"C:\Program Files\ffmpeg\bin\ffmpeg.exe",
                ]
            )
            try:
                result = subprocess.run(
                    ["where.exe", "ffmpeg"],
                    capture_output=True,
                    text=True,
                    timeout=5,
                    creationflags=subprocess.CREATE_NO_WINDOW,
                    check=False,
                )
                if result.returncode == 0:
                    candidates.extend(line.strip() for line in result.stdout.splitlines() if line.strip())
            except Exception:
                pass
        which_path = shutil.which("ffmpeg")
        if which_path:
            candidates.append(which_path)
        seen: set[str] = set()
        for candidate in candidates:
            if candidate in seen:
                continue
            seen.add(candidate)
            try:
                result = subprocess.run(
                    [candidate, "-version"],
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                    timeout=5,
                    creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
                    check=False,
                )
            except Exception:
                continue
            if result.returncode == 0:
                return candidate
        return None

    def _write_html(
        self,
        title: str,
        detail: str,
        log_path: Path | None,
        extra_paths: list[Path],
        video_path: Path | None,
        video_note: str,
    ) -> Path:
        index_path = self.report_dir / "index.html"
        frame_links = sorted(self.frames_dir.glob("screen_*.jpg"))[:50]
        log_text = self._read_tail(log_path, 120) if log_path else ""
        extra_sections = []
        for path in extra_paths:
            if path.exists():
                extra_sections.append((path.name, self._read_tail(path, 120)))

        css = """
body { margin: 0; font-family: Segoe UI, Arial, sans-serif; color: #17202a; background: #f6f7f9; }
main { max-width: 1180px; margin: 0 auto; padding: 28px; }
h1 { font-size: 26px; margin: 0 0 8px; }
h2 { font-size: 18px; margin: 28px 0 10px; }
.meta, pre, .panel { background: #fff; border: 1px solid #d8dee7; border-radius: 6px; padding: 14px; }
.meta { display: grid; grid-template-columns: 190px 1fr; gap: 8px 14px; }
pre { overflow: auto; white-space: pre-wrap; }
video { max-width: 100%; background: #111; border-radius: 6px; }
.frames { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
.frames img { width: 100%; border: 1px solid #d8dee7; border-radius: 4px; background: #fff; }
a { color: #075c65; }
"""
        parts = [
            "<!doctype html>",
            '<html lang="de">',
            "<head>",
            '<meta charset="utf-8">',
            "<title>STRATO Fehlerbericht</title>",
            f"<style>{css}</style>",
            "</head>",
            "<body><main>",
            f"<h1>{html.escape(title)}</h1>",
            '<div class="meta">',
            "<strong>Start</strong>",
            f"<span>{html.escape(self.started_at.strftime('%Y-%m-%d %H:%M:%S'))}</span>",
            "<strong>Screenshots</strong>",
            f"<span>{self.frame_count} von maximal {self.max_frames}</span>",
            "<strong>Aufnahme</strong>",
            f"<span>Alle {self.interval_seconds:g} Sekunden, Film: {self.video_fps} Bilder pro Sekunde</span>",
            "<strong>Quelle</strong>",
            f"<span>{html.escape(self._capture_source_label())}</span>",
            "<strong>Ordner</strong>",
            f"<span>{html.escape(str(self.report_dir))}</span>",
            "</div>",
            "<h2>Fehler</h2>",
            f"<pre>{html.escape(detail)}</pre>",
            "<h2>Film</h2>",
        ]
        if video_path and video_path.exists():
            parts.append(f'<video src="{html.escape(video_path.name)}" controls></video>')
        parts.append(f'<p>{html.escape(video_note)}</p>')
        if self.capture_errors:
            parts.extend(["<h2>Aufnahme-Hinweise</h2>", f"<pre>{html.escape(chr(10).join(self.capture_errors))}</pre>"])
        if log_text:
            parts.extend(["<h2>strato_batch_run.log</h2>", f"<pre>{html.escape(log_text)}</pre>"])
        for name, text in extra_sections:
            parts.extend([f"<h2>{html.escape(name)}</h2>", f"<pre>{html.escape(text)}</pre>"])
        if frame_links:
            parts.append("<h2>Erste Screenshots</h2>")
            parts.append('<div class="frames">')
            for frame in frame_links:
                rel = f"screenshots/{frame.name}"
                parts.append(f'<a href="{html.escape(rel)}"><img src="{html.escape(rel)}" alt="{html.escape(frame.name)}"></a>')
            parts.append("</div>")
        parts.extend(["</main></body>", "</html>"])
        index_path.write_text("\n".join(parts), encoding="utf-8")
        return index_path

    def _capture_source_label(self) -> str:
        if self.browser_fallback_used:
            return "Browser-Tab"
        if self.browser_window_control_used:
            return "Vollbild, Browser waehrend der Aufnahme im Vordergrund"
        return "Vollbild"

    @staticmethod
    def _read_tail(path: Path | None, lines: int) -> str:
        if not path or not path.exists():
            return ""
        try:
            text = path.read_text(encoding="utf-8", errors="replace")
        except OSError as exc:
            return f"Datei konnte nicht gelesen werden: {exc}"
        return "\n".join(text.splitlines()[-lines:])


def eval_js(port: int, expression: str) -> str:
    with CDP_LOCK:
        tab = choose_strato_tab(chrome_debug_tabs(port))
        if not tab:
            raise RuntimeError("Kein steuerbarer Chrome-Tab gefunden.")
        ws_url = str(tab.get("webSocketDebuggerUrl", ""))
        with _ws_connect(ws_url, timeout=CDP_EVALUATE_TIMEOUT_SECONDS) as sock:
            message = {
                "id": 1,
                "method": "Runtime.evaluate",
                "params": {
                    "expression": expression,
                    "returnByValue": True,
                    "awaitPromise": True,
                },
            }
            _ws_send_text(sock, json.dumps(message))
            while True:
                data = json.loads(_ws_read_text(sock))
                if data.get("id") != 1:
                    continue
                result = data.get("result", {}).get("result", {})
                if "value" in result:
                    return str(result.get("value", ""))
                if "description" in result:
                    return str(result.get("description", ""))
                return ""


def page_text(port: int) -> str:
    with CDP_LOCK:
        return chrome_page_text(port)


def wait_until(predicate, timeout: float, label: str, interval: float = 0.5):
    deadline = time.monotonic() + timeout
    last = None
    while time.monotonic() < deadline:
        last = predicate()
        if last:
            return last
        time.sleep(interval)
    raise TimeoutError(label)


def browser_command(port: int, method: str, params: dict | None = None) -> dict:
    with CDP_LOCK:
        version = http_json(f"{chrome_debug_base(port)}/json/version", timeout=2.0)
        ws_url = str(version.get("webSocketDebuggerUrl") or "")
        if not ws_url:
            raise RuntimeError("No browser WebSocket URL from Chrome debug endpoint.")

        with _ws_connect(ws_url, timeout=5.0) as sock:
            message = {"id": 1, "method": method}
            if params is not None:
                message["params"] = params
            _ws_send_text(sock, json.dumps(message))
            while True:
                response = json.loads(_ws_read_text(sock))
                if response.get("id") != 1:
                    continue
                if "error" in response:
                    raise RuntimeError(response["error"])
                return response.get("result") or {}


def page_command(port: int, method: str, params: dict | None = None) -> dict:
    with CDP_LOCK:
        tab = choose_strato_tab(chrome_debug_tabs(port))
        if not tab:
            raise RuntimeError("No STRATO page tab on Chrome debug endpoint.")
        ws_url = str(tab.get("webSocketDebuggerUrl") or "")
        if not ws_url:
            raise RuntimeError("No page WebSocket URL from Chrome debug endpoint.")

        with _ws_connect(ws_url, timeout=5.0) as sock:
            message = {"id": 1, "method": method}
            if params is not None:
                message["params"] = params
            _ws_send_text(sock, json.dumps(message))
            while True:
                response = json.loads(_ws_read_text(sock))
                if response.get("id") != 1:
                    continue
                if "error" in response:
                    raise RuntimeError(response["error"])
                return response.get("result") or {}


def close_debug_browser(port: int) -> None:
    try:
        browser_command(port, "Browser.close")
    except Exception:
        return
    try:
        wait_until(lambda: not chrome_debug_available(port), 5, "Chrome debug endpoint did not stop", interval=0.25)
    except Exception:
        pass


def tcp_port_accepts_connection(port: int, timeout: float = 0.4) -> bool:
    try:
        with socket.create_connection(("127.0.0.1", int(port)), timeout=timeout):
            return True
    except OSError:
        return False


def choose_debug_port(preferred_port: int, fallback_count: int = 12) -> int:
    if chrome_debug_available(preferred_port):
        return preferred_port
    if not tcp_port_accepts_connection(preferred_port):
        return preferred_port

    log(
        f"Debug-Port {preferred_port} ist belegt, antwortet aber nicht als Chrome-Debug-Endpunkt; "
        "suche freien Ersatzport"
    )
    for candidate in range(preferred_port + 1, preferred_port + fallback_count + 1):
        if not tcp_port_accepts_connection(candidate):
            log(f"Nutze Ersatz-Debug-Port {candidate}")
            return candidate
    raise RuntimeError(f"Kein freier Chrome-Debug-Port ab {preferred_port} gefunden.")


def stop_launched_browser_process(process: subprocess.Popen | None, label: str) -> None:
    if process is None:
        return
    if process.poll() is not None:
        log(f"Chrome-Prozess fuer {label} ist bereits beendet (Exitcode {process.returncode})")
        return
    log(f"Beende nicht steuerbaren Chrome-Prozess fuer {label} (PID {process.pid})")
    try:
        process.terminate()
        process.wait(timeout=5)
    except Exception:
        try:
            process.kill()
            process.wait(timeout=5)
        except Exception as exc:
            log(f"Chrome-Prozess fuer {label} konnte nicht beendet werden: {exc}")


def close_extra_debug_tabs(port: int) -> bool:
    tabs = [tab for tab in chrome_debug_tabs(port) if tab.get("type") == "page" and tab.get("id")]
    if not tabs:
        return False

    def priority(tab: dict) -> tuple[int, str]:
        haystack = f"{tab.get('title', '')} {tab.get('url', '')}".casefold()
        if "strato" in haystack:
            return (0, str(tab.get("id") or ""))
        return (1, str(tab.get("id") or ""))

    keep = sorted(tabs, key=priority)[0]
    keep_id = str(keep.get("id") or "")
    for tab in tabs:
        target_id = str(tab.get("id") or "")
        if not target_id or target_id == keep_id:
            continue
        try:
            browser_command(port, "Target.closeTarget", {"targetId": target_id})
        except Exception:
            pass
    try:
        browser_command(port, "Target.activateTarget", {"targetId": keep_id})
    except Exception:
        pass
    return True


def _tab_query_value(tab: dict, name: str) -> str:
    url = str(tab.get("url") or "")
    try:
        query = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query)
    except Exception:
        return ""
    return (query.get(name) or [""])[0]


def keep_only_target_tab(port: int, keep_id: str) -> bool:
    if not keep_id:
        return False
    for tab in [tab for tab in chrome_debug_tabs(port) if tab.get("type") == "page" and tab.get("id")]:
        target_id = str(tab.get("id") or "")
        if not target_id or target_id == keep_id:
            continue
        try:
            browser_command(port, "Target.closeTarget", {"targetId": target_id})
        except Exception:
            pass
    try:
        browser_command(port, "Target.activateTarget", {"targetId": keep_id})
    except Exception:
        pass
    return True


def activate_single_tab_by_query(port: int, expected_cid: str, expected_node: str = "") -> bool:
    matches = []
    for tab in [tab for tab in chrome_debug_tabs(port) if tab.get("type") == "page" and tab.get("id")]:
        if _tab_query_value(tab, "cID") != expected_cid:
            continue
        node = _tab_query_value(tab, "node")
        node_matches = not expected_node or node == expected_node
        matches.append((0 if node_matches else 1, str(tab.get("id") or ""), tab))
    if not matches:
        return False
    _priority, keep_id, _tab = sorted(matches, key=lambda item: item[:2])[0]
    return keep_only_target_tab(port, keep_id)


def reset_debug_browser_to_url(port: int, url: str) -> None:
    if not close_extra_debug_tabs(port):
        open_url_in_debug_chrome(port, url)
        return
    page_command(port, "Page.navigate", {"url": url})


def normalize_debug_browser_window(port: int) -> bool:
    health_set("window_maximize_attempted", True)
    tab = choose_strato_tab(chrome_debug_tabs(port))
    if not tab:
        return False
    target_id = str(tab.get("id") or "")
    if not target_id:
        return False
    page_command(port, "Page.bringToFront")
    result = browser_command(port, "Browser.getWindowForTarget", {"targetId": target_id})
    window_id = result.get("windowId")
    if window_id is None:
        return False
    try:
        browser_command(
            port,
            "Browser.setWindowBounds",
            {
                "windowId": int(window_id),
                "bounds": {
                    "windowState": "normal",
                    "left": BROWSER_WINDOW_LEFT,
                    "top": BROWSER_WINDOW_TOP,
                    "width": BROWSER_WINDOW_WIDTH,
                    "height": BROWSER_WINDOW_HEIGHT,
                },
            },
        )
    except Exception:
        pass
    browser_command(
        port,
        "Browser.setWindowBounds",
        {
            "windowId": int(window_id),
            "bounds": {
                "windowState": "maximized",
            },
        },
    )
    health_set("window_maximized", True)
    return True


def set_download_folder(port: int, folder: Path) -> None:
    folder.mkdir(parents=True, exist_ok=True)
    browser_command(
        port,
        "Browser.setDownloadBehavior",
        {
            "behavior": "allow",
            "downloadPath": str(folder),
            "eventsEnabled": True,
        },
    )


def normalize_js_helpers() -> str:
    return r"""
const clean = (value) => ((value || '') + '')
  .toLowerCase()
  .normalize('NFD')
  .replace(/[\u0300-\u036f]/g, '')
  .replace(/ß/g, 'ss')
  .replace(/\s+/g, ' ')
  .trim();
const textOf = (el) => ((el.innerText || el.textContent || el.value || el.getAttribute('aria-label') || el.title || '') + '').replace(/\s+/g, ' ').trim();
const visible = (el) => {
  const rect = el.getBoundingClientRect();
  const style = window.getComputedStyle(el);
  return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
};
const scrollables = () => {
  const items = [document.scrollingElement, ...document.querySelectorAll('main,section,div,table,tbody')]
    .filter(Boolean)
    .filter((el, index, arr) => arr.indexOf(el) === index)
    .filter((el) => el.scrollHeight > el.clientHeight + 30);
  if (!items.length && document.scrollingElement) items.push(document.scrollingElement);
  return items;
};
const scanPage = async (finder) => {
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
  let found = finder();
  if (found) return found;
  for (const el of scrollables()) {
    const max = Math.max(0, el.scrollHeight - el.clientHeight);
    const steps = Math.max(1, Math.min(16, Math.ceil(max / Math.max(240, window.innerHeight * 0.65))));
    for (let step = 0; step <= steps; step += 1) {
      const y = Math.round((max * step) / steps);
      el.scrollTop = y;
      if (el === document.scrollingElement) window.scrollTo(0, y);
      await delay(80);
      found = finder();
      if (found) return found;
    }
  }
  return null;
};
"""


def close_strato_overlays(port: int) -> str:
    expression = r"""
(() => {
""" + normalize_js_helpers() + r"""
  const candidates = [...document.querySelectorAll('button,a,[role="button"],input[type="button"],input[type="submit"],[aria-label]')];
  const exactClose = candidates.find((el) => visible(el) && (
    clean(textOf(el)).includes('zustimmen und schliessen') ||
    clean(textOf(el)).includes('alle akzeptieren') ||
    clean(textOf(el)) === 'akzeptieren' ||
    clean(el.getAttribute('aria-label')) === 'schliessen' ||
    clean(textOf(el)) === 'schliessen' ||
    clean(textOf(el)) === 'nein, danke' ||
    clean(textOf(el)) === 'nicht mehr anzeigen' ||
    clean(textOf(el)) === 'auswahl bestatigen' ||
    clean(textOf(el)) === 'ok' ||
    clean(textOf(el)) === 'verstanden'
  ));
  if (exactClose) {
    exactClose.click();
    return textOf(exactClose) || exactClose.getAttribute('aria-label') || 'closed';
  }
  return '';
})()
"""
    clicked: list[str] = []
    for _ in range(5):
        try:
            result = eval_js(port, expression).strip()
        except Exception:
            break
        if not result:
            break
        clicked.append(result)
        time.sleep(0.4)
    return ", ".join(clicked)


def wait_for_chrome(port: int, timeout: float = 20.0) -> None:
    wait_until(lambda: chrome_debug_available(port), timeout, "Chrome debug endpoint did not start", interval=0.5)


def cleanup_old_batch_run_profiles(root: Path, max_age_days: int = 7) -> None:
    parent = root / BATCH_RUN_PROFILE_DIR_NAME
    if not parent.exists():
        return
    cutoff = time.time() - max_age_days * 24 * 60 * 60
    for child in parent.iterdir():
        try:
            if child.is_dir() and child.stat().st_mtime < cutoff:
                shutil.rmtree(child, ignore_errors=True)
        except OSError:
            pass


def new_batch_run_profile(root: Path) -> Path:
    cleanup_old_batch_run_profiles(root)
    stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    return root / BATCH_RUN_PROFILE_DIR_NAME / f"run-{stamp}-{os.getpid()}"


def package_list_visible(port: int) -> bool:
    try:
        text = page_text(port)
    except Exception:
        return False
    return "Alle Pakete" in text and bool(parse_strato_packages(text))


def login_page_visible(port: int) -> bool:
    try:
        text = page_text(port)
    except Exception:
        return False
    if "Benutzername oder Kundennummer" in text or "Passwort" in text or "Sitzung beendet" in text:
        return True
    try:
        state = login_form_state(port)
    except Exception:
        return False
    return bool(state.get("password_present") or state.get("identity_present"))


def login_retry_message_visible(port: int) -> bool:
    try:
        text = page_text(port).casefold()
    except Exception:
        return False
    return (
        "zugangsdaten nicht erkannt" in text
        or "überprüfen sie ihre eingaben" in text
        or "ueberpruefen sie ihre eingaben" in text
    )


def login_form_state(port: int) -> dict:
    expression = r"""
(() => {
""" + normalize_js_helpers() + r"""
  const inputTypesToSkip = new Set(['hidden', 'button', 'submit', 'reset', 'checkbox', 'radio', 'image']);
  const inputs = [...document.querySelectorAll('input')]
    .filter((el) => visible(el) && !inputTypesToSkip.has(clean(el.getAttribute('type') || 'text')));
  const password = inputs.find((el) => clean(el.getAttribute('type')) === 'password') || null;
  const identityCandidates = inputs.filter((el) => el !== password);
  const identity = identityCandidates.find((el) => {
    const haystack = clean([
      el.getAttribute('name'),
      el.getAttribute('id'),
      el.getAttribute('autocomplete'),
      el.getAttribute('placeholder'),
      el.getAttribute('aria-label')
    ].join(' '));
    return haystack.includes('benutzer') ||
      haystack.includes('kunde') ||
      haystack.includes('user') ||
      haystack.includes('login') ||
      haystack.includes('email');
  }) || identityCandidates[0] || null;
  if (identity) identity.focus();
  if (password) password.focus();
  return JSON.stringify({
    identity_present: Boolean(identity),
    identity_filled: Boolean(identity && String(identity.value || '').trim()),
    password_present: Boolean(password),
    password_filled: Boolean(password && String(password.value || '')),
    retry_visible: clean(document.body ? document.body.innerText || '' : '').includes('zugangsdaten nicht erkannt')
  });
})()
"""
    try:
        return json.loads(eval_js(port, expression) or "{}")
    except Exception:
        return {}


def wait_for_saved_login_fields(port: int, timeout: float = 10.0) -> dict:
    deadline = time.monotonic() + timeout
    last: dict = {}
    while time.monotonic() < deadline:
        close_strato_overlays(port)
        last = login_form_state(port)
        if last.get("identity_filled") and last.get("password_filled"):
            return last
        time.sleep(0.5)
    return last


def fill_login_fields(port: int, credentials: LoginCredentials) -> dict:
    expression = r"""
(() => {
""" + normalize_js_helpers() + r"""
  const identityValue = """ + json.dumps(credentials.identity) + r""";
  const passwordValue = """ + json.dumps(credentials.password) + r""";
  const inputTypesToSkip = new Set(['hidden', 'button', 'submit', 'reset', 'checkbox', 'radio', 'image']);
  const inputs = [...document.querySelectorAll('input')]
    .filter((el) => visible(el) && !inputTypesToSkip.has(clean(el.getAttribute('type') || 'text')));
  const password = inputs.find((el) => clean(el.getAttribute('type')) === 'password') || null;
  const identityCandidates = inputs.filter((el) => el !== password);
  const identity = identityCandidates.find((el) => {
    const haystack = clean([
      el.getAttribute('name'),
      el.getAttribute('id'),
      el.getAttribute('autocomplete'),
      el.getAttribute('placeholder'),
      el.getAttribute('aria-label')
    ].join(' '));
    return haystack.includes('benutzer') ||
      haystack.includes('kunde') ||
      haystack.includes('user') ||
      haystack.includes('login') ||
      haystack.includes('email');
  }) || identityCandidates[0] || null;
  const setValue = (el, value) => {
    if (!el) return false;
    el.focus();
    const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
    const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
    if (descriptor && descriptor.set) {
      descriptor.set.call(el, value);
    } else {
      el.value = value;
    }
    el.dispatchEvent(new Event('input', { bubbles: true }));
    el.dispatchEvent(new Event('change', { bubbles: true }));
    return true;
  };
  const identitySet = setValue(identity, identityValue);
  const passwordSet = setValue(password, passwordValue);
  if (password) password.focus();
  return JSON.stringify({
    identity_present: Boolean(identity),
    identity_filled: Boolean(identity && String(identity.value || '').trim()),
    password_present: Boolean(password),
    password_filled: Boolean(password && String(password.value || '')),
    identity_set: identitySet,
    password_set: passwordSet
  });
})()
"""
    try:
        return json.loads(eval_js(port, expression) or "{}")
    except Exception as exc:
        raise RuntimeError(f"STRATO Login-Felder konnten nicht gefuellt werden: {exc}") from exc


def click_saved_login(port: int) -> str:
    expression = r"""
(() => {
""" + normalize_js_helpers() + r"""
  const password = document.querySelector('input[type="password"]');
  if (password) {
    password.focus();
  }
  const candidates = [...document.querySelectorAll('button,input[type="submit"],input[type="button"],a,[role="button"]')];
  const button = candidates.find((el) => visible(el) && (
      clean(textOf(el)).includes('einloggen') ||
      clean(textOf(el)).includes('login') ||
      clean(textOf(el)).includes('anmelden') ||
      clean(el.getAttribute('name')).includes('login')
    ));
  if (!button) return '';
  button.scrollIntoView({ block: 'center', inline: 'center' });
  button.click();
  return textOf(button) || button.getAttribute('name') || 'login';
})()
"""
    return eval_js(port, expression).strip()


def wait_for_login_result(port: int, timeout: float, ignore_retry_seconds: float = 2.5) -> str:
    started = time.monotonic()
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        close_strato_overlays(port)
        if package_list_visible(port):
            return "packages"
        if time.monotonic() - started >= ignore_retry_seconds and login_retry_message_visible(port):
            return "retry"
        time.sleep(0.5)
    if package_list_visible(port):
        return "packages"
    if login_retry_message_visible(port):
        return "retry"
    if login_page_visible(port):
        return "login"
    return "timeout"


def perform_login_attempt(
    port: int,
    root: Path,
    chrome_path: Path,
    profile_dir: Path,
    login_url: str,
    attempt: int,
    force_prompt: bool = False,
) -> str:
    suffix = "" if attempt == 1 else f" (Versuch {attempt})"
    log(f"Loginseite erkannt; bereite Login vor{suffix}")
    state = wait_for_saved_login_fields(port, 10 if attempt == 1 else 3)
    if force_prompt or not state.get("identity_filled") or not state.get("password_filled"):
        reason = (
            "STRATO hat die gespeicherten Zugangsdaten nicht akzeptiert. "
            "Bitte Benutzername/Kundennummer und Passwort neu eingeben."
            if force_prompt
            else "STRATO hat die Login-Felder nicht automatisch gefuellt. "
            "Bitte Benutzername/Kundennummer und Passwort eingeben; sie werden lokal verschluesselt gespeichert."
        )
        SENSITIVE_INPUT_ACTIVE.set()
        try:
            credentials = load_or_prompt_login_credentials(root, reason=reason, force_prompt=force_prompt)
            ensure_login_page_for_credentials(port, root, chrome_path, profile_dir, login_url)
            log("STRATO Login-Felder mit lokal verschluesselten Zugangsdaten fuellen")
            state = fill_login_fields(port, credentials)
            if not state.get("identity_filled") or not state.get("password_filled"):
                raise RuntimeError("STRATO Login-Felder konnten nicht vollstaendig gefuellt werden.")
            try:
                page_command(port, "Page.bringToFront")
            except Exception:
                pass
            SENSITIVE_INPUT_ACTIVE.clear()
        except Exception:
            raise
    else:
        log("STRATO Login-Felder sind bereits durch das Browserprofil gefuellt")

    clicked = click_saved_login(port)
    if not clicked:
        raise RuntimeError("Login-Schaltflaeche nicht gefunden.")
    return wait_for_login_result(port, 12 if attempt < 3 else 35)


def start_controlled_browser_with_fallback(
    port: int,
    chrome_path: Path,
    profile_dir: Path,
    login_url: str,
    downloads: Path,
    root: Path,
) -> Path:
    run_profile = new_batch_run_profile(root)
    fallback_profile = root / BATCH_FALLBACK_PROFILE_DIR_NAME
    candidates: list[tuple[str, Path]] = [("frisches Tagesprofil", run_profile)]
    if profile_dir.resolve() != run_profile.resolve():
        candidates.append(("Steuerprofil", profile_dir))
    if fallback_profile.resolve() not in {path.resolve() for _label, path in candidates}:
        candidates.append(("dauerhaftes Batch-Profil", fallback_profile))

    last_error: BaseException | None = None
    for index, (label, candidate) in enumerate(candidates):
        log(f"Steuerbaren Browser sichtbar starten ({label}: {candidate})")
        health_set("browser_profile_label", label)
        health_set("browser_profile_path", str(candidate))
        health_set("fresh_batch_profile_used", candidate.resolve() == run_profile.resolve())
        process: subprocess.Popen | None = None
        try:
            health_set("browser_started", True)
            process = launch_controlled_chrome(chrome_path, candidate, port, login_url, downloads, start_visible=True)
            wait_for_chrome(port, timeout=35)
            reset_debug_browser_to_url(port, login_url)
            normalize_debug_browser_window(port)
            wait_until(lambda: login_page_visible(port) or package_list_visible(port), 45, "login page did not load")
            normalize_debug_browser_window(port)
            return candidate
        except Exception as exc:
            last_error = exc
            log(f"Steuerbrowser wurde nicht bereit mit {label}: {exc}")
            stop_launched_browser_process(process, label)
            if index < len(candidates) - 1:
                close_debug_browser(port)
            time.sleep(1.0)

    if last_error:
        raise last_error
    raise TimeoutError("Chrome debug endpoint did not start")


def ensure_login_page_for_credentials(
    port: int,
    root: Path,
    chrome_path: Path,
    profile_dir: Path,
    login_url: str,
) -> None:
    if login_page_visible(port) or package_list_visible(port):
        return
    log("Steuerbarer STRATO-Tab nicht mehr erreichbar; starte Browser fuer Login neu")
    close_debug_browser(port)
    downloads = root / "downloads"
    start_controlled_browser_with_fallback(port, chrome_path, profile_dir, login_url, downloads, root)


def ensure_browser_ready(port: int, chrome_path: Path, profile_dir: Path, login_url: str, root: Path) -> None:
    downloads = root / "downloads"
    if not chrome_debug_available(port):
        start_controlled_browser_with_fallback(port, chrome_path, profile_dir, login_url, downloads, root)
    else:
        health_set("used_existing_browser", True)
        close_extra_debug_tabs(port)
        normalize_debug_browser_window(port)
        if not package_list_visible(port):
            reset_debug_browser_to_url(port, login_url)

    if package_list_visible(port):
        return

    wait_until(lambda: login_page_visible(port) or package_list_visible(port), 45, "login page did not load")
    if package_list_visible(port):
        return

    max_attempts = 3
    force_prompt = False
    for attempt in range(1, max_attempts + 1):
        result = perform_login_attempt(port, root, chrome_path, profile_dir, login_url, attempt, force_prompt=force_prompt)
        if result == "packages":
            return
        if result == "retry":
            if attempt < max_attempts:
                log("STRATO meldet Zugangsdaten nicht erkannt; frage Zugangsdaten neu ab")
                force_prompt = True
                time.sleep(1.5)
                continue
            raise RuntimeError("STRATO meldet weiterhin: Zugangsdaten nicht erkannt. " + LOGIN_REPAIR_HINT)
        if attempt < max_attempts:
            log("Paketliste noch nicht sichtbar; wiederhole Login")
            force_prompt = False
            time.sleep(1.0)
            continue
        raise TimeoutError("package list did not appear after login")


def go_all_packages(port: int) -> None:
    expression = r"""
(() => {
  const u = new URL(location.href);
  const sid = u.searchParams.get('sessionID');
  if (!sid) return '';
  const target = u.origin + u.pathname + '?sessionID=' + encodeURIComponent(sid) + '&cID=0&node=kds_CustomerEntryPage';
  if (location.href !== target) location.href = target;
  return target;
})()
"""
    eval_js(port, expression)

    def ready() -> bool:
        activate_single_tab_by_query(port, "0", "kds_CustomerEntryPage")
        close_strato_overlays(port)
        text = page_text(port)
        return "Alle Pakete" in text and bool(parse_strato_packages(text))

    wait_until(ready, 15, "package list did not load")


def package_links(port: int) -> list[dict]:
    return package_page_snapshot(port)[1]


def package_page_snapshot(port: int) -> tuple[str, list[dict]]:
    expression = r"""
(async () => {
""" + normalize_js_helpers() + r"""
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
  const texts = [];
  const rows = new Map();
  const collect = () => {
    texts.push(document.body ? document.body.innerText || '' : '');
    for (const a of [...document.querySelectorAll('a')]) {
      const item = { text: textOf(a), href: a.href || '' };
      if (clean(item.text).startsWith('strato ') && item.href.includes('cID=') && item.href.includes('node=')) {
        rows.set(clean(item.text) + '|' + item.href, item);
      }
    }
  };
  const scrollableItems = [document.scrollingElement, ...document.querySelectorAll('main,section,div,table,tbody')]
    .filter(Boolean)
    .filter((el, index, arr) => arr.indexOf(el) === index)
    .filter((el) => el.scrollHeight > el.clientHeight + 30);
  if (!scrollableItems.length && document.scrollingElement) scrollableItems.push(document.scrollingElement);
  collect();
  for (const el of scrollableItems) {
    const max = Math.max(0, el.scrollHeight - el.clientHeight);
    const steps = Math.max(1, Math.min(12, Math.ceil(max / Math.max(300, window.innerHeight * 0.8))));
    for (let step = 0; step <= steps; step += 1) {
      const y = Math.round((max * step) / steps);
      el.scrollTop = y;
      if (el === document.scrollingElement) window.scrollTo(0, y);
      await delay(120);
      collect();
    }
  }
  return JSON.stringify({ text: texts.join('\n'), links: [...rows.values()] });
})()
"""
    data = json.loads(eval_js(port, expression) or "{}")
    return str(data.get("text") or ""), list(data.get("links") or [])


def known_config_packages(root: Path) -> list:
    packages = []
    for path in (user_config_path(), config_path(root / "archive"), config_path(root)):
        data = load_json_file(path)
        for item in data.get("strato_packages", []):
            if isinstance(item, dict):
                package = package_from_dict(item)
                if package:
                    packages.append(package)
    return dedupe_strato_packages(packages)


def safe_folder_name(label: str) -> str:
    cleaned = re.sub(r'[<>:"/\\|?*]+', "_", label).strip(" .")
    cleaned = re.sub(r"\s+", " ", cleaned)
    return cleaned or "STRATO Paket"


def find_package_link(links: list[dict], package_name: str) -> str:
    wanted = " ".join(package_name.split()).casefold()
    for item in links:
        if " ".join(str(item.get("text", "")).split()).casefold() == wanted:
            return str(item.get("href") or "")
    for item in links:
        if wanted in " ".join(str(item.get("text", "")).split()).casefold():
            return str(item.get("href") or "")
    return ""


def navigate(port: int, url: str) -> None:
    eval_js(port, "location.href = " + json.dumps(url))


def current_page_location(port: int) -> dict[str, str]:
    expression = r"""
(() => JSON.stringify({
  href: location.href,
  cID: new URL(location.href).searchParams.get('cID') || '',
  node: new URL(location.href).searchParams.get('node') || '',
  readyState: document.readyState
}))()
"""
    try:
        return json.loads(eval_js(port, expression) or "{}")
    except (json.JSONDecodeError, RuntimeError):
        return {}


def open_package(port: int, href: str, order_number: str) -> None:
    href_parts = urllib.parse.urlsplit(href)
    href_query = urllib.parse.parse_qs(href_parts.query)
    expected_cid = (href_query.get("cID") or [""])[0]
    expected_node = (href_query.get("node") or [""])[0]
    navigate(port, href)

    def loaded() -> bool:
        if expected_cid:
            activate_single_tab_by_query(port, expected_cid, expected_node)
        close_strato_overlays(port)
        location = current_page_location(port)
        ready_state = str(location.get("readyState") or "")
        if ready_state not in {"interactive", "complete"}:
            return False
        if expected_cid and str(location.get("cID") or "") == expected_cid:
            node = str(location.get("node") or "")
            if expected_node and node == expected_node:
                return True
            if node and node != "kds_CustomerEntryPage":
                return True
        text = page_text(port)
        return bool(order_number and order_number in text)

    wait_until(loaded, 15, f"package {order_number} did not load")


def click_statistic(port: int) -> str:
    expression = r"""
(async () => {
""" + normalize_js_helpers() + r"""
  const findStat = () => {
    const candidates = [...document.querySelectorAll('a,button,[role="button"],input[type="button"],input[type="submit"]')];
    return candidates.find((el) => visible(el) && clean(textOf(el)).includes('statistik anzeigen'))
      || candidates.find((el) => visible(el) && clean(textOf(el)).includes('statistik verwalten'))
      || candidates.find((el) => (el.href || '').includes('node=kds_Statistic'));
  };
  const stat = await scanPage(findStat);
  if (!stat) return '';
  const href = stat.href || stat.getAttribute('href') || '';
  if (href && href !== '#') {
    location.href = href;
  } else {
    stat.scrollIntoView({ block: 'center', inline: 'center' });
    stat.click();
  }
  return textOf(stat) || href;
})()
"""
    return eval_js(port, expression).strip()


def wait_for_logfiles_link(port: int) -> None:
    def ready() -> bool:
        close_strato_overlays(port)
        expression = r"""
(async () => {
""" + normalize_js_helpers() + r"""
  const findLogfiles = () => [...document.querySelectorAll('a,button,[role="button"]')]
    .find((el) => (el.href || '').includes('node=LogfileDownload2') || clean(textOf(el)) === 'logfiles');
  return Boolean(await scanPage(findLogfiles));
})()
"""
        return eval_js(port, expression).strip().lower() == "true"

    wait_until(ready, 15, "logfiles link did not appear")


def click_logfiles(port: int) -> str:
    expression = r"""
(async () => {
""" + normalize_js_helpers() + r"""
  const findLogfiles = () => {
    const candidates = [...document.querySelectorAll('a,button,[role="button"]')];
    return candidates.find((el) => (el.href || '').includes('node=LogfileDownload2'))
      || candidates.find((el) => clean(textOf(el)) === 'logfiles');
  };
  const logfiles = await scanPage(findLogfiles);
  if (!logfiles) return '';
  const href = logfiles.href || logfiles.getAttribute('href') || '';
  if (href && href !== '#') {
    location.href = href;
  } else {
    logfiles.scrollIntoView({ block: 'center', inline: 'center' });
    logfiles.click();
  }
  return textOf(logfiles) || href;
})()
"""
    result = eval_js(port, expression).strip()
    if result:
        health_increment("logfiles_click_count")
    return result


def wait_for_download_button(port: int) -> None:
    def ready() -> bool:
        close_strato_overlays(port)
        normalize_debug_browser_window(port)
        expression = r"""
(async () => {
""" + normalize_js_helpers() + r"""
  const findDownload = () => [...document.querySelectorAll('input[type="submit"],button,a,input[type="button"]')]
    .find((el) => clean(textOf(el)) === 'logfile download' || clean(textOf(el)).includes('logfile download'));
  const button = await scanPage(findDownload);
  if (!button) return false;
  button.scrollIntoView({ block: 'center', inline: 'center' });
  return true;
})()
"""
        return eval_js(port, expression).strip().lower() == "true"

    wait_until(ready, 30, "logfile download page did not load")
    health_increment("download_page_ready_count")


def snapshot_files(folder: Path) -> dict[str, float]:
    folder.mkdir(parents=True, exist_ok=True)
    return {item.name: item.stat().st_mtime for item in folder.iterdir() if item.is_file()}


def completed_new_files(folder: Path, before: dict[str, float]) -> list[Path]:
    found: list[Path] = []
    for item in folder.iterdir():
        if not item.is_file():
            continue
        if item.suffix.lower() in PARTIAL_SUFFIXES:
            continue
        mtime = item.stat().st_mtime
        if item.name not in before or mtime > before[item.name] + 0.1:
            found.append(item)
    return sorted(found, key=lambda p: p.stat().st_mtime, reverse=True)


def click_download(port: int) -> str:
    expression = r"""
(async () => {
""" + normalize_js_helpers() + r"""
  const findDownload = () => {
    const candidates = [...document.querySelectorAll('input[type="submit"],button,a,input[type="button"]')];
    return candidates.find((el) => clean(textOf(el)) === 'logfile download')
      || candidates.find((el) => clean(textOf(el)).includes('logfile download'));
  };
  const button = await scanPage(findDownload);
  if (!button) return '';
  button.scrollIntoView({ block: 'center', inline: 'center' });
  button.click();
  return textOf(button);
})()
"""
    return eval_js(port, expression).strip()


def visible_controls(port: int, limit: int = 80) -> list[str]:
    expression = r"""
(() => {
""" + normalize_js_helpers() + r"""
  const items = [];
  const seen = new Set();
  for (const el of [...document.querySelectorAll('a,button,[role="button"],input[type="button"],input[type="submit"]')]) {
    if (!visible(el)) continue;
    const label = textOf(el);
    const href = el.href || el.getAttribute('href') || '';
    const value = label || href;
    if (!value) continue;
    const key = clean(value);
    if (seen.has(key)) continue;
    seen.add(key);
    items.push(label ? label : href);
    if (items.length >= 80) break;
  }
  return JSON.stringify(items);
})()
"""
    try:
        data = json.loads(eval_js(port, expression) or "[]")
    except Exception as exc:
        return [f"Klicktexte konnten nicht gelesen werden: {exc!r}"]
    return [str(item) for item in data[:limit]]


def unique_target(path: Path) -> Path:
    if not path.exists():
        return path
    stem = path.stem
    suffix = path.suffix
    for index in range(1, 1000):
        candidate = path.with_name(f"{stem} ({index}){suffix}")
        if not candidate.exists():
            return candidate
    return path.with_name(f"{stem}-{int(time.time())}{suffix}")


def download_form(port: int) -> dict:
    expression = r"""
(async () => {
""" + normalize_js_helpers() + r"""
  const findDownload = () => {
    const candidates = [...document.querySelectorAll('input[type="submit"],button,a,input[type="button"]')];
    return candidates.find((el) => clean(textOf(el)) === 'logfile download')
      || candidates.find((el) => clean(textOf(el)).includes('logfile download'));
  };
  const button = await scanPage(findDownload);
  if (!button) return JSON.stringify({});
  const form = button.closest('form');
  if (!form) return JSON.stringify({});
  const fields = [];
  for (const el of [...form.elements]) {
    if (!el.name || el.disabled) continue;
    const type = (el.type || '').toLowerCase();
    if ((type === 'checkbox' || type === 'radio') && !el.checked) continue;
    fields.push([el.name, el.value || '']);
  }
  if (button.name) {
    fields.push([button.name, button.value || textOf(button)]);
  }
  return JSON.stringify({
    action: form.action || location.href,
    method: (form.method || 'GET').toUpperCase(),
    url: location.href,
    fields
  });
})()
"""
    form = json.loads(eval_js(port, expression) or "{}")
    if form.get("action"):
        health_increment("download_form_count")
    return form


def chrome_cookie_header(port: int, url: str) -> str:
    result = page_command(port, "Network.getCookies", {"urls": [url]})
    cookies = result.get("cookies") or []
    return "; ".join(f"{cookie.get('name')}={cookie.get('value')}" for cookie in cookies if cookie.get("name"))


def direct_download(port: int, target_file: Path) -> Path:
    form = download_form(port)
    action = str(form.get("action") or "")
    if not action:
        raise RuntimeError("Download-Formular nicht gefunden.")
    method = str(form.get("method") or "GET").upper()
    fields = [(str(name), str(value)) for name, value in form.get("fields", [])]
    encoded = urllib.parse.urlencode(fields).encode("utf-8")
    url = action
    data = None
    if method == "GET":
        parts = urllib.parse.urlsplit(url)
        query = urllib.parse.urlencode(urllib.parse.parse_qsl(parts.query, keep_blank_values=True) + fields)
        url = urllib.parse.urlunsplit((parts.scheme, parts.netloc, parts.path, query, parts.fragment))
    else:
        data = encoded

    cookie_header = chrome_cookie_header(port, str(form.get("url") or action))
    request = urllib.request.Request(
        url,
        data=data,
        headers={
            "Cookie": cookie_header,
            "User-Agent": "Mozilla/5.0",
            "Referer": str(form.get("url") or action),
        },
        method="POST" if data is not None else "GET",
    )
    with urllib.request.urlopen(request, timeout=60) as response:
        body = response.read()
        content_type = str(response.headers.get("Content-Type", ""))
    if not body:
        raise RuntimeError("Download-Antwort war leer.")
    if "text/html" in content_type.lower() and b"<html" in body[:512].lower():
        raise RuntimeError("Download lieferte HTML statt Logdatei.")
    target = unique_target(target_file)
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_bytes(body)
    return target


def suggested_log_filename(port: int) -> str:
    expression = r"""
(() => {
  const values = [...document.querySelectorAll('input')]
    .map((input) => (input.value || '').trim())
    .filter(Boolean);
  const dates = values
    .map((value) => value.match(/^(\d{2})\.(\d{2})\.(\d{4})$/))
    .filter(Boolean)
    .map((m) => `${m[3]}${m[2]}${m[1]}`);
  if (dates.length >= 2) {
    return `access_log_${dates[0]}-${dates[1]}.gz`;
  }
  return 'access_log.gz';
})()
"""
    filename = eval_js(port, expression).strip()
    if not re.match(r"^[A-Za-z0-9._ -]+\.gz$", filename):
        return "access_log.gz"
    return filename


def powershell_quote(value: str) -> str:
    return "'" + value.replace("'", "''") + "'"


def fill_windows_save_dialog(target_file: Path) -> bool:
    # Chrome for Testing still opens the native Save As dialog for STRATO.
    # Pasting the full path into the selected "Dateiname" field stores it in
    # the package folder without needing to inspect localized dialog controls.
    script = "\n".join(
        [
            "Add-Type -AssemblyName System.Windows.Forms",
            "$ws = New-Object -ComObject WScript.Shell",
            "$activated = $false",
            "foreach ($i in 1..30) {",
            "  if ($ws.AppActivate('Speichern unter') -or $ws.AppActivate('Save As')) { $activated = $true; break }",
            "  Start-Sleep -Milliseconds 500",
            "}",
            "if (-not $activated) { Write-Output 'activated=False'; exit 3 }",
            "Start-Sleep -Milliseconds 800",
            f"Set-Clipboard -Value {powershell_quote(str(target_file))}",
            "[System.Windows.Forms.SendKeys]::SendWait('%n')",
            "Start-Sleep -Milliseconds 150",
            "[System.Windows.Forms.SendKeys]::SendWait('^a')",
            "Start-Sleep -Milliseconds 150",
            "[System.Windows.Forms.SendKeys]::SendWait('^v')",
            "Start-Sleep -Milliseconds 150",
            "[System.Windows.Forms.SendKeys]::SendWait('%s')",
            "Start-Sleep -Milliseconds 500",
            "[System.Windows.Forms.SendKeys]::SendWait('{ENTER}')",
            "Write-Output 'activated=True'",
        ]
    )
    completed = subprocess.run(
        ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script],
        check=False,
        capture_output=True,
        text=True,
        timeout=25,
    )
    return "activated=True" in (completed.stdout or "")


def wait_for_download(folder: Path, before: dict[str, float], timeout: float) -> list[Path]:
    deadline = time.monotonic() + timeout
    stable_since: float | None = None
    last_seen: list[Path] = []
    while time.monotonic() < deadline:
        partials = [p for p in folder.iterdir() if p.is_file() and p.suffix.lower() in PARTIAL_SUFFIXES]
        new_files = completed_new_files(folder, before)
        if new_files and not partials:
            if [p.name for p in new_files] == [p.name for p in last_seen]:
                if stable_since is not None and time.monotonic() - stable_since >= 1.0:
                    return new_files
            else:
                stable_since = time.monotonic()
                last_seen = new_files
        else:
            stable_since = None
            last_seen = []
        time.sleep(0.5)
    return completed_new_files(folder, before)


def process_package(port: int, root: Path, link: str, package) -> PackageResult:
    label = package.label
    folder = root / safe_folder_name(label)
    log(f"Paket: {label}")
    open_package(port, link, package.order_number)

    statistic = click_statistic(port)
    if not statistic:
        log("  keine Statistik-Funktion gefunden, weiter zum naechsten Paket")
        return PackageResult(label, package.order_number, str(folder), "no_statistics", "Geprueft, aber keine Statistik-Funktion gefunden.", [])

    log(f"  Statistik: {statistic[:100]}")
    wait_for_logfiles_link(port)
    logfiles = click_logfiles(port)
    if not logfiles:
        return PackageResult(label, package.order_number, str(folder), "error", "Logfiles-Link nicht gefunden.", [])

    log(f"  Logfiles: {logfiles[:100]}")
    normalize_debug_browser_window(port)
    wait_for_download_button(port)
    target_file = folder / suggested_log_filename(port)
    if target_file.exists():
        log(f"  schon vorhanden, nicht erneut gespeichert: {target_file.name}")
        return PackageResult(label, package.order_number, str(folder), "already_exists", "Datei war bereits vorhanden.", [str(target_file)])
    try:
        saved = direct_download(port, target_file)
    except (OSError, urllib.error.URLError, RuntimeError) as exc:
        return PackageResult(label, package.order_number, str(folder), "error", f"Direkter Download fehlgeschlagen: {exc}", [])
    log(f"  gespeichert: {saved.name}")
    return PackageResult(label, package.order_number, str(folder), "downloaded", "OK", [str(saved)])


def run(port: int, root: Path) -> list[PackageResult]:
    root.mkdir(parents=True, exist_ok=True)
    log("Oeffne Paketliste")
    go_all_packages(port)
    text, links = package_page_snapshot(port)
    packages = parse_strato_packages(text)
    known_packages = known_config_packages(root)
    if len(known_packages) > len(packages):
        packages = known_packages
    health_set("package_count", len(packages))
    log(f"{len(packages)} Paket(e) gefunden")

    results: list[PackageResult] = []
    for package in packages:
        link = find_package_link(links, package.name)
        if not link:
            text, refreshed_links = package_page_snapshot(port)
            if refreshed_links:
                links = refreshed_links
            link = find_package_link(links, package.name)
        if not link:
            results.append(PackageResult(package.label, package.order_number, str(root), "error", "Paket-Link nicht gefunden.", []))
            continue
        try:
            results.append(process_package(port, root, link, package))
        except Exception as exc:
            log(f"  Fehler: {exc}")
            results.append(PackageResult(package.label, package.order_number, str(root / safe_folder_name(package.label)), "error", repr(exc), []))
        finally:
            try:
                go_all_packages(port)
            except Exception:
                pass

    return results


def diagnose_controls(port: int, root: Path) -> list[ControlCheckResult]:
    root.mkdir(parents=True, exist_ok=True)
    log("Diagnose: Paketliste oeffnen")
    go_all_packages(port)
    text, links = package_page_snapshot(port)
    packages = parse_strato_packages(text)
    known_packages = known_config_packages(root)
    if len(known_packages) > len(packages):
        packages = known_packages
    health_set("package_count", len(packages))
    log(f"Diagnose: {len(packages)} Paket(e) gefunden")

    results: list[ControlCheckResult] = []
    for package in packages:
        log(f"Diagnose Paket: {package.label}")
        try:
            link = find_package_link(links, package.name)
            if not link:
                text, refreshed_links = package_page_snapshot(port)
                if refreshed_links:
                    links = refreshed_links
                link = find_package_link(links, package.name)
            if not link:
                results.append(ControlCheckResult(
                    package.label,
                    package.order_number,
                    "error",
                    "Paket-Link nicht gefunden.",
                    controls=visible_controls(port),
                ))
                continue

            open_package(port, link, package.order_number)
            package_controls = visible_controls(port)
            statistic = click_statistic(port)
            if not statistic:
                results.append(ControlCheckResult(
                    package.label,
                    package.order_number,
                    "no_statistics",
                    "Keine Statistik-Funktion erkannt. Wenn dieses Paket keine Statistik besitzt, ist das in Ordnung.",
                    controls=package_controls,
                ))
                continue

            wait_for_logfiles_link(port)
            statistic_controls = visible_controls(port)
            logfiles = click_logfiles(port)
            if not logfiles:
                results.append(ControlCheckResult(
                    package.label,
                    package.order_number,
                    "error",
                    "Statistik erkannt, aber Logfiles-Link nicht erkannt.",
                    statistic=statistic,
                    controls=statistic_controls,
                ))
                continue

            normalize_debug_browser_window(port)
            time.sleep(1.0)
            wait_for_download_button(port)
            download_controls = visible_controls(port)
            form = download_form(port)
            action = str(form.get("action") or "")
            if not action:
                results.append(ControlCheckResult(
                    package.label,
                    package.order_number,
                    "error",
                    "Logfiles-Seite erkannt, aber Downloadformular nicht erkannt.",
                    statistic=statistic,
                    logfiles=logfiles,
                    controls=download_controls,
                ))
                continue

            filename = suggested_log_filename(port)
            results.append(ControlCheckResult(
                package.label,
                package.order_number,
                "ok",
                f"Erkennung funktioniert. Erwarteter Dateiname: {filename}",
                statistic=statistic,
                logfiles=logfiles,
                download_form=action,
                controls=download_controls,
            ))
        except Exception as exc:
            results.append(ControlCheckResult(
                package.label,
                package.order_number,
                "error",
                repr(exc),
                controls=visible_controls(port),
            ))
        finally:
            try:
                go_all_packages(port)
            except Exception:
                pass

    return results


def write_reports(root: Path, results: list[PackageResult]) -> None:
    data = [asdict(result) for result in results]
    (root / "strato_batch_report.json").write_text(json.dumps(data, indent=2), encoding="utf-8")
    lines = ["STRATO Log Batch Report", time.strftime("%Y-%m-%d %H:%M:%S"), ""]
    for result in results:
        lines.append(f"{result.status.upper()}: {result.package}")
        lines.append(f"  Ordner: {result.folder}")
        if result.detail:
            lines.append(f"  Detail: {result.detail}")
        for file_name in result.files or []:
            lines.append(f"  Datei: {file_name}")
        lines.append("")
    (root / "strato_batch_report.txt").write_text("\n".join(lines), encoding="utf-8")


def write_startup_error_report(root: Path, exc: BaseException) -> None:
    detail = str(exc) or repr(exc)
    write_reports(
        root,
        [
            PackageResult(
                "STRATO Login / Startphase",
                "",
                str(root),
                "error",
                detail,
                [],
            )
        ],
    )


def morning_window_status(started_at: str) -> tuple[bool, str]:
    try:
        started = datetime.fromisoformat(started_at)
    except ValueError:
        started = datetime.now()
    hour_minute = (started.hour, started.minute)
    in_window = (5, 30) <= hour_minute <= (8, 30)
    return in_window, started.strftime("%Y-%m-%d %H:%M:%S")


def add_run_check(checks: list[dict[str, str]], name: str, status: str, detail: str) -> None:
    checks.append({"name": name, "status": status, "detail": detail})


def run_check_overall(checks: list[dict[str, str]]) -> str:
    statuses = {check["status"] for check in checks}
    if "error" in statuses:
        return "error"
    if "warning" in statuses:
        return "warning"
    return "ok"


def write_run_check(
    root: Path,
    scheduled_task: bool,
    diagnose: bool,
    exit_code: int,
    results: list[PackageResult] | list[ControlCheckResult],
    fatal_detail: str = "",
) -> None:
    checks: list[dict[str, str]] = []
    started_at = str(RUN_HEALTH.get("started_at") or "")
    in_morning, started_text = morning_window_status(started_at)
    mode = "diagnose" if diagnose else "download"

    if scheduled_task:
        add_run_check(checks, "Geplanter Aufgabenlauf", "ok", "Der Batch wurde vom Aufgaben-Wrapper mit --scheduled-task gestartet.")
        if in_morning:
            add_run_check(checks, "Morgen-Zeitfenster", "ok", f"Startzeit {started_text} liegt im Prueffenster 05:30-08:30.")
        else:
            add_run_check(checks, "Morgen-Zeitfenster", "warning", f"Startzeit {started_text} liegt ausserhalb 05:30-08:30; StartWhenAvailable oder manueller Start moeglich.")
    else:
        add_run_check(checks, "Geplanter Aufgabenlauf", "info", "Manueller Lauf; Morgen-Zeitfenster wird nicht bewertet.")

    profile_label = str(RUN_HEALTH.get("browser_profile_label") or "")
    profile_path = str(RUN_HEALTH.get("browser_profile_path") or "")
    if RUN_HEALTH.get("fresh_batch_profile_used"):
        add_run_check(checks, "Frisches Batch-Profil", "ok", f"Verwendet: {profile_label} ({profile_path})")
    elif RUN_HEALTH.get("used_existing_browser"):
        add_run_check(checks, "Frisches Batch-Profil", "warning", "Ein bereits laufender Debug-Browser wurde verwendet; fuer den echten Aufgabenlauf soll normalerweise ein frisches Tagesprofil starten.")
    elif RUN_HEALTH.get("browser_started"):
        add_run_check(checks, "Frisches Batch-Profil", "warning", f"Gestartet mit {profile_label or 'unbekanntem Profil'} ({profile_path or 'kein Pfad protokolliert'}).")
    else:
        add_run_check(checks, "Frisches Batch-Profil", "warning", "Kein selbst gestarteter Steuerbrowser protokolliert.")

    if RUN_HEALTH.get("window_maximized"):
        add_run_check(checks, "Browser maximiert", "ok", "Browserfenster wurde per Chrome DevTools maximiert.")
    elif RUN_HEALTH.get("window_maximize_attempted"):
        add_run_check(checks, "Browser maximiert", "warning", "Maximierung wurde versucht, aber nicht bestaetigt.")
    else:
        add_run_check(checks, "Browser maximiert", "warning", "Keine Maximierung protokolliert.")

    package_count = int(RUN_HEALTH.get("package_count") or 0)
    if package_count > 0:
        add_run_check(checks, "Paketliste erkannt", "ok", f"{package_count} Paket(e) erkannt.")
    else:
        add_run_check(checks, "Paketliste erkannt", "error" if exit_code else "warning", "Keine Paketliste protokolliert.")

    result_statuses = [getattr(result, "status", "") for result in results]
    if exit_code == 0 and all(status in {"downloaded", "already_exists", "no_statistics", "ok"} for status in result_statuses):
        add_run_check(checks, "Paket-Ergebnisse", "ok", "Alle Paket-Ergebnisse sind erfolgreich oder nachvollziehbar.")
    elif fatal_detail:
        add_run_check(checks, "Paket-Ergebnisse", "error", fatal_detail.splitlines()[0][:240])
    else:
        add_run_check(checks, "Paket-Ergebnisse", "error", f"Exitcode {exit_code}; Statusliste: {', '.join(result_statuses) or 'keine'}")

    needs_download_page = any(status in {"downloaded", "already_exists", "ok"} for status in result_statuses)
    if needs_download_page:
        if int(RUN_HEALTH.get("download_page_ready_count") or 0) > 0 and int(RUN_HEALTH.get("logfiles_click_count") or 0) > 0:
            add_run_check(checks, "Logfiles/Download erreicht", "ok", "Logfiles-Link und Downloadformular wurden erreicht.")
        else:
            add_run_check(checks, "Logfiles/Download erreicht", "warning", "Pakete waren erfolgreich, aber Logfiles-/Downloadzaehler sind unvollstaendig.")
    else:
        add_run_check(checks, "Logfiles/Download erreicht", "ok", "Kein Downloadformular erforderlich, weil keine Download-Pakete verarbeitet wurden.")

    overall = run_check_overall(checks)
    payload = {
        "name": "STRATO Morgenlauf-Pruefung",
        "created_at": datetime.now().isoformat(timespec="seconds"),
        "mode": mode,
        "scheduled_task": scheduled_task,
        "exit_code": exit_code,
        "overall": overall,
        "health": RUN_HEALTH,
        "checks": checks,
    }
    try:
        (root / RUN_CHECK_JSON_NAME).write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
        lines = [
            "STRATO Morgenlauf-Pruefung",
            f"Erzeugt: {payload['created_at']}",
            f"Modus: {mode}",
            f"Geplanter Lauf: {'ja' if scheduled_task else 'nein'}",
            f"Exitcode: {exit_code}",
            f"Gesamtstatus: {overall.upper()}",
            "",
        ]
        for check in checks:
            lines.append(f"{check['status'].upper()}: {check['name']}")
            lines.append(f"  {check['detail']}")
        (root / RUN_CHECK_TXT_NAME).write_text("\n".join(lines) + "\n", encoding="utf-8")
        log(f"Morgenlauf-Pruefung: {overall} ({root / RUN_CHECK_TXT_NAME})")
    except OSError as exc:
        log(f"Morgenlauf-Pruefung konnte nicht geschrieben werden: {exc}")


def write_diagnose_reports(root: Path, results: list[ControlCheckResult]) -> None:
    data = [asdict(result) for result in results]
    (root / "strato_erkennung_pruefung.json").write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
    lines = [
        "STRATO Erkennungs-Pruefung",
        time.strftime("%Y-%m-%d %H:%M:%S"),
        "",
        "Dieser Prueflauf laedt keine Logdateien herunter.",
        "Wenn ein Paket OK meldet, wurden Statistik, Logfiles und Downloadformular erkannt.",
        "Wenn ein Paket NO_STATISTICS meldet, wurde es geoeffnet, aber keine Statistik-Funktion erkannt.",
        "Bei ERROR bitte diese Datei an den Betreuer des Werkzeugs weitergeben.",
        "",
    ]
    for result in results:
        lines.append(f"{result.status.upper()}: {result.package}")
        if result.order_number:
            lines.append(f"  Auftragsnummer: {result.order_number}")
        if result.detail:
            lines.append(f"  Ergebnis: {result.detail}")
        if result.statistic:
            lines.append(f"  Statistik erkannt als: {result.statistic}")
        if result.logfiles:
            lines.append(f"  Logfiles erkannt als: {result.logfiles}")
        if result.download_form:
            lines.append(f"  Downloadformular: {result.download_form}")
        controls = result.controls or []
        if controls:
            lines.append("  Sichtbare Klicktexte auf der letzten geprueften Seite:")
            for control in controls[:30]:
                lines.append(f"    - {control}")
        lines.append("")
    (root / "strato_erkennung_pruefung.txt").write_text("\n".join(lines), encoding="utf-8")


def main() -> int:
    global LOG_PATH
    parser = argparse.ArgumentParser(description="Run STRATO log download automation against a Chrome CDP port.")
    parser.add_argument("--port", type=int, default=DEFAULT_PORT)
    parser.add_argument("--root", type=Path, default=DEFAULT_ROOT)
    parser.add_argument("--chrome-path", type=Path, default=default_chrome_path())
    parser.add_argument("--browser-profile", type=Path, default=default_browser_profile_dir())
    parser.add_argument("--login-url", default=DEFAULT_LOGIN_URL)
    parser.add_argument("--log-file", type=Path, default=None)
    parser.add_argument("--diagnose", action="store_true", help="prueft STRATO-Erkennung ohne Logdateien herunterzuladen")
    parser.add_argument("--scheduled-task", action="store_true", help="markiert den Lauf als Windows-Aufgabenlauf fuer die Morgenlauf-Pruefung")
    args = parser.parse_args()

    root = args.root.resolve()
    LOG_PATH = args.log_file.resolve() if args.log_file else root / "strato_batch_run.log"
    try:
        LOG_PATH.write_text("", encoding="utf-8")
    except OSError:
        pass

    debug_port = choose_debug_port(args.port)
    health_set("debug_port", debug_port)
    capture = FailureCapture(root, debug_port)
    capture.start()
    failure_paths = [
        root / "strato_batch_report.txt",
        root / "strato_erkennung_pruefung.txt",
    ]
    close_started_browser = not chrome_debug_available(debug_port)
    try:
        try:
            ensure_browser_ready(debug_port, args.chrome_path.resolve(), args.browser_profile.resolve(), args.login_url, root)
        except Exception as exc:
            write_startup_error_report(root, exc)
            if args.diagnose:
                write_diagnose_reports(
                    root,
                    [
                        ControlCheckResult(
                            "STRATO Login / Startphase",
                            "",
                            "error",
                            str(exc) or repr(exc),
                            controls=[],
                        )
                    ],
                )
            raise

        if args.diagnose:
            results = diagnose_controls(debug_port, root)
            write_diagnose_reports(root, results)
            log("Diagnose fertig")
            for result in results:
                log(f"{result.status}: {result.package} -> {result.detail}")
            exit_code = 0 if all(result.status in {"ok", "no_statistics"} for result in results) else 2
            write_run_check(root, args.scheduled_task, args.diagnose, exit_code, results)
            if exit_code == 0:
                capture.discard()
            else:
                capture.finalize_failure("STRATO Diagnose meldet Fehler", "Mindestens ein Diagnosepaket endete mit ERROR.", LOG_PATH, failure_paths)
            return exit_code

        results = run(debug_port, root)
        write_reports(root, results)
        log("Fertig")
        for result in results:
            log(f"{result.status}: {result.package} -> {result.detail}")
        exit_code = 0 if all(result.status in {"downloaded", "already_exists", "no_statistics"} for result in results) else 2
        write_run_check(root, args.scheduled_task, args.diagnose, exit_code, results)
        if exit_code == 0:
            capture.discard()
        else:
            capture.finalize_failure("STRATO Log Batch meldet Fehler", "Mindestens ein Paket endete mit ERROR.", LOG_PATH, failure_paths)
        return exit_code
    except Exception as exc:
        write_fatal_error(exc)
        write_run_check(root, args.scheduled_task, args.diagnose, 1, [], traceback.format_exc())
        capture.finalize_failure("STRATO Log Batch abgebrochen", traceback.format_exc(), LOG_PATH, failure_paths)
        return 1
    finally:
        if close_started_browser:
            close_debug_browser(debug_port)


if __name__ == "__main__":
    raise SystemExit(main())
