#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""STRATO log download helper.

The app deliberately does not store credentials. It opens the official STRATO
login page in the user's browser, then watches the normal browser download
folder and imports downloaded log archives into a local archive directory.
"""

from __future__ import annotations

import argparse
import base64
import ctypes
import gzip
import hashlib
import json
import os
import re
import shutil
import socket
import struct
import subprocess
import sys
import tarfile
import time
import traceback
import urllib.error
import urllib.parse
import urllib.request
import webbrowser
import zipfile
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from tkinter import filedialog, messagebox
import tkinter as tk
from tkinter import ttk


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


APP_NAME = "STRATO Log Assistent"
APP_VERSION = "1.7.0"
DEFAULT_LOGIN_URL = "https://www.strato.de/login"
DEFAULT_PACKAGE_NAME = "Paket-1"
CONFIG_FILE_NAME = "strato_log_assistant_config.json"
DEFAULT_DEBUG_PORT = 9223
DEFAULT_PROFILE_DIR_NAME = "browser-profile-playwright"
ALLOWED_SUFFIXES = {
    ".zip",
    ".gz",
    ".tgz",
    ".tar",
    ".log",
    ".txt",
    ".csv",
}
TEMP_SUFFIXES = {
    ".crdownload",
    ".download",
    ".part",
    ".tmp",
}
DOMAIN_RE = re.compile(
    r"\b(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:de|com|net|org|info|eu|biz|name|online|site|shop|app|cloud|host|hosting)\b",
    re.IGNORECASE,
)
PACKAGE_LABEL_RE = re.compile(
    r"(?i)\b(?:paketname|paket|domain|domainname|hauptdomain|kennung|vertragsname|auftragsnummer)\s*[:\-]\s*([^\r\n|]{2,140})"
)
ORDER_NUMBER_RE = re.compile(r"^\d{5,12}$")
PACKAGE_NAME_RE = re.compile(r"^STRATO\b", re.IGNORECASE)
BROWSER_TITLE_SUFFIXES = (
    " - Google Chrome",
    " - Microsoft Edge",
    " - Mozilla Firefox",
    " - Opera",
    " - Brave",
)


def default_download_dir() -> Path:
    home = Path.home()
    candidate = home / "Downloads"
    return candidate if candidate.exists() else home


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


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


def default_browser_profile_dir() -> Path:
    return app_base_dir() / DEFAULT_PROFILE_DIR_NAME


def startup_log(message: str) -> None:
    try:
        path = app_base_dir() / "strato_log_assistant_startup.log"
        with path.open("a", encoding="utf-8") as fh:
            fh.write(f"{datetime.now().isoformat(timespec='seconds')} {message}\n")
    except Exception:
        pass


def force_show_window(window: tk.Tk) -> None:
    try:
        window.state("normal")
        window.deiconify()
        window.update_idletasks()
        window.lift()
        window.focus_force()
        frame = str(window.frame())
        if sys.platform == "win32":
            hwnd = int(frame, 16) if frame.lower().startswith("0x") else int(frame)
            user32 = ctypes.windll.user32
            user32.ShowWindow(wintypes.HWND(hwnd), 9)
            user32.SetForegroundWindow(wintypes.HWND(hwnd))
        startup_log(
            "window "
            f"id={window.winfo_id()} "
            f"frame={frame} "
            f"state={window.state()} "
            f"mapped={window.winfo_ismapped()} "
            f"viewable={window.winfo_viewable()} "
            f"geometry={window.geometry()}"
        )
    except Exception as exc:
        startup_log(f"force_show_window failed: {exc!r}")


def playwright_chromium_candidates() -> list[Path]:
    roots = [
        Path(os.environ.get("LOCALAPPDATA", "")) / "ms-playwright",
        Path.home() / "AppData" / "Local" / "ms-playwright",
    ]
    candidates: list[Path] = []
    for root in roots:
        if not root.exists():
            continue
        for browser_dir in sorted(root.glob("chromium-*"), reverse=True):
            candidate = browser_dir / "chrome-win64" / "chrome.exe"
            if candidate.exists():
                candidates.append(candidate)
    return candidates


def default_chrome_path() -> Path:
    candidates = playwright_chromium_candidates() + [
        Path(os.environ.get("ProgramFiles", "")) / "Google" / "Chrome" / "Application" / "chrome.exe",
        Path(os.environ.get("ProgramFiles(x86)", "")) / "Google" / "Chrome" / "Application" / "chrome.exe",
        Path(os.environ.get("LOCALAPPDATA", "")) / "Google" / "Chrome" / "Application" / "chrome.exe",
    ]
    for candidate in candidates:
        if candidate.exists():
            return candidate
    return Path("chrome.exe")


def expand_path(value: str | os.PathLike[str]) -> Path:
    return Path(os.path.expandvars(str(value))).expanduser()


def native_path_text(value: str | os.PathLike[str]) -> str:
    return str(expand_path(value))


def normalize_package_name(name: str) -> str:
    name = " ".join(name.strip().split())
    name = re.sub(r"-\s+", "-", name)
    return name or DEFAULT_PACKAGE_NAME


@dataclass(frozen=True)
class StratoPackage:
    name: str
    order_number: str = ""
    identifiers: tuple[str, ...] = ()

    @property
    def label(self) -> str:
        name = normalize_package_name(self.name)
        return f"{name} [{self.order_number}]" if self.order_number else name

    def as_dict(self) -> dict:
        return {
            "name": normalize_package_name(self.name),
            "order_number": self.order_number,
            "identifiers": list(self.identifiers),
            "label": self.label,
        }


def package_from_dict(data: dict) -> StratoPackage | None:
    name = str(data.get("name") or data.get("label") or "").strip()
    if not name:
        return None
    order_number = str(data.get("order_number") or "").strip()
    identifiers = tuple(str(item).strip() for item in data.get("identifiers", []) if str(item).strip())
    return StratoPackage(name=name, order_number=order_number, identifiers=identifiers)


def package_label_from_dict(data: dict) -> str:
    package = package_from_dict(data)
    return package.label if package else ""


def clean_candidate_text(text: str) -> str:
    text = re.sub(r"\s+", " ", text.strip())
    text = re.sub(r"\s*[|/]\s*STRATO.*$", "", text, flags=re.IGNORECASE)
    text = re.sub(r"\s*-\s*STRATO.*$", "", text, flags=re.IGNORECASE)
    text = re.sub(r"^(?:paketname|paket|domain|domainname|hauptdomain|kennung|vertragsname|auftragsnummer)\s*[:\-]\s*", "", text, flags=re.IGNORECASE)
    return text.strip(" .,:;|")


def strip_browser_title(title: str) -> str:
    title = title.strip()
    for suffix in BROWSER_TITLE_SUFFIXES:
        if title.endswith(suffix):
            title = title[: -len(suffix)].strip()
    return title


def extract_package_name_from_text(text: str) -> str:
    if not text:
        return ""

    domain_match = DOMAIN_RE.search(text)
    if domain_match:
        return normalize_package_name(domain_match.group(0).lower())

    label_match = PACKAGE_LABEL_RE.search(text)
    if label_match:
        candidate = clean_candidate_text(label_match.group(1))
        if candidate and len(candidate) <= 120:
            return normalize_package_name(candidate)

    lines = [clean_candidate_text(line) for line in text.splitlines()]
    lines = [line for line in lines if 2 <= len(line) <= 120]
    for line in lines:
        if line and not re.search(r"(?i)\b(strato|kunden-login|login|passwort|hilfe|logout|abmelden)\b", line):
            return normalize_package_name(line)
    return ""


def split_visible_text(text: str) -> list[str]:
    lines: list[str] = []
    for raw_line in re.split(r"[\r\n\t]+", text):
        line = clean_candidate_text(raw_line)
        if line:
            lines.append(line)
    return lines


def is_noise_line(line: str) -> bool:
    return bool(
        re.search(
            r"(?i)^(alle pakete|suche|paketname|auftragsnummer|kennung|alle anzeigen|weitere produkte bestellen|konto|hilfe|mein konto|aktuelle angebote|copyright|datenschutzerklärung|agb|impressum|cookie-einstellungen|kontakt|info:)",
            line,
        )
    )


def looks_like_identifier(line: str) -> bool:
    return bool(DOMAIN_RE.search(line) or re.search(r"(?i)\bstrato-hosting\.eu\b", line))


def parse_strato_packages(text: str) -> list[StratoPackage]:
    lines = [line for line in split_visible_text(text) if not is_noise_line(line)]
    packages: list[StratoPackage] = []
    i = 0
    while i < len(lines):
        line = lines[i]
        if not PACKAGE_NAME_RE.search(line):
            i += 1
            continue

        name_parts = [line]
        i += 1
        while i < len(lines):
            next_line = lines[i]
            if ORDER_NUMBER_RE.match(next_line) or PACKAGE_NAME_RE.search(next_line):
                break
            if looks_like_identifier(next_line):
                break
            name_parts.append(next_line)
            i += 1

        order_number = ""
        if i < len(lines) and ORDER_NUMBER_RE.match(lines[i]):
            order_number = lines[i]
            i += 1

        identifiers: list[str] = []
        while i < len(lines):
            next_line = lines[i]
            if PACKAGE_NAME_RE.search(next_line):
                break
            if looks_like_identifier(next_line):
                for match in DOMAIN_RE.finditer(next_line):
                    identifier = match.group(0).lower()
                    if identifier not in identifiers:
                        identifiers.append(identifier)
            i += 1

        name = normalize_package_name(" ".join(name_parts))
        if name and name.casefold() != DEFAULT_PACKAGE_NAME.casefold():
            packages.append(StratoPackage(name=name, order_number=order_number, identifiers=tuple(identifiers)))

    return dedupe_strato_packages(packages)


def dedupe_strato_packages(packages: list[StratoPackage]) -> list[StratoPackage]:
    unique: list[StratoPackage] = []
    seen: set[str] = set()
    for package in packages:
        key = package.order_number or package.label.casefold()
        if key not in seen:
            unique.append(package)
            seen.add(key)
    return unique


def visible_window_titles() -> list[str]:
    if sys.platform != "win32":
        return []

    user32 = ctypes.windll.user32
    titles: list[str] = []

    @ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
    def enum_proc(hwnd: int, _lparam: int) -> bool:
        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.strip()
        if title:
            titles.append(title)
        return True

    user32.EnumWindows(enum_proc, 0)
    return titles


def extract_package_name_from_strato_windows() -> str:
    for title in visible_window_titles():
        if "strato" not in title.lower():
            continue
        candidate = extract_package_name_from_text(strip_browser_title(title))
        if candidate and candidate.casefold() not in {"strato", "strato kunden-login", "kunden-login"}:
            return candidate
    return ""


def safe_package_folder(name: str) -> str:
    cleaned = re.sub(r'[<>:"/\\|?*\x00-\x1f]+', "_", normalize_package_name(name))
    cleaned = cleaned.strip(" ._")
    if not cleaned:
        cleaned = DEFAULT_PACKAGE_NAME
    if cleaned.upper() in {"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"}:
        cleaned = f"_{cleaned}"
    return cleaned[:120]


def package_archive_dir(archive_root: Path, package_name: str) -> Path:
    return archive_root / "pakete" / safe_package_folder(package_name)


def config_path(archive_root: Path) -> Path:
    return archive_root / CONFIG_FILE_NAME


def user_config_dir() -> Path:
    appdata = os.environ.get("APPDATA")
    if appdata:
        return Path(appdata) / "StratoLogAssistent"
    return Path.home() / ".strato-log-assistent"


def user_config_path() -> Path:
    return user_config_dir() / CONFIG_FILE_NAME


def load_json_file(path: Path) -> dict:
    if not path.exists():
        return {}
    try:
        with path.open("r", encoding="utf-8") as fh:
            data = json.load(fh)
    except (OSError, json.JSONDecodeError):
        return {}
    return data if isinstance(data, dict) else {}


def load_user_config() -> dict:
    return load_json_file(user_config_path())


def save_json_file(path: Path, data: dict) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    temp = path.with_suffix(".json.tmp")
    with temp.open("w", encoding="utf-8") as fh:
        json.dump(data, fh, ensure_ascii=False, indent=2)
    temp.replace(path)


def try_save_json_file(path: Path, data: dict) -> bool:
    try:
        save_json_file(path, data)
    except OSError:
        return False
    return True


def provided_flags(argv: list[str]) -> set[str]:
    flags: set[str] = set()
    for item in argv:
        if item.startswith("--"):
            flags.add(item.split("=", 1)[0])
    return flags


def first_text(*values: object, default: str = "") -> str:
    for value in values:
        text = str(value or "").strip()
        if text:
            return text
    return default


def load_package_history(archive_root: Path, current_package: str) -> list[str]:
    names: list[str] = []
    known_identifiers: set[str] = set()
    known_package_count = 0
    user_config = load_user_config()
    hidden: set[str] = {
        normalize_package_name(name).casefold()
        for name in user_config.get("hidden_packages", [])
        if str(name).strip()
    }
    names.extend(str(name) for name in user_config.get("packages", []) if str(name).strip())
    names.extend(
        label
        for label in (package_label_from_dict(item) for item in user_config.get("strato_packages", []))
        if label
    )
    for item in user_config.get("strato_packages", []):
        package = package_from_dict(item) if isinstance(item, dict) else None
        if package:
            known_package_count += 1
            known_identifiers.update(identifier.casefold() for identifier in package.identifiers)

    config_file = config_path(archive_root)
    data = load_json_file(config_file)
    hidden.update(
        normalize_package_name(name).casefold()
        for name in data.get("hidden_packages", [])
        if str(name).strip()
    )
    names.extend(str(name) for name in data.get("packages", []) if str(name).strip())
    names.extend(
        label
        for label in (package_label_from_dict(item) for item in data.get("strato_packages", []))
        if label
    )
    for item in data.get("strato_packages", []):
        package = package_from_dict(item) if isinstance(item, dict) else None
        if package:
            known_package_count += 1
            known_identifiers.update(identifier.casefold() for identifier in package.identifiers)

    packages_dir = archive_root / "pakete"
    if packages_dir.exists():
        for manifest in packages_dir.glob("*/strato_log_manifest.json"):
            try:
                with manifest.open("r", encoding="utf-8") as fh:
                    data = json.load(fh)
                label = str(data.get("package_name", "")).strip()
                if label:
                    names.append(label)
            except (OSError, json.JSONDecodeError):
                folder_name = manifest.parent.name
                if folder_name:
                    names.append(folder_name)

    names.append(normalize_package_name(current_package))
    unique: list[str] = []
    seen: set[str] = set()
    for name in names:
        normalized = normalize_package_name(name)
        key = normalized.casefold()
        if key in hidden:
            continue
        if known_package_count and (key == DEFAULT_PACKAGE_NAME.casefold() or key in known_identifiers):
            continue
        if key not in seen:
            unique.append(normalized)
            seen.add(key)
    return unique


def save_package_history(archive_root: Path, packages: list[str], current_package: str) -> None:
    archive_root.mkdir(parents=True, exist_ok=True)
    names = load_package_history(archive_root, current_package) + packages
    existing = load_json_file(config_path(archive_root))
    unique: list[str] = []
    seen: set[str] = set()
    for name in names:
        normalized = normalize_package_name(name)
        key = normalized.casefold()
        if key not in seen:
            unique.append(normalized)
            seen.add(key)
    data = {
        "app": APP_NAME,
        "version": APP_VERSION,
        "current_package": normalize_package_name(current_package),
        "packages": unique,
        "strato_packages": existing.get("strato_packages", []),
        "package_statuses": existing.get("package_statuses", {}),
        "hidden_packages": existing.get("hidden_packages", []),
        "updated_at": datetime.now().isoformat(timespec="seconds"),
    }
    save_json_file(config_path(archive_root), data)


def merge_strato_package_entries(existing: list[dict], packages: list[StratoPackage]) -> list[dict]:
    merged: list[dict] = []
    by_key: dict[str, dict] = {}

    for item in existing:
        package = package_from_dict(item) if isinstance(item, dict) else None
        if not package:
            continue
        entry = package.as_dict()
        key = package.order_number or package.label.casefold()
        by_key[key] = entry

    for package in packages:
        key = package.order_number or package.label.casefold()
        previous = by_key.get(key, {})
        identifiers = list(dict.fromkeys(list(previous.get("identifiers", [])) + list(package.identifiers)))
        by_key[key] = {
            "name": normalize_package_name(package.name),
            "order_number": package.order_number or previous.get("order_number", ""),
            "identifiers": identifiers,
            "label": package.label,
        }

    for key in sorted(by_key, key=lambda item: by_key[item].get("label", "").casefold()):
        merged.append(by_key[key])
    return merged


def save_strato_packages(archive_root: Path, packages: list[StratoPackage], current_package: str) -> list[str]:
    archive_root.mkdir(parents=True, exist_ok=True)
    labels = [package.label for package in packages]

    user_config = load_user_config()
    archive_config = load_json_file(config_path(archive_root))
    existing_entries = []
    existing_entries.extend(item for item in user_config.get("strato_packages", []) if isinstance(item, dict))
    existing_entries.extend(item for item in archive_config.get("strato_packages", []) if isinstance(item, dict))
    entries = merge_strato_package_entries(existing_entries, packages)

    for path, base in ((user_config_path(), user_config), (config_path(archive_root), archive_config)):
        package_names = list(dict.fromkeys([str(name) for name in base.get("packages", []) if str(name).strip()] + labels + [current_package]))
        hidden_packages = [
            item
            for item in base.get("hidden_packages", [])
            if normalize_package_name(str(item)).casefold() not in {label.casefold() for label in labels}
        ]
        data = dict(base)
        data.update(
            {
                "app": APP_NAME,
                "version": APP_VERSION,
                "current_package": current_package,
                "packages": package_names,
                "strato_packages": entries,
                "hidden_packages": hidden_packages,
                "updated_at": datetime.now().isoformat(timespec="seconds"),
            }
        )
        try_save_json_file(path, data)

    return labels


def save_package_status(archive_root: Path, package_name: str, status: str) -> None:
    for path in (user_config_path(), config_path(archive_root)):
        data = load_json_file(path)
        statuses = data.get("package_statuses", {})
        if not isinstance(statuses, dict):
            statuses = {}
        statuses[package_name] = {
            "status": status,
            "checked_at": datetime.now().isoformat(timespec="seconds"),
        }
        data.update(
            {
                "app": APP_NAME,
                "version": APP_VERSION,
                "package_statuses": statuses,
                "updated_at": datetime.now().isoformat(timespec="seconds"),
            }
        )
        try_save_json_file(path, data)


def remove_package_from_saved_configs(archive_root: Path, package_label: str) -> None:
    target = normalize_package_name(package_label).casefold()
    for path in (user_config_path(), config_path(archive_root)):
        data = load_json_file(path)
        if not data:
            continue

        packages = [
            normalize_package_name(name)
            for name in data.get("packages", [])
            if normalize_package_name(str(name)).casefold() != target
        ]
        strato_packages = []
        for item in data.get("strato_packages", []):
            if not isinstance(item, dict):
                continue
            label = package_label_from_dict(item)
            name = normalize_package_name(str(item.get("name", "")))
            if label.casefold() == target or name.casefold() == target:
                continue
            strato_packages.append(item)

        statuses = data.get("package_statuses", {})
        if isinstance(statuses, dict):
            statuses = {
                key: value
                for key, value in statuses.items()
                if normalize_package_name(str(key)).casefold() != target
            }
        else:
            statuses = {}

        hidden_packages = [
            normalize_package_name(name)
            for name in data.get("hidden_packages", [])
            if str(name).strip()
        ]
        if package_label not in hidden_packages:
            hidden_packages.append(package_label)

        current_package = normalize_package_name(str(data.get("current_package", "")))
        if current_package.casefold() == target:
            current_package = packages[0] if packages else DEFAULT_PACKAGE_NAME

        data.update(
            {
                "app": APP_NAME,
                "version": APP_VERSION,
                "current_package": current_package,
                "packages": packages,
                "strato_packages": strato_packages,
                "package_statuses": statuses,
                "hidden_packages": hidden_packages,
                "updated_at": datetime.now().isoformat(timespec="seconds"),
            }
        )
        try_save_json_file(path, data)


def apply_saved_defaults(args: argparse.Namespace, flags: set[str]) -> argparse.Namespace:
    config = load_user_config()
    if "--download-dir" not in flags:
        args.download_dir = first_text(config.get("download_dir"), args.download_dir, default=str(default_download_dir()))
    if "--archive-dir" not in flags:
        args.archive_dir = first_text(config.get("archive_dir"), args.archive_dir, default=str(default_archive_dir()))
    if "--login-url" not in flags:
        args.login_url = first_text(config.get("login_url"), args.login_url, default=DEFAULT_LOGIN_URL)
    if "--package" not in flags:
        args.package = first_text(config.get("current_package"), args.package, default=DEFAULT_PACKAGE_NAME)
    if "--scan-seconds" not in flags:
        try:
            args.scan_seconds = int(config.get("scan_seconds", args.scan_seconds))
        except (TypeError, ValueError):
            pass
    if "--chrome-path" not in flags:
        args.chrome_path = first_text(config.get("chrome_path"), args.chrome_path, default=str(default_chrome_path()))
    if "--debug-port" not in flags:
        try:
            args.debug_port = int(config.get("debug_port", args.debug_port))
        except (TypeError, ValueError):
            pass
    if "--browser-profile" not in flags:
        args.browser_profile = first_text(config.get("browser_profile"), args.browser_profile, default=str(default_browser_profile_dir()))
    return args


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    arg_list = list(sys.argv[1:] if argv is None else argv)
    flags = provided_flags(arg_list)
    parser = argparse.ArgumentParser(
        prog="StratoLogAssistent",
        description=(
            "GUI-Helfer fuer STRATO-Logfiles: Loginseite oeffnen, "
            "Browser-Downloads ueberwachen, Log-Archive lokal importieren."
        ),
    )
    parser.add_argument(
        "--download-dir",
        default=str(default_download_dir()),
        help="Ordner, in dem der Browser heruntergeladene STRATO-Dateien ablegt.",
    )
    parser.add_argument(
        "--archive-dir",
        default=str(default_archive_dir()),
        help="Lokaler Zielordner fuer importierte und entpackte Logs.",
    )
    parser.add_argument(
        "--login-url",
        default=DEFAULT_LOGIN_URL,
        help="Login-URL, die ueber den Standardbrowser geoeffnet wird.",
    )
    parser.add_argument(
        "--package",
        default=DEFAULT_PACKAGE_NAME,
        help="Paket-/Domainname fuer die Ablage unter archive\\pakete\\<name>.",
    )
    parser.add_argument(
        "--scan-seconds",
        type=int,
        default=5,
        help="Ueberwachungsintervall in Sekunden.",
    )
    parser.add_argument(
        "--chrome-path",
        default=str(default_chrome_path()),
        help="Pfad zu chrome.exe fuer den steuerbaren STRATO-Browser.",
    )
    parser.add_argument(
        "--debug-port",
        type=int,
        default=DEFAULT_DEBUG_PORT,
        help="Lokaler Chrome-Remote-Debugging-Port.",
    )
    parser.add_argument(
        "--browser-profile",
        default=str(default_browser_profile_dir()),
        help="Eigenes Chrome-Profil fuer den steuerbaren STRATO-Browser.",
    )
    parser.add_argument(
        "--auto-watch",
        action="store_true",
        help="Ueberwachung direkt beim Start einschalten.",
    )
    parser.add_argument(
        "--import-existing",
        action="store_true",
        help="Beim Start vorhandene passende Dateien im Downloadordner importieren.",
    )
    parser.add_argument(
        "--version",
        action="version",
        version=f"{APP_NAME} {APP_VERSION}",
    )
    args = parser.parse_args(arg_list)
    args._provided_flags = flags
    return apply_saved_defaults(args, flags)


@dataclass
class ImportResult:
    source: Path
    copied_to: Path | None
    extracted_to: Path | None
    status: str
    detail: str
    package_name: str
    package_dir: Path | None = None
    sha256: str | None = None


class LogArchive:
    def __init__(self, archive_root: Path, package_name: str) -> None:
        self.archive_root = archive_root
        self.package_name = normalize_package_name(package_name)
        self.package_folder = safe_package_folder(self.package_name)
        self.archive_dir = package_archive_dir(archive_root, self.package_name)
        self.imported_dir = self.archive_dir / "imported"
        self.extracted_dir = self.archive_dir / "extracted"
        self.manifest_path = self.archive_dir / "strato_log_manifest.json"
        self.archive_dir.mkdir(parents=True, exist_ok=True)
        self.imported_dir.mkdir(parents=True, exist_ok=True)
        self.extracted_dir.mkdir(parents=True, exist_ok=True)
        self.manifest = self._load_manifest()

    def _load_manifest(self) -> dict:
        if not self.manifest_path.exists():
            return {
                "app": APP_NAME,
                "version": APP_VERSION,
                "package_name": self.package_name,
                "package_folder": self.package_folder,
                "imports": [],
            }
        try:
            with self.manifest_path.open("r", encoding="utf-8") as fh:
                data = json.load(fh)
        except (OSError, json.JSONDecodeError):
            timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
            backup = self.manifest_path.with_suffix(f".broken-{timestamp}.json")
            try:
                self.manifest_path.rename(backup)
            except OSError:
                pass
            return {
                "app": APP_NAME,
                "version": APP_VERSION,
                "package_name": self.package_name,
                "package_folder": self.package_folder,
                "imports": [],
            }
        data["app"] = APP_NAME
        data["version"] = APP_VERSION
        data["package_name"] = self.package_name
        data["package_folder"] = self.package_folder
        data.setdefault("imports", [])
        return data

    def _save_manifest(self) -> None:
        self.archive_dir.mkdir(parents=True, exist_ok=True)
        self.manifest["app"] = APP_NAME
        self.manifest["version"] = APP_VERSION
        self.manifest["package_name"] = self.package_name
        self.manifest["package_folder"] = self.package_folder
        temp = self.manifest_path.with_suffix(".json.tmp")
        with temp.open("w", encoding="utf-8") as fh:
            json.dump(self.manifest, fh, ensure_ascii=False, indent=2)
        temp.replace(self.manifest_path)

    def known_hashes(self) -> set[str]:
        return {
            str(entry.get("sha256"))
            for entry in self.manifest.get("imports", [])
            if entry.get("sha256")
        }

    def import_file(self, source: Path) -> ImportResult:
        source = source.resolve()
        if not source.exists() or not source.is_file():
            return ImportResult(source, None, None, "Fehler", "Datei existiert nicht.", self.package_name, self.archive_dir)
        if is_temp_download(source):
            return ImportResult(source, None, None, "Wartet", "Download ist noch nicht fertig.", self.package_name, self.archive_dir)

        digest = sha256_file(source)
        if digest in self.known_hashes():
            return ImportResult(
                source,
                None,
                None,
                "Übersprungen",
                "Diese Datei wurde für dieses Paket bereits importiert.",
                self.package_name,
                self.archive_dir,
                digest,
            )

        day_dir = self.imported_dir / datetime.now().strftime("%Y-%m-%d")
        day_dir.mkdir(parents=True, exist_ok=True)
        target = unique_path(day_dir / source.name)
        shutil.copy2(source, target)

        extracted_to: Path | None = None
        detail = "Datei wurde kopiert."
        if zipfile.is_zipfile(target):
            extracted_to = unique_path(self.extracted_dir / target.stem)
            safe_extract_zip(target, extracted_to)
            detail = f"ZIP wurde kopiert und entpackt nach {extracted_to}."
        elif tarfile.is_tarfile(target):
            extracted_to = unique_path(self.extracted_dir / target.stem)
            safe_extract_tar(target, extracted_to)
            detail = f"TAR-Archiv wurde kopiert und entpackt nach {extracted_to}."
        elif target.suffix.lower() == ".gz":
            extracted_to = unique_path(self.extracted_dir / target.stem)
            extracted_to.parent.mkdir(parents=True, exist_ok=True)
            with gzip.open(target, "rb") as src, extracted_to.open("wb") as dst:
                shutil.copyfileobj(src, dst)
            detail = f"GZIP wurde kopiert und entpackt nach {extracted_to}."

        entry = {
            "imported_at": datetime.now().isoformat(timespec="seconds"),
            "source": str(source),
            "copied_to": str(target),
            "extracted_to": str(extracted_to) if extracted_to else None,
            "package_name": self.package_name,
            "package_folder": self.package_folder,
            "sha256": digest,
            "size": target.stat().st_size,
        }
        self.manifest.setdefault("imports", []).append(entry)
        self._save_manifest()
        return ImportResult(source, target, extracted_to, "Importiert", detail, self.package_name, self.archive_dir, digest)


def is_temp_download(path: Path) -> bool:
    suffixes = [suffix.lower() for suffix in path.suffixes]
    return any(suffix in TEMP_SUFFIXES for suffix in suffixes)


def is_candidate_file(path: Path) -> bool:
    if not path.is_file() or is_temp_download(path):
        return False
    suffixes = [suffix.lower() for suffix in path.suffixes]
    return any(suffix in ALLOWED_SUFFIXES for suffix in suffixes)


def sha256_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as fh:
        for chunk in iter(lambda: fh.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def unique_path(path: Path) -> Path:
    if not path.exists():
        return path
    stem = path.stem
    suffix = path.suffix
    parent = path.parent
    timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    candidate = parent / f"{stem}-{timestamp}{suffix}"
    counter = 2
    while candidate.exists():
        candidate = parent / f"{stem}-{timestamp}-{counter}{suffix}"
        counter += 1
    return candidate


def safe_extract_zip(zip_path: Path, dest_dir: Path) -> None:
    dest_dir.mkdir(parents=True, exist_ok=True)
    root = dest_dir.resolve()
    with zipfile.ZipFile(zip_path) as archive:
        for info in archive.infolist():
            target = (root / info.filename).resolve()
            try:
                target.relative_to(root)
            except ValueError:
                raise ValueError(f"Unsicherer ZIP-Pfad: {info.filename}")
        archive.extractall(root)


def safe_extract_tar(tar_path: Path, dest_dir: Path) -> None:
    dest_dir.mkdir(parents=True, exist_ok=True)
    root = dest_dir.resolve()
    with tarfile.open(tar_path) as archive:
        for member in archive.getmembers():
            if member.issym() or member.islnk():
                raise ValueError(f"Unsicherer TAR-Link: {member.name}")
            target = (root / member.name).resolve()
            try:
                target.relative_to(root)
            except ValueError:
                raise ValueError(f"Unsicherer TAR-Pfad: {member.name}")
        archive.extractall(root)


def open_folder(path: Path) -> None:
    path.mkdir(parents=True, exist_ok=True)
    os.startfile(str(path))  # noqa: S606 - intended local Explorer launch


def http_json(url: str, timeout: float = 2.0, method: str = "GET") -> object:
    request = urllib.request.Request(url, method=method)
    with urllib.request.urlopen(request, timeout=timeout) as response:
        return json.loads(response.read().decode("utf-8", errors="replace"))


def chrome_debug_base(port: int) -> str:
    return f"http://127.0.0.1:{int(port)}"


def chrome_debug_available(port: int) -> bool:
    try:
        http_json(f"{chrome_debug_base(port)}/json/version", timeout=1.0)
    except (OSError, urllib.error.URLError, json.JSONDecodeError):
        return False
    return True


def chrome_debug_tabs(port: int) -> list[dict]:
    try:
        data = http_json(f"{chrome_debug_base(port)}/json/list", timeout=2.0)
    except (OSError, urllib.error.URLError, json.JSONDecodeError):
        return []
    return data if isinstance(data, list) else []


def choose_strato_tab(tabs: list[dict]) -> dict | None:
    page_tabs = [tab for tab in tabs if tab.get("type") == "page" and tab.get("webSocketDebuggerUrl")]
    for tab in page_tabs:
        haystack = f"{tab.get('title', '')} {tab.get('url', '')}".lower()
        if "strato" in haystack and "alle pakete" in haystack and "node=kds_customerentrypage" in haystack:
            return tab
    for tab in page_tabs:
        haystack = f"{tab.get('title', '')} {tab.get('url', '')}".lower()
        if "strato" in haystack and "#/skl" in haystack:
            return tab
    for tab in page_tabs:
        haystack = f"{tab.get('title', '')} {tab.get('url', '')}".lower()
        if "strato" in haystack and "sessionid=" in haystack:
            return tab
    for tab in page_tabs:
        haystack = f"{tab.get('title', '')} {tab.get('url', '')}".lower()
        if "strato" in haystack:
            return tab
    return page_tabs[0] if page_tabs else None


def open_url_in_debug_chrome(port: int, url: str) -> None:
    encoded = urllib.parse.quote(url, safe=":/?&=%#")
    try:
        http_json(f"{chrome_debug_base(port)}/json/new?{encoded}", timeout=2.0, method="PUT")
    except (OSError, urllib.error.URLError, json.JSONDecodeError):
        pass


def configure_chrome_profile(profile_dir: Path, download_dir: Path) -> None:
    default_dir = profile_dir / "Default"
    default_dir.mkdir(parents=True, exist_ok=True)
    prefs_path = default_dir / "Preferences"
    prefs = load_json_file(prefs_path)
    for parent in (prefs, prefs.setdefault("account_values", {})):
        download = parent.setdefault("download", {})
        download["default_directory"] = str(download_dir)
        download["directory_upgrade"] = True
        download["prompt_for_download"] = False
    savefile = prefs.setdefault("savefile", {})
    savefile["default_directory"] = str(download_dir)
    profile = prefs.setdefault("profile", {})
    profile["exit_type"] = "Normal"
    profile["exited_cleanly"] = True
    content_settings = profile.setdefault("default_content_setting_values", {})
    content_settings["automatic_downloads"] = 1
    try_save_json_file(prefs_path, prefs)


def launch_controlled_chrome(
    chrome_path: Path,
    profile_dir: Path,
    port: int,
    login_url: str,
    download_dir: Path,
    start_visible: bool = False,
) -> subprocess.Popen | None:
    profile_dir.mkdir(parents=True, exist_ok=True)
    download_dir.mkdir(parents=True, exist_ok=True)
    configure_chrome_profile(profile_dir, download_dir)

    if chrome_debug_available(port):
        open_url_in_debug_chrome(port, login_url)
        return None

    args = [
        str(chrome_path),
        f"--remote-debugging-port={int(port)}",
        "--remote-debugging-address=127.0.0.1",
        f"--user-data-dir={profile_dir}",
        "--no-first-run",
        "--disable-session-crashed-bubble",
        "--hide-crash-restore-bubble",
        "--disable-popup-blocking",
        "--disable-features=DownloadBubble,DownloadBubbleV2",
        "--start-maximized" if start_visible else "--start-minimized",
        "--window-position=0,0" if start_visible else "--window-position=80,80",
        "--window-size=1920,1080" if start_visible else "--window-size=1060,780",
        "--new-window",
        login_url,
    ]
    return subprocess.Popen(args, close_fds=True)


class WebSocketError(RuntimeError):
    pass


def _read_exact(sock: socket.socket, count: int) -> bytes:
    chunks: list[bytes] = []
    remaining = count
    while remaining:
        chunk = sock.recv(remaining)
        if not chunk:
            raise WebSocketError("WebSocket-Verbindung wurde geschlossen.")
        chunks.append(chunk)
        remaining -= len(chunk)
    return b"".join(chunks)


def _ws_send_text(sock: socket.socket, text: str) -> None:
    payload = text.encode("utf-8")
    header = bytearray([0x81])
    length = len(payload)
    if length < 126:
        header.append(0x80 | length)
    elif length < 65536:
        header.extend([0x80 | 126])
        header.extend(struct.pack("!H", length))
    else:
        header.extend([0x80 | 127])
        header.extend(struct.pack("!Q", length))
    mask = os.urandom(4)
    masked = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
    sock.sendall(bytes(header) + mask + masked)


def _ws_read_text(sock: socket.socket) -> str:
    while True:
        first, second = _read_exact(sock, 2)
        opcode = first & 0x0F
        masked = bool(second & 0x80)
        length = second & 0x7F
        if length == 126:
            length = struct.unpack("!H", _read_exact(sock, 2))[0]
        elif length == 127:
            length = struct.unpack("!Q", _read_exact(sock, 8))[0]
        mask = _read_exact(sock, 4) if masked else b""
        payload = _read_exact(sock, length) if length else b""
        if masked:
            payload = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
        if opcode == 1:
            return payload.decode("utf-8", errors="replace")
        if opcode == 8:
            raise WebSocketError("WebSocket wurde geschlossen.")
        if opcode == 9:
            sock.sendall(b"\x8a\x00")


def _ws_connect(ws_url: str, timeout: float = 5.0) -> socket.socket:
    parsed = urllib.parse.urlparse(ws_url)
    if parsed.scheme != "ws":
        raise WebSocketError(f"Nicht unterstützte WebSocket-URL: {ws_url}")
    host = parsed.hostname or "127.0.0.1"
    port = parsed.port or 80
    path = parsed.path or "/"
    if parsed.query:
        path += f"?{parsed.query}"

    sock = socket.create_connection((host, port), timeout=timeout)
    key = base64.b64encode(os.urandom(16)).decode("ascii")
    request = (
        f"GET {path} HTTP/1.1\r\n"
        f"Host: {host}:{port}\r\n"
        "Upgrade: websocket\r\n"
        "Connection: Upgrade\r\n"
        f"Sec-WebSocket-Key: {key}\r\n"
        "Sec-WebSocket-Version: 13\r\n"
        "\r\n"
    )
    sock.sendall(request.encode("ascii"))
    response = b""
    while b"\r\n\r\n" not in response:
        chunk = sock.recv(4096)
        if not chunk:
            break
        response += chunk
    if b" 101 " not in response.split(b"\r\n", 1)[0]:
        sock.close()
        raise WebSocketError("Chrome hat die WebSocket-Verbindung nicht akzeptiert.")
    return sock


def cdp_evaluate(port: int, expression: str) -> str:
    tab = choose_strato_tab(chrome_debug_tabs(port))
    if not tab:
        raise WebSocketError("Kein steuerbarer Chrome-Tab gefunden.")
    ws_url = str(tab.get("webSocketDebuggerUrl", ""))
    with _ws_connect(ws_url) 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 chrome_page_text(port: int) -> str:
    expression = "document.body ? document.body.innerText : ''"
    return cdp_evaluate(port, expression)


def package_click_text(package_label: str) -> str:
    return re.sub(r"\s+\[\d+\]\s*$", "", normalize_package_name(package_label)).strip()


def controlled_browser_close_popups(port: int) -> str:
    expression = r"""
(() => {
  const isVisible = (el) => {
    const style = window.getComputedStyle(el);
    const rect = el.getBoundingClientRect();
    return style && style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
  };
  const candidates = Array.from(document.querySelectorAll('button, [role="button"], a, [aria-label]'));
  const closeWords = [
    'zustimmen und schliessen', 'zustimmen und schließen', 'alle akzeptieren', 'akzeptieren',
    'schließen', 'schliessen', 'close', 'ok', 'verstanden', 'ablehnen',
    'nicht jetzt', 'später', 'spaeter', 'nein danke'
  ];
  for (const el of candidates) {
    if (!isVisible(el)) continue;
    const text = ((el.innerText || el.textContent || el.getAttribute('aria-label') || el.title || '') + '').trim().toLowerCase();
    if (!text) continue;
    if (closeWords.some((word) => text === word || text.includes(word))) {
      el.click();
      return text;
    }
  }
  return '';
})()
"""
    return cdp_evaluate(port, expression)


def controlled_browser_click_text(port: int, target_text: str) -> str:
    target = package_click_text(target_text)
    expression = f"""
(async () => {{
  const target = {json.dumps(target)};
  const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
  const isVisible = (el) => {{
    const style = window.getComputedStyle(el);
    const rect = el.getBoundingClientRect();
    return style && style.visibility !== 'hidden' && style.display !== 'none' &&
      rect.width > 0 && rect.height > 0 && rect.bottom >= 0 && rect.top <= window.innerHeight;
  }};
  const selectors = [
    'a',
    'button',
    '[role="button"]',
    '[data-testid]',
    '[tabindex]',
    'td',
    'div'
  ];
  const findTarget = () => {{
    const elements = Array.from(document.querySelectorAll(selectors.join(',')));
    const exact = elements.find((el) => isVisible(el) && normalize(el.innerText || el.textContent) === target);
    return exact || elements.find((el) => {{
      if (!isVisible(el)) return false;
      const text = normalize(el.innerText || el.textContent);
      return text && text.includes(target) && text.length <= target.length + 80;
    }});
  }};
  const scrollables = [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 (!scrollables.length && document.scrollingElement) scrollables.push(document.scrollingElement);
  for (const el of scrollables) {{
    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);
      const loose = findTarget();
      if (!loose) continue;
      const clickable = loose.closest('a,button,[role="button"],[tabindex]') || loose;
      clickable.scrollIntoView({{ block: 'center', inline: 'center' }});
      await delay(120);
      clickable.click();
      return normalize(clickable.innerText || clickable.textContent || target);
    }}
  }}
  return '';
}})()
"""
    return cdp_evaluate(port, expression)


class StratoLogApp(tk.Tk):
    def __init__(self, args: argparse.Namespace) -> None:
        super().__init__()
        self.title(f"{APP_NAME} {APP_VERSION}")
        self.geometry("1060x780")
        self.minsize(860, 660)

        self.login_url = tk.StringVar(value=args.login_url)
        self.download_dir = tk.StringVar(value=native_path_text(args.download_dir))
        self.archive_dir = tk.StringVar(value=native_path_text(args.archive_dir))
        self.package_name = tk.StringVar(value=normalize_package_name(args.package))
        self.package_history = load_package_history(expand_path(self.archive_dir.get()), self.package_name.get())
        self.scan_seconds = tk.IntVar(value=max(2, args.scan_seconds))
        self.chrome_path = tk.StringVar(value=native_path_text(args.chrome_path))
        self.browser_profile = tk.StringVar(value=native_path_text(args.browser_profile))
        self.debug_port = tk.IntVar(value=max(1, int(args.debug_port)))
        self.watch_enabled = False
        self.watch_started_at = time.time()
        self.pending_sizes: dict[str, tuple[int, float]] = {}
        self._save_after_id: str | None = None
        self._saving_settings = False
        self.archive = LogArchive(expand_path(self.archive_dir.get()), self.package_name.get())

        self._build_ui()
        self._bind_setting_saves()
        self.protocol("WM_DELETE_WINDOW", self.on_close)
        self._log(f"{APP_NAME} gestartet.")
        self._log("Ablauf: Paket wählen, Loginseite öffnen, manuell einloggen, Logfile bei STRATO herunterladen, dann importiert die App die Datei.")
        self._log("STRATO-Pfad laut FAQ: Datenbanken und Webspace > Statistik > Logfiles.")
        self.after(100, lambda: self.save_settings(include_current_package=True))

        if args.import_existing:
            self.after(300, lambda: self.import_existing_files(silent=False))
        if args.auto_watch:
            self.after(500, self.start_watch)

    def _build_ui(self) -> None:
        self.columnconfigure(0, weight=1)
        self.rowconfigure(5, weight=1)

        header = ttk.Frame(self, padding=(12, 10, 12, 4))
        header.grid(row=0, column=0, sticky="ew")
        header.columnconfigure(0, weight=1)
        ttk.Label(header, text=APP_NAME, font=("Segoe UI", 16, "bold")).grid(row=0, column=0, sticky="w")
        ttk.Label(
            header,
            text="Oeffnet den STRATO-Login, ueberwacht Browser-Downloads und archiviert Logfiles lokal.",
        ).grid(row=1, column=0, sticky="w", pady=(2, 0))
        ttk.Button(header, text="Parameter-Hilfe", command=self.show_parameter_help).grid(row=0, column=1, rowspan=2, sticky="e")

        login_frame = ttk.LabelFrame(self, text="1. Login im Browser", padding=10)
        login_frame.grid(row=1, column=0, sticky="ew", padx=12, pady=6)
        login_frame.columnconfigure(1, weight=1)
        ttk.Label(login_frame, text="Login-URL").grid(row=0, column=0, sticky="w", padx=(0, 8))
        ttk.Entry(login_frame, textvariable=self.login_url).grid(row=0, column=1, sticky="ew", padx=(0, 8))
        ttk.Button(login_frame, text="Steuerbaren Browser starten", command=self.start_controlled_chrome).grid(row=0, column=2, padx=(0, 6), sticky="e")
        ttk.Button(login_frame, text="Verbindung prüfen", command=self.check_controlled_chrome).grid(row=0, column=3, sticky="e")
        ttk.Label(login_frame, text="Browser").grid(row=1, column=0, sticky="w", padx=(0, 8), pady=(6, 0))
        ttk.Entry(login_frame, textvariable=self.chrome_path).grid(row=1, column=1, sticky="ew", padx=(0, 8), pady=(6, 0))
        ttk.Button(login_frame, text="Wählen", command=self.choose_chrome_path).grid(row=1, column=2, padx=(0, 6), pady=(6, 0), sticky="e")
        ttk.Button(login_frame, text="Loginseite öffnen", command=self.open_login).grid(row=1, column=3, pady=(6, 0), sticky="e")
        ttk.Label(login_frame, text="Profil / Port").grid(row=2, column=0, sticky="w", padx=(0, 8), pady=(6, 0))
        profile_row = ttk.Frame(login_frame)
        profile_row.grid(row=2, column=1, sticky="ew", padx=(0, 8), pady=(6, 0))
        profile_row.columnconfigure(0, weight=1)
        ttk.Entry(profile_row, textvariable=self.browser_profile).grid(row=0, column=0, sticky="ew", padx=(0, 8))
        ttk.Spinbox(profile_row, from_=1024, to=65535, width=7, textvariable=self.debug_port).grid(row=0, column=1, sticky="e")
        ttk.Button(login_frame, text="Profil wählen", command=self.choose_browser_profile).grid(row=2, column=2, padx=(0, 6), pady=(6, 0), sticky="e")
        ttk.Button(login_frame, text="Paketliste aus Browser", command=self.take_package_from_controlled_chrome).grid(row=2, column=3, pady=(6, 0), sticky="e")

        folders = ttk.LabelFrame(self, text="2. Paket und Ordner", padding=10)
        folders.grid(row=2, column=0, sticky="ew", padx=12, pady=6)
        folders.columnconfigure(1, weight=1)
        ttk.Label(folders, text="Aktuelles Paket / Domain").grid(row=0, column=0, sticky="w", padx=(0, 8), pady=2)
        self.package_combo = ttk.Combobox(folders, textvariable=self.package_name, values=self.package_history)
        self.package_combo.grid(row=0, column=1, sticky="ew", padx=(0, 8), pady=2)
        self.package_menu = tk.Menu(self, tearoff=False)
        self.package_menu.add_command(label="Auswahleintrag löschen", command=self.remove_current_package_entry)
        self.package_combo.bind("<Button-3>", self.show_package_menu)
        self.package_combo.bind("<Delete>", lambda _event: self.remove_current_package_entry())
        ttk.Button(folders, text="Von STRATO übernehmen", command=self.take_package_from_strato_page).grid(row=0, column=2, padx=(0, 6), pady=2)
        ttk.Button(folders, text="Paketordner", command=self.open_current_package_folder).grid(row=0, column=3, pady=2)
        ttk.Label(folders, text="Browser-Downloads").grid(row=1, column=0, sticky="w", padx=(0, 8), pady=2)
        ttk.Entry(folders, textvariable=self.download_dir).grid(row=1, column=1, sticky="ew", padx=(0, 8), pady=2)
        ttk.Button(folders, text="Wählen", command=self.choose_download_dir).grid(row=1, column=2, padx=(0, 6), pady=2)
        ttk.Button(folders, text="Öffnen", command=lambda: open_folder(expand_path(self.download_dir.get()))).grid(row=1, column=3, pady=2)
        ttk.Label(folders, text="Lokales Archiv").grid(row=2, column=0, sticky="w", padx=(0, 8), pady=2)
        ttk.Entry(folders, textvariable=self.archive_dir).grid(row=2, column=1, sticky="ew", padx=(0, 8), pady=2)
        ttk.Button(folders, text="Wählen", command=self.choose_archive_dir).grid(row=2, column=2, padx=(0, 6), pady=2)
        ttk.Button(folders, text="Öffnen", command=lambda: open_folder(expand_path(self.archive_dir.get()))).grid(row=2, column=3, pady=2)

        workflow = ttk.LabelFrame(self, text="Ablauf", padding=10)
        workflow.grid(row=3, column=0, sticky="ew", padx=12, pady=6)
        workflow.columnconfigure(0, weight=1)
        ttk.Label(
            workflow,
            text=(
                "1. 'Steuerbaren Browser starten' und dort manuell einloggen.  "
                "2. Auf 'Alle Pakete' gehen -> 'Paketliste aus Browser' oder 'Von STRATO übernehmen'.  "
                "3. Paket auswählen -> 'Paket öffnen'; störende Dialoge mit 'Popups schließen' entfernen.  "
                "4. 'Statistik prüfen' liest den steuerbaren Browser, sonst die Zwischenablage.  "
                "5. Falls Statistik vorhanden: Logfile herunterladen.  "
                "6. Überwachung/Import legt die Datei im Paketordner ab."
            ),
            wraplength=1000,
            justify="left",
        ).grid(row=0, column=0, sticky="ew")

        actions = ttk.LabelFrame(self, text="3. Import", padding=10)
        actions.grid(row=4, column=0, sticky="ew", padx=12, pady=6)
        actions.columnconfigure(8, weight=1)
        ttk.Button(actions, text="Überwachung starten", command=self.start_watch).grid(row=0, column=0, padx=(0, 6))
        ttk.Button(actions, text="Stoppen", command=self.stop_watch).grid(row=0, column=1, padx=(0, 14))
        ttk.Label(actions, text="Intervall").grid(row=0, column=2, sticky="e", padx=(0, 6))
        ttk.Spinbox(actions, from_=2, to=120, width=5, textvariable=self.scan_seconds).grid(row=0, column=3, sticky="w", padx=(0, 14))
        ttk.Button(actions, text="Vorhandene importieren", command=lambda: self.import_existing_files(silent=False)).grid(row=0, column=4, padx=(0, 6))
        ttk.Button(actions, text="Datei auswählen", command=self.choose_file_to_import).grid(row=0, column=5, padx=(0, 6), sticky="w")
        ttk.Button(actions, text="Paket öffnen", command=self.open_current_package_in_browser).grid(row=0, column=6, padx=(0, 6), sticky="w")
        ttk.Button(actions, text="Popups schließen", command=self.close_popups_in_browser).grid(row=0, column=7, padx=(0, 6), sticky="w")
        ttk.Button(actions, text="Statistik prüfen", command=self.check_statistics_page_from_clipboard).grid(row=0, column=8, sticky="w")

        body = ttk.PanedWindow(self, orient=tk.VERTICAL)
        body.grid(row=5, column=0, sticky="nsew", padx=12, pady=(6, 12))

        table_frame = ttk.Frame(body)
        table_frame.columnconfigure(0, weight=1)
        table_frame.rowconfigure(0, weight=1)
        self.table = ttk.Treeview(
            table_frame,
            columns=("time", "status", "package", "name", "detail"),
            show="headings",
            height=9,
        )
        self.table.heading("time", text="Zeit")
        self.table.heading("status", text="Status")
        self.table.heading("package", text="Paket")
        self.table.heading("name", text="Datei")
        self.table.heading("detail", text="Detail")
        self.table.column("time", width=130, stretch=False)
        self.table.column("status", width=110, stretch=False)
        self.table.column("package", width=150, stretch=True)
        self.table.column("name", width=240, stretch=True)
        self.table.column("detail", width=380, stretch=True)
        self.table.grid(row=0, column=0, sticky="nsew")
        table_scroll = ttk.Scrollbar(table_frame, orient=tk.VERTICAL, command=self.table.yview)
        table_scroll.grid(row=0, column=1, sticky="ns")
        self.table.configure(yscrollcommand=table_scroll.set)

        log_frame = ttk.Frame(body)
        log_frame.columnconfigure(0, weight=1)
        log_frame.rowconfigure(0, weight=1)
        self.log_text = tk.Text(log_frame, height=10, wrap="word")
        self.log_text.grid(row=0, column=0, sticky="nsew")
        log_scroll = ttk.Scrollbar(log_frame, orient=tk.VERTICAL, command=self.log_text.yview)
        log_scroll.grid(row=0, column=1, sticky="ns")
        self.log_text.configure(yscrollcommand=log_scroll.set)

        body.add(table_frame, weight=2)
        body.add(log_frame, weight=1)

        status_frame = ttk.Frame(self, padding=(12, 0, 12, 10))
        status_frame.grid(row=6, column=0, sticky="ew")
        status_frame.columnconfigure(0, weight=1)
        self.status = tk.StringVar(value="Bereit.")
        ttk.Label(status_frame, textvariable=self.status).grid(row=0, column=0, sticky="w")
        ttk.Button(status_frame, text="Beenden", command=self.on_close).grid(row=0, column=1, sticky="e")

    def show_package_menu(self, event: tk.Event) -> None:
        self.package_combo.focus_set()
        self.package_menu.tk_popup(event.x_root, event.y_root)

    def _bind_setting_saves(self) -> None:
        for variable in (self.login_url, self.download_dir, self.archive_dir, self.package_name, self.chrome_path, self.browser_profile):
            variable.trace_add("write", lambda *_args: self.schedule_save_settings())
        self.scan_seconds.trace_add("write", lambda *_args: self.schedule_save_settings())
        self.debug_port.trace_add("write", lambda *_args: self.schedule_save_settings())

    def schedule_save_settings(self) -> None:
        if self._saving_settings:
            return
        if self._save_after_id is not None:
            self.after_cancel(self._save_after_id)
        self._save_after_id = self.after(150, self.save_settings)

    def save_settings(self, include_current_package: bool = False) -> None:
        self._save_after_id = None
        self._saving_settings = True
        try:
            package = self.current_package_name()
            packages = self.merged_package_names(package) if include_current_package else list(self.package_history)
            existing = load_user_config()
            data = {
                "app": APP_NAME,
                "version": APP_VERSION,
                "login_url": self.login_url.get().strip() or DEFAULT_LOGIN_URL,
                "download_dir": str(expand_path(self.download_dir.get())),
                "archive_dir": str(expand_path(self.archive_dir.get())),
                "current_package": package,
                "packages": packages,
                "strato_packages": existing.get("strato_packages", []),
                "package_statuses": existing.get("package_statuses", {}),
                "hidden_packages": existing.get("hidden_packages", []),
                "scan_seconds": max(2, int(self.scan_seconds.get())),
                "chrome_path": str(expand_path(self.chrome_path.get())),
                "debug_port": max(1, int(self.debug_port.get())),
                "browser_profile": str(expand_path(self.browser_profile.get())),
                "updated_at": datetime.now().isoformat(timespec="seconds"),
            }
            user_saved = try_save_json_file(user_config_path(), data)
            try:
                save_package_history(expand_path(self.archive_dir.get()), packages, package)
            except (OSError, ValueError) as exc:
                if hasattr(self, "log_text"):
                    self._log(f"Archiv-Einstellungen konnten nicht gespeichert werden: {exc}", error=True)
            if not user_saved and hasattr(self, "log_text"):
                self._log("Benutzerprofil-Einstellungen konnten nicht gespeichert werden.", error=True)
        finally:
            self._saving_settings = False

    def on_close(self) -> None:
        if self._save_after_id is not None:
            self.after_cancel(self._save_after_id)
            self._save_after_id = None
        self.watch_enabled = False
        self.save_settings(include_current_package=True)
        self.destroy()

    def merged_package_names(self, current_package: str) -> list[str]:
        names = list(self.package_history) + [current_package]
        unique: list[str] = []
        seen: set[str] = set()
        for name in names:
            normalized = normalize_package_name(name)
            key = normalized.casefold()
            if key not in seen:
                unique.append(normalized)
                seen.add(key)
        return unique

    def refresh_archive(self) -> None:
        self.archive = LogArchive(expand_path(self.archive_dir.get()), self.current_package_name())

    def current_package_name(self) -> str:
        raw_package = self.package_name.get()
        package = normalize_package_name(raw_package)
        if raw_package != package:
            self.package_name.set(package)
        return package

    def refresh_package_history(self) -> None:
        archive_root = expand_path(self.archive_dir.get())
        self.package_history = load_package_history(archive_root, self.current_package_name())
        if hasattr(self, "package_combo"):
            self.package_combo.configure(values=self.package_history)

    def remember_current_package(self) -> None:
        package = self.current_package_name()
        self.package_history = self.merged_package_names(package)
        self.save_settings(include_current_package=True)
        self.refresh_package_history()
        self._log(f"Paket gespeichert: {package}")
        self.status.set(f"Paket aktiv: {package}")

    def remove_current_package_entry(self) -> None:
        package = self.current_package_name()
        target = package.casefold()
        remaining = [
            item
            for item in self.package_history
            if normalize_package_name(item).casefold() != target
        ]
        remove_package_from_saved_configs(expand_path(self.archive_dir.get()), package)
        self.package_history = remaining
        next_package = remaining[0] if remaining else DEFAULT_PACKAGE_NAME
        self.package_name.set(next_package)
        self.package_combo.configure(values=self.package_history)
        self.save_settings(include_current_package=bool(remaining))
        self._log(f"Auswahleintrag gelöscht: {package}. Paketordner auf der Festplatte wurde nicht entfernt.")
        self.status.set(f"Auswahleintrag gelöscht: {package}")

    def open_current_package_folder(self) -> None:
        package = self.current_package_name()
        open_folder(package_archive_dir(expand_path(self.archive_dir.get()), package))
        self.remember_current_package()

    def current_debug_port(self) -> int:
        try:
            port = int(self.debug_port.get())
        except (TypeError, ValueError, tk.TclError):
            port = DEFAULT_DEBUG_PORT
        port = max(1, min(65535, port))
        self.debug_port.set(port)
        return port

    def choose_chrome_path(self) -> None:
        file_name = filedialog.askopenfilename(
            initialdir=str(default_chrome_path().parent),
            title="chrome.exe wählen",
            filetypes=[("Chrome", "chrome.exe"), ("Alle Dateien", "*.*")],
        )
        if file_name:
            file_name = native_path_text(file_name)
            self.chrome_path.set(file_name)
            self.save_settings(include_current_package=True)
            self._log(f"Chrome-Pfad gesetzt: {file_name}")

    def choose_browser_profile(self) -> None:
        folder = filedialog.askdirectory(initialdir=self.browser_profile.get(), title="Chrome-Profilordner wählen")
        if folder:
            folder = native_path_text(folder)
            self.browser_profile.set(folder)
            self.save_settings(include_current_package=True)
            self._log(f"Steuerbares Chrome-Profil gesetzt: {folder}")

    def start_controlled_chrome(self) -> None:
        chrome = expand_path(self.chrome_path.get())
        profile = expand_path(self.browser_profile.get())
        download = expand_path(self.download_dir.get())
        port = self.current_debug_port()
        url = self.login_url.get().strip() or DEFAULT_LOGIN_URL
        if not chrome.exists():
            messagebox.showerror("Chrome nicht gefunden", f"Chrome wurde nicht gefunden:\n{chrome}")
            return
        self.save_settings(include_current_package=True)
        try:
            process = launch_controlled_chrome(chrome, profile, port, url, download, start_visible=True)
        except OSError as exc:
            self._log(f"Steuerbarer Browser konnte nicht gestartet werden: {exc}", error=True)
            messagebox.showerror("Chrome starten", str(exc))
            return
        if process is None:
            self._log(f"Steuerbarer Browser läuft bereits auf Port {port}; Loginseite wurde geöffnet.")
        else:
            self._log(f"Steuerbarer Browser gestartet auf Port {port} mit Profil {profile}.")
        self.status.set("Steuerbarer Browser bereit. Der Batch fragt Zugangsdaten bei Bedarf ab.")

    def check_controlled_chrome(self) -> None:
        port = self.current_debug_port()
        tabs = chrome_debug_tabs(port)
        tab = choose_strato_tab(tabs)
        if not tab:
            self._log(f"Keine Verbindung zu steuerbarem Browser auf Port {port}.", error=True)
            self.status.set("Steuerbarer Browser nicht erreichbar.")
            return
        title = tab.get("title", "")
        url = tab.get("url", "")
        self._log(f"Steuerbarer Browser erreichbar: {title} - {url}")
        self.status.set("Steuerbarer Browser erreichbar.")

    def read_controlled_chrome_text(self) -> str:
        port = self.current_debug_port()
        return chrome_page_text(port)

    def close_popups_in_browser(self) -> None:
        try:
            clicked = controlled_browser_close_popups(self.current_debug_port())
        except Exception as exc:
            self._log(f"Popup-Prüfung fehlgeschlagen: {exc}", error=True)
            self.status.set("Popup-Prüfung fehlgeschlagen.")
            return
        if clicked:
            self._log(f"Popup/Dialog geschlossen: {clicked}")
            self.status.set("Popup/Dialog geschlossen.")
        else:
            self._log("Kein typischer Popup-Schließen-Button gefunden.")
            self.status.set("Kein Popup erkannt.")

    def open_current_package_in_browser(self) -> None:
        package = self.current_package_name()
        try:
            self.close_popups_in_browser()
            clicked = controlled_browser_click_text(self.current_debug_port(), package)
        except Exception as exc:
            self._log(f"Paket konnte im Browser nicht geöffnet werden: {exc}", error=True)
            self.status.set("Paket öffnen fehlgeschlagen.")
            return
        if clicked:
            self._log(f"Paket im Browser angeklickt: {package}")
            self.status.set(f"Paket geöffnet: {package}")
        else:
            self._log(f"Paket nicht auf der aktuellen Browserseite gefunden: {package}", error=True)
            self.status.set("Paket nicht gefunden.")

    def take_package_from_controlled_chrome(self) -> None:
        try:
            controlled_browser_close_popups(self.current_debug_port())
            text = self.read_controlled_chrome_text()
        except Exception as exc:
            self._log(f"Paketliste konnte nicht aus Browser gelesen werden: {exc}", error=True)
            self.status.set("Chrome-Lesen fehlgeschlagen.")
            return
        packages = parse_strato_packages(text)
        if not packages:
            self._log("Im steuerbaren Browser wurde keine STRATO-Paketliste erkannt.", error=True)
            self.status.set("Keine Paketliste im Browser erkannt.")
            return
        self.apply_strato_package_list(packages, "steuerbarem Browser")

    def take_package_from_strato_page(self) -> None:
        try:
            controlled_browser_close_popups(self.current_debug_port())
            text = self.read_controlled_chrome_text()
        except Exception:
            text = ""
        if text:
            packages = parse_strato_packages(text)
            if packages:
                self.apply_strato_package_list(packages, "steuerbarem Browser")
                return

        try:
            clipboard_text = self.clipboard_get()
        except tk.TclError:
            clipboard_text = ""

        packages = parse_strato_packages(clipboard_text)
        if packages:
            self.apply_strato_package_list(packages, "Zwischenablage")
            return

        candidate = extract_package_name_from_text(clipboard_text)
        source = "Zwischenablage"

        if not candidate:
            candidate = extract_package_name_from_strato_windows()
            source = "STRATO-Browserfenster"

        if not candidate:
            messagebox.showinfo(
                "Paketname übernehmen",
                "Ich konnte keinen Paketnamen erkennen. Markiere auf der STRATO-Seite die Paketliste oder den Paketnamen, kopiere den Text mit Strg+C und klicke dann erneut auf 'Von STRATO übernehmen'.",
            )
            return

        self.package_name.set(candidate)
        self.remember_current_package()
        self._log(f"Paket aus {source} übernommen: {candidate}")

    def apply_strato_package_list(self, packages: list[StratoPackage], source: str) -> None:
        labels = save_strato_packages(expand_path(self.archive_dir.get()), packages, packages[0].label)
        self.package_history = list(dict.fromkeys(self.package_history + labels))
        self.package_combo.configure(values=self.package_history)
        self.package_name.set(packages[0].label)
        self.save_settings(include_current_package=True)
        self._log(f"{len(packages)} STRATO-Pakete aus {source} übernommen.")
        for package in packages:
            identifiers = ", ".join(package.identifiers) if package.identifiers else "keine Kennung übernommen"
            self._log(f"Paket: {package.label} - {identifiers}")
        self.status.set(f"{len(packages)} STRATO-Pakete übernommen.")

    def check_statistics_page_from_clipboard(self) -> None:
        source = "steuerbarem Browser"
        try:
            controlled_browser_close_popups(self.current_debug_port())
            text = self.read_controlled_chrome_text()
        except Exception:
            text = ""
            source = "Zwischenablage"
        if not text:
            try:
                text = self.clipboard_get()
            except tk.TclError:
                text = ""

        package = self.current_package_name()
        if re.search(r"(?i)\bstatistik\s+anzeigen\b", text):
            status = "Statistik anzeigen gefunden"
            message = f"Für '{package}' ist in {source} eine Statistik-Schaltfläche sichtbar."
        else:
            status = "Keine Statistik anzeigen gefunden"
            message = f"Für '{package}' wurde in {source} keine 'Statistik anzeigen'-Schaltfläche gefunden."

        save_package_status(expand_path(self.archive_dir.get()), package, status)
        self._log(message)
        self.status.set(status)

    def open_login(self) -> None:
        url = self.login_url.get().strip() or DEFAULT_LOGIN_URL
        self.save_settings(include_current_package=True)
        self._log(f"Öffne Loginseite: {url}")
        webbrowser.open(url, new=2)
        self.status.set("Loginseite geöffnet. Bitte im Browser manuell einloggen und Logfile-Download auslösen.")

    def choose_download_dir(self) -> None:
        folder = filedialog.askdirectory(initialdir=self.download_dir.get(), title="Browser-Downloadordner wählen")
        if folder:
            folder = native_path_text(folder)
            self.download_dir.set(folder)
            self.save_settings(include_current_package=True)
            self._log(f"Downloadordner gesetzt: {folder}")

    def choose_archive_dir(self) -> None:
        folder = filedialog.askdirectory(initialdir=self.archive_dir.get(), title="Lokales Archiv wählen")
        if folder:
            folder = native_path_text(folder)
            self.archive_dir.set(folder)
            self.refresh_archive()
            self.refresh_package_history()
            self.save_settings(include_current_package=True)
            self._log(f"Archivordner gesetzt: {folder}")

    def choose_file_to_import(self) -> None:
        file_name = filedialog.askopenfilename(
            initialdir=self.download_dir.get(),
            title="STRATO-Logdatei auswählen",
            filetypes=[
                ("Log-Archive", "*.zip *.gz *.tgz *.tar"),
                ("Log/Text", "*.log *.txt *.csv"),
                ("Alle Dateien", "*.*"),
            ],
        )
        if file_name:
            self.import_one(Path(file_name))

    def start_watch(self) -> None:
        package = self.current_package_name()
        self.remember_current_package()
        self.watch_enabled = True
        self.watch_started_at = time.time()
        self.pending_sizes.clear()
        self.status.set("Überwachung läuft.")
        self._log(f"Überwachung gestartet für Paket '{package}': {self.download_dir.get()}")
        self.scan_downloads()

    def stop_watch(self) -> None:
        self.watch_enabled = False
        self.status.set("Überwachung gestoppt.")
        self._log("Überwachung gestoppt.")

    def scan_downloads(self) -> None:
        if not self.watch_enabled:
            return
        download_dir = expand_path(self.download_dir.get())
        if not download_dir.exists():
            self._log(f"Downloadordner existiert nicht: {download_dir}", error=True)
        else:
            now = time.time()
            for path in sorted(download_dir.iterdir(), key=lambda p: p.stat().st_mtime if p.exists() else 0):
                if not is_candidate_file(path):
                    continue
                try:
                    stat = path.stat()
                except OSError as exc:
                    self._log(f"Kann Datei nicht lesen: {path} ({exc})", error=True)
                    continue
                if stat.st_mtime < self.watch_started_at - 2:
                    continue
                key = str(path.resolve())
                last = self.pending_sizes.get(key)
                if last is None:
                    self.pending_sizes[key] = (stat.st_size, now)
                    self._add_row("Wartet", path, "Download gefunden, warte auf stabile Dateigröße.", self.current_package_name())
                    continue
                last_size, first_seen = last
                if stat.st_size == last_size and now - first_seen >= 2:
                    self.import_one(path)
                    self.pending_sizes.pop(key, None)
                else:
                    self.pending_sizes[key] = (stat.st_size, now)

        interval_ms = max(2, int(self.scan_seconds.get())) * 1000
        self.after(interval_ms, self.scan_downloads)

    def import_existing_files(self, silent: bool) -> None:
        download_dir = expand_path(self.download_dir.get())
        if not download_dir.exists():
            self._log(f"Downloadordner existiert nicht: {download_dir}", error=True)
            return
        count = 0
        for path in sorted(download_dir.iterdir(), key=lambda p: p.stat().st_mtime if p.exists() else 0):
            if is_candidate_file(path):
                self.import_one(path)
                count += 1
        if count == 0 and not silent:
            self._log("Keine passenden Dateien im Downloadordner gefunden.")
        elif not silent:
            self._log(f"Importlauf abgeschlossen, geprüfte Dateien: {count}.")

    def import_one(self, path: Path) -> None:
        try:
            self.remember_current_package()
            self.refresh_archive()
            result = self.archive.import_file(path)
        except Exception as exc:  # GUI boundary: report instead of crashing.
            self._add_row("Fehler", path, str(exc), self.current_package_name())
            self._log(f"Fehler beim Import von {path}: {exc}", error=True)
            self.status.set("Importfehler.")
            return

        self._add_row(result.status, result.source, result.detail, result.package_name)
        self._log(f"{result.status} [{result.package_name}]: {result.source.name} - {result.detail}")
        if result.copied_to:
            self.status.set(f"Importiert: {result.copied_to}")

    def _add_row(self, status: str, path: Path, detail: str, package: str) -> None:
        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
        self.table.insert("", 0, values=(timestamp, status, package, path.name, detail))

    def _log(self, message: str, error: bool = False) -> None:
        timestamp = datetime.now().strftime("%H:%M:%S")
        prefix = "FEHLER" if error else "INFO"
        self.log_text.insert("end", f"[{timestamp}] {prefix}: {message}\n")
        self.log_text.see("end")

    def show_parameter_help(self) -> None:
        text = (
            "Beispiele:\n\n"
            "StratoLogAssistent.exe --auto-watch\n\n"
            "StratoLogAssistent.exe --download-dir \"%USERPROFILE%\\Downloads\" "
            "--archive-dir \"E:\\codex\\strato-logs\\archive\"\n\n"
            "StratoLogAssistent.exe --package \"meine-domain.de\" --auto-watch\n\n"
            "Parameter:\n"
            "--download-dir ORDNER       Browser-Downloadordner\n"
            "--archive-dir ORDNER        Zielordner fuer importierte Logs\n"
            "--package NAME              Paket-/Domainname fuer getrennte Ablage\n"
            "--chrome-path PFAD          Pfad zum steuerbaren Browser\n"
            "--debug-port ZAHL           Remote-Debugging-Port, Standard 9222\n"
            "--browser-profile ORDNER    separates Chrome-Profil\n"
            "--login-url URL             Loginseite, Standard: https://www.strato.de/login\n"
            "--scan-seconds ZAHL         Ueberwachungsintervall\n"
            "--auto-watch                Ueberwachung direkt starten\n"
            "--import-existing           Vorhandene passende Downloads beim Start importieren\n"
            "\nDie GUI-Eingaben werden automatisch im Windows-Benutzerprofil gespeichert.\n"
            "Der steuerbare Browser nutzt ein separates Profil und den lokalen Debug-Port.\n"
            "Paketlisten und Statistikstatus kann die App direkt aus diesem Browser lesen.\n"
        )
        messagebox.showinfo("Parameter-Hilfe", text)


def main(argv: list[str] | None = None) -> int:
    startup_log("main start")
    try:
        args = parse_args(argv)
        startup_log("args parsed")
        app = StratoLogApp(args)
        startup_log("app created")
        force_show_window(app)
        app.update()
        force_show_window(app)
        app.after(300, lambda: force_show_window(app))
        app.after(1500, lambda: force_show_window(app))
        try:
            app.attributes("-topmost", True)
            app.after(1200, lambda: app.attributes("-topmost", False))
        except tk.TclError:
            pass
        startup_log("mainloop start")
        app.mainloop()
        startup_log("mainloop ended")
    except Exception as exc:
        startup_log(f"fatal: {exc!r}\n{traceback.format_exc()}")
        try:
            messagebox.showerror(APP_NAME, f"Start fehlgeschlagen:\n{exc}")
        except Exception:
            pass
        raise
    return 0


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