#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
AI Knowledge Sync – SMB zu lokaler KI-Wissensdatenbank
(c) 2026 by rbconnect.eu - mail@rbconnect.eu
======================================================

Funktionen:
- SMB/CIFS-Freigabe automatisch mounten
- Zugangsdaten interaktiv erfassen oder sicher speichern
- Netzwerk-Unterordner getrennt von der SMB-Freigabe konfigurieren
- Mount bei Bedarf automatisch wieder aushängen
- Rekursive Verarbeitung von PDF, EPUB, Text und Quellcode
- Optional Originaldateien lokal kopieren
- Unveränderte Dateien anhand ihrer Metadaten überspringen
- INDEX.md, manifest.json und Fehlerprotokoll erstellen
- Einstellbare Worker, Dateigröße und ausgeschlossene Verzeichnisse
- PDFs werden vorab mit qpdf geprüft; defekte PDFs werden
  sofort übersprungen (ohne langen pdfplumber-Traceback)
- Reparierbare PDFs werden optional über qpdf repariert
  (Reparatur nur in /tmp, die Netzwerkfreigabe bleibt unverändert)
- Bereits erzeugte gute Markdown-Dateien werden NIE gelöscht oder
  durch einen fehlgeschlagenen Lauf beschädigt (atomares Schreiben)
- Ein Fehler bei einer Datei stoppt nicht die Verarbeitung der übrigen Dateien
- Keine Änderungen an den Dateien auf der Netzwerkfreigabe

Benötigte Systempakete:
    sudo apt install cifs-utils qpdf

Python-Pakete:
    pdfplumber ebooklib beautifulsoup4 tqdm
"""

import os
import sys
import json
import time
import shutil
import hashlib
import logging
import getpass
import tempfile
import signal
import threading
import subprocess

from pathlib import Path, PurePosixPath
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed

import pdfplumber
import ebooklib
from ebooklib import epub
from bs4 import BeautifulSoup
from tqdm import tqdm


# ============================================================
# 1. ZENTRALE KONFIGURATION
# ============================================================

# --- SMB-Server ---
SMB_SERVER = "servername.local"

# Tatsächlicher Name der SMB-Freigabe.
# Annahme: "ebooks-archiv" ist der Freigabename.
SMB_SHARE = "ebooks-archiv"

# Unterordner innerhalb der Freigabe.
# Leerzeichen sind erlaubt.
SMB_REMOTE_SUBDIR = (
    "nextcloud/ebooks archiv"
)

# SMB-Protokollversion: "3.0", "3.1.1" oder "2.1"
SMB_VERSION = "3.0"

# SMB-Benutzer. Leer = interaktive Abfrage.
SMB_USERNAME = ""

# Lokaler Mountpunkt.
MOUNT_POINT = Path("/mnt/ebooks-archiv")

# SMB-Mount automatisch wieder aushängen, wenn dieses Skript
# den Mount selbst erstellt hat.
AUTO_UNMOUNT = True

# Falls bereits gemountet: vorhandenen Mount verwenden.
REUSE_EXISTING_MOUNT = True


# --- Zugangsdaten ---
#
# "prompt":
#   Zugangsdaten interaktiv abfragen.
#
# "file":
#   Zugangsdaten aus einer geschützten Datei lesen.
#   Falls die Datei fehlt, wird nachgefragt und sie kann
#   optional erstellt werden.
#
CREDENTIAL_MODE = "prompt"

CREDENTIALS_FILE = (
    Path.home() / ".config" / "ai-knowledge" / "smb.credentials"
)

SAVE_CREDENTIALS_ON_FIRST_USE = False


# --- Lokales Ziel ---
TARGET_DIR = Path(
    "/home/MEIN USERNAME/Dokumente/AI-Knowledge"
)

# False: Nur Markdown-Dateien erstellen.
# True: Originaldateien zusätzlich spiegeln.
COPY_ORIGINAL_FILES = False

# Bei True werden vorhandene Originaldateien nicht überschrieben.
PRESERVE_EXISTING_ORIGINALS = True


# --- Verarbeitung ---
#
# Anzahl paralleler Worker-Threads.
# Bei Netzwerkdateien ist ThreadPoolExecutor meist sinnvoll.
#
MAX_WORKERS = 8

# Größere Dateien überspringen, z. B. 100 MB.
MAX_FILE_SIZE_MB = 50

# Unveränderte Dateien anhand von Größe und Änderungszeit
# überspringen. SHA256 wird nur zur Manifest-Dokumentation
# verwendet, nicht bei jedem Lauf für jede Datei berechnet.
SKIP_UNCHANGED = True

# Optional alle Dateien mit SHA256 prüfen.
# Das liest sämtliche Dateien vollständig und belastet das NAS.
VERIFY_WITH_SHA256 = False

# Keine symbolischen Links verfolgen.
FOLLOW_SYMLINKS = False

# Unter dem Gesamtbalken je eine Fortschrittszeile pro gleichzeitig
# laufender Konvertierung anzeigen (PDF: Seiten, EPUB: Kapitel).
# Die Anzahl wird auf die Terminalhöhe begrenzt; Worker ohne freie
# Zeile arbeiten trotzdem normal weiter.
SHOW_WORKER_BARS = True

# Dateien, die schon einmal nicht verarbeitet werden konnten (defektes
# PDF/EPUB usw.), beim nächsten Lauf überspringen, solange sich Größe und
# Änderungszeit nicht ändern. Geänderte Dateien werden erneut versucht.
# False = defekte Dateien bei jedem Lauf erneut versuchen.
SKIP_KNOWN_BAD_FILES = True


# --- PDF-Integritätsprüfung (qpdf) ---

# PDFs vor der Verarbeitung mit "qpdf --check" prüfen.
# Defekte PDFs werden sofort übersprungen, ohne dass pdfplumber
# sie überhaupt öffnet.
CHECK_PDF_WITH_QPDF = True

# Defekte PDFs vor dem Überspringen per qpdf zu reparieren versuchen.
# Die reparierte Kopie liegt nur temporär in /tmp; die Datei auf der
# Netzwerkfreigabe wird nie verändert.
REPAIR_BROKEN_PDFS = True

# Zeitlimit pro qpdf-Aufruf in Sekunden.
QPDF_TIMEOUT_SECONDS = 180

QPDF_PATH = shutil.which("qpdf")


# --- Ausgeschlossene Verzeichnisse ---
EXCLUDED_DIRECTORIES = {
    ".git", ".svn", ".hg",
    ".venv", "venv", "env",
    "__pycache__", ".pytest_cache",
    "node_modules", ".npm",
    "dist", "build", ".USERNAME",
    ".idea", ".vscode",
}

EXCLUDED_FILES = {
    ".DS_Store",
    "Thumbs.db",
}


# --- Dateiendungen, die grundsätzlich als Binärdateien gelten ---
BINARY_EXTENSIONS = {
    ".exe", ".dll", ".so", ".bin", ".iso",
    ".zip", ".7z", ".rar", ".tar", ".gz",
    ".bz2", ".xz", ".zst",
    ".jpg", ".jpeg", ".png", ".gif", ".bmp",
    ".webp", ".tif", ".tiff", ".ico",
    ".mp3", ".wav", ".flac", ".ogg",
    ".mp4", ".mkv", ".avi", ".mov", ".wmv",
    ".woff", ".woff2", ".ttf", ".otf",
    ".pyc", ".pyo", ".class",
    ".db", ".sqlite", ".sqlite3",
}


# --- Sprachzuordnung für Markdown-Codeblöcke ---
LANGUAGE_MAP = {
    ".py": "python",
    ".sh": "bash", ".bash": "bash", ".zsh": "bash",
    ".ps1": "powershell", ".psm1": "powershell",
    ".bat": "bat", ".cmd": "bat",
    ".php": "php",
    ".js": "javascript", ".jsx": "javascript",
    ".ts": "typescript", ".tsx": "typescript",
    ".html": "html", ".htm": "html",
    ".css": "css",
    ".json": "json",
    ".yaml": "yaml", ".yml": "yaml",
    ".xml": "xml",
    ".sql": "sql",
    ".c": "c", ".h": "c",
    ".cpp": "cpp", ".cc": "cpp", ".cxx": "cpp",
    ".hpp": "cpp",
    ".java": "java",
    ".go": "go",
    ".rs": "rust",
    ".rb": "ruby",
    ".pl": "perl",
    ".lua": "lua",
    ".swift": "swift",
    ".kt": "kotlin",
    ".dart": "dart",
    ".r": "r",
    ".tex": "latex",
    ".ini": "ini", ".cfg": "ini", ".conf": "ini",
    ".toml": "toml",
    ".dockerfile": "docker",
    ".makefile": "makefile",
}

TEXT_EXTENSIONS = {
    ".txt", ".md", ".markdown", ".rst",
    ".log", ".csv", ".tsv",
    ".lst", ".list", ".properties",
    ".env", ".gitignore", ".gitattributes",
    ".editorconfig", ".dockerignore",
    ".service", ".timer", ".desktop",
    ".patch", ".diff",
}

SPECIAL_TEXT_NAMES = {
    "dockerfile": "docker",
    "makefile": "makefile",
    "jenkinsfile": "groovy",
    "vagrantfile": "ruby",
    "gemfile": "ruby",
    "rakefile": "ruby",
    "license": "text",
    "readme": "text",
    "notice": "text",
}


# ============================================================
# 2. LOGGING
# ============================================================

TARGET_DIR.mkdir(parents=True, exist_ok=True)

LOG_FILE = TARGET_DIR / "ai_knowledge_sync.log"
ERROR_REPORT_FILE = TARGET_DIR / "fehlerprotokoll.tsv"

logging.basicConfig(
    filename=LOG_FILE,
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
    encoding="utf-8",
)

log = logging.getLogger("ai_knowledge_sync")

# pdfminer/pdfplumber sind bei defekten PDFs sehr gesprächig.
for noisy_logger in ("pdfminer", "pdfplumber"):
    logging.getLogger(noisy_logger).setLevel(logging.ERROR)


class DamagedPdfError(Exception):
    """PDF ist beschädigt und nicht reparierbar (kein Traceback nötig)."""


# ============================================================
# 2b. FORTSCHRITTSZEILEN PRO WORKER
# ============================================================

class WorkerBars:
    """Eine tqdm-Zeile pro gleichzeitig laufender Konvertierung."""

    LABEL_WIDTH = 44
    BAR_FORMAT = "{desc} {percentage:3.0f}%|{bar:25}| {n_fmt}/{total_fmt}"

    def __init__(self, count, first_position=1):
        self._lock = threading.Lock()
        self._free = list(range(count))
        self._names = [""] * count
        self._bars = [
            tqdm(
                total=1,
                position=first_position + index,
                desc=self._label("Leerlauf"),
                leave=False,
                bar_format=self.BAR_FORMAT,
                ncols=110,
                dynamic_ncols=False,
                mininterval=0.2,
            )
            for index in range(count)
        ]

    @classmethod
    def _label(cls, text):
        if len(text) > cls.LABEL_WIDTH:
            text = text[:cls.LABEL_WIDTH - 1] + "…"
        return text.ljust(cls.LABEL_WIDTH)

    def acquire(self, name):
        """Reserviert eine freie Zeile oder liefert None."""
        with self._lock:
            if not self._free:
                return None
            slot = self._free.pop(0)

        self._names[slot] = name
        bar = self._bars[slot]
        bar.set_description_str(self._label(name))
        bar.total = 1
        bar.n = 0
        bar.refresh()
        return slot

    def set_status(self, slot, text):
        name = self._names[slot]

        if text:
            name = f"{name[:26]} [{text}]"

        self._bars[slot].set_description_str(self._label(name))

    def set_progress(self, slot, done, total):
        bar = self._bars[slot]
        bar.total = max(total, 1)
        bar.n = min(done, bar.total)
        bar.refresh()

    def release(self, slot):
        bar = self._bars[slot]
        bar.set_description_str(self._label("Leerlauf"))
        bar.total = 1
        bar.n = 0
        bar.refresh()

        with self._lock:
            self._free.append(slot)

    def close(self):
        for bar in self._bars:
            bar.close()


# Wird in main() gesetzt; None = keine Worker-Zeilen.
WORKER_BARS = None

_thread_state = threading.local()


# --- Sauberes Stoppen mit Strg+C ---
# Erstes Strg+C: laufende Dateien werden abgebrochen, wartende nicht mehr
# gestartet; Index, Manifest und Fehlerprotokoll werden noch geschrieben
# und der Mount wird ausgehängt. Zweites Strg+C: sofort beenden.
STOP_EVENT = threading.Event()


class StopRequested(Exception):
    """Der Benutzer hat einen sauberen Stopp angefordert."""


def check_stop():
    if STOP_EVENT.is_set():
        raise StopRequested()


def report_progress(done, total):
    """Meldet den Fortschritt der aktuellen Datei dieses Worker-Threads."""

    bars = WORKER_BARS
    slot = getattr(_thread_state, "slot", None)

    if bars is None or slot is None:
        return

    try:
        bars.set_progress(slot, done, total)
    except Exception:
        pass


def report_status(text):
    """Zeigt einen kurzen Status (z. B. 'qpdf prüft') in der Worker-Zeile."""

    bars = WORKER_BARS
    slot = getattr(_thread_state, "slot", None)

    if bars is None or slot is None:
        return

    try:
        bars.set_status(slot, text)
    except Exception:
        pass


# ============================================================
# 3. SYSTEM- UND MOUNT-HILFSFUNKTIONEN
# ============================================================

def run_command(command, input_text=None):
    """Führt einen Systembefehl ohne Shell aus."""

    return subprocess.run(
        command,
        input=input_text,
        text=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        check=False,
    )


def require_cifs_tools():
    """Prüft, ob mount.cifs vorhanden ist."""

    if shutil.which("mount.cifs") is None:
        raise RuntimeError(
            "cifs-utils fehlt. Installation:\n"
            "sudo apt install cifs-utils"
        )


def is_mountpoint(path):
    """Prüft, ob path ein tatsächlicher Mountpunkt ist."""

    result = run_command([
        "mountpoint", "-q", str(path)
    ])

    return result.returncode == 0


def get_mount_source(path):
    """
    Liefert die Quelle des Mounts über findmnt.
    Hilft zu verhindern, dass ein fremder Mount
    versehentlich als unsere SMB-Quelle behandelt wird.
    """

    result = run_command([
        "findmnt", "-n", "-o", "SOURCE", "--target", str(path)
    ])

    if result.returncode != 0:
        return ""

    return result.stdout.strip()


def validate_existing_mount():
    """Prüft einen bereits vorhandenen Mount."""

    source = get_mount_source(MOUNT_POINT)

    if not source:
        return False

    expected = f"//{SMB_SERVER}/{SMB_SHARE}"

    # findmnt kann bei CIFS z. B. //server/share ausgeben.
    if source.rstrip("/").lower() != expected.rstrip("/").lower():
        raise RuntimeError(
            f"Mountpunkt {MOUNT_POINT} wird bereits von einer "
            f"anderen Quelle verwendet: {source}\n"
            f"Erwartet: {expected}"
        )

    if not REUSE_EXISTING_MOUNT:
        raise RuntimeError(
            f"{MOUNT_POINT} ist bereits gemountet. "
            "REUSE_EXISTING_MOUNT ist deaktiviert."
        )

    return True


# ============================================================
# 4. SMB-ZUGANGSDATEN
# ============================================================

def read_credentials_file():
    """Liest eine CIFS-Credentials-Datei."""

    if not CREDENTIALS_FILE.is_file():
        return None, None

    # Keine welt- oder gruppenlesbare Kennwortdatei akzeptieren.
    mode = CREDENTIALS_FILE.stat().st_mode & 0o777

    if mode & 0o077:
        raise RuntimeError(
            f"Unsichere Berechtigungen für {CREDENTIALS_FILE}.\n"
            "Setze sie mit chmod 600 auf den Eigentümer."
        )

    username = None
    password = None

    for line in CREDENTIALS_FILE.read_text(
        encoding="utf-8"
    ).splitlines():

        if "=" not in line:
            continue

        key, value = line.split("=", 1)
        key = key.strip().lower()

        if key == "username":
            username = value

        elif key == "password":
            password = value

    if not username or password is None:
        raise RuntimeError(
            f"Ungültige Credentials-Datei: {CREDENTIALS_FILE}"
        )

    return username, password


def save_credentials(username, password):
    """Speichert Zugangsdaten mit Dateimodus 0600."""

    CREDENTIALS_FILE.parent.mkdir(
        parents=True,
        exist_ok=True,
    )

    fd = os.open(
        CREDENTIALS_FILE,
        os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
        0o600,
    )

    with os.fdopen(fd, "w", encoding="utf-8") as handle:
        handle.write(f"username={username}\n")
        handle.write(f"password={password}\n")

    CREDENTIALS_FILE.chmod(0o600)


def get_credentials():
    """Ermittelt Benutzername und Passwort."""

    if CREDENTIAL_MODE not in {"prompt", "file"}:
        raise RuntimeError(
            "CREDENTIAL_MODE muss 'prompt' oder 'file' sein."
        )

    username = SMB_USERNAME.strip()

    if CREDENTIAL_MODE == "file":
        file_user, file_password = read_credentials_file()

        if file_user is not None:
            return file_user, file_password

        print(f"\nCredentials-Datei fehlt: {CREDENTIALS_FILE}")

    if not username:
        username = input("SMB-Benutzername: ").strip()

    if not username:
        raise RuntimeError("SMB-Benutzername darf nicht leer sein.")

    password = getpass.getpass("SMB-Passwort: ")

    if not password:
        raise RuntimeError("SMB-Passwort darf nicht leer sein.")

    if SAVE_CREDENTIALS_ON_FIRST_USE:
        save_credentials(username, password)
        print(f"Zugangsdaten gespeichert: {CREDENTIALS_FILE}")

    return username, password


# ============================================================
# 5. SMB MOUNT / UNMOUNT
# ============================================================

def mount_smb():
    """
    Bindet die konfigurierte SMB-Freigabe ein.
    Rückgabewert True: Dieser Lauf hat selbst gemountet.
    Rückgabewert False: Bereits vorhandenen Mount verwendet.
    """

    require_cifs_tools()

    # /mnt ist normalerweise root-owned. Den Mountpunkt deshalb
    # erst mit sudo anlegen, statt ihn vorher als normaler Benutzer
    # erstellen zu wollen.
    if is_mountpoint(MOUNT_POINT):
        validate_existing_mount()
        print(f"Verwende vorhandenen SMB-Mount: {MOUNT_POINT}")
        return False

    username, password = get_credentials()

    # Temporäre Credentials-Datei: Passwort wird nicht als
    # Kommandozeilenargument an mount übergeben.
    fd, temporary_name = tempfile.mkstemp(
        prefix="ai-knowledge-smb-",
        dir="/tmp",
    )

    temporary_path = Path(temporary_name)

    try:
        os.fchmod(fd, 0o600)

        with os.fdopen(fd, "w", encoding="utf-8") as handle:
            handle.write(f"username={username}\n")
            handle.write(f"password={password}\n")

        del password

        result = run_command([
            "sudo", "mkdir", "-p", str(MOUNT_POINT)
        ])

        if result.returncode != 0:
            raise RuntimeError(
                "Mountpunkt konnte nicht erstellt werden:\n"
                + result.stderr.strip()
            )

        mount_command = [
            "sudo", "mount",
            "-t", "cifs",
            f"//{SMB_SERVER}/{SMB_SHARE}",
            str(MOUNT_POINT),
            "-o",
            (
                f"credentials={temporary_path},"
                f"vers={SMB_VERSION},"
                "iocharset=utf8,"
                "ro,"
                "nosuid,"
                "nodev,"
                "noexec"
            ),
        ]

        result = run_command(mount_command)

        if result.returncode != 0:
            raise RuntimeError(
                "SMB-Mount fehlgeschlagen:\n"
                f"{result.stderr.strip()}\n\n"
                "Prüfe Server, Freigabename, Zugangsdaten, "
                "SMB-Version und Netzwerkverbindung."
            )

        if not is_mountpoint(MOUNT_POINT):
            raise RuntimeError(
                "Mount-Befehl war erfolgreich, aber der Mountpunkt "
                "wird nicht als Mount erkannt."
            )

        print(f"SMB-Freigabe eingebunden: {MOUNT_POINT}")
        return True

    finally:
        try:
            temporary_path.unlink(missing_ok=True)
        except OSError:
            log.exception("Temporäre Credentials-Datei konnte nicht gelöscht werden.")


def unmount_smb(mounted_by_this_run):
    """Hängt nur einen von diesem Skript erstellten Mount aus."""

    if not AUTO_UNMOUNT or not mounted_by_this_run:
        return

    if not is_mountpoint(MOUNT_POINT):
        return

    print(f"Hänge SMB-Freigabe aus: {MOUNT_POINT}")

    result = run_command([
        "sudo", "umount", str(MOUNT_POINT)
    ])

    if result.returncode != 0:
        log.error("Unmount fehlgeschlagen: %s", result.stderr.strip())
        print(
            "WARNUNG: Unmount fehlgeschlagen. "
            "Die Freigabe bleibt möglicherweise eingebunden.\n"
            + result.stderr.strip()
        )


# ============================================================
# 6. QUELLPFAD
# ============================================================

def get_source_dir():
    """Ermittelt den tatsächlichen Unterordner der SMB-Freigabe."""

    remote = PurePosixPath(SMB_REMOTE_SUBDIR)

    if remote.is_absolute() or ".." in remote.parts:
        raise RuntimeError(
            "SMB_REMOTE_SUBDIR muss ein relativer Unterpfad "
            "ohne '..' sein."
        )

    source = MOUNT_POINT.joinpath(*remote.parts)

    if not source.is_dir():
        raise RuntimeError(
            f"Quell-Unterordner nicht gefunden:\n{source}\n\n"
            "Prüfe SMB_SHARE und SMB_REMOTE_SUBDIR. "
            "Der Unterordner muss innerhalb der eingebundenen "
            "Freigabe liegen."
        )

    return source


# ============================================================
# 7. DATEIERKENNUNG
# ============================================================

def looks_like_binary(path):
    """Erkennt offensichtliche Binärdateien anhand der ersten Bytes."""

    try:
        with path.open("rb") as handle:
            sample = handle.read(8192)

        return b"\x00" in sample

    except OSError:
        return True


def get_file_type(path):
    """Ermittelt, ob und wie eine Datei verarbeitet werden kann."""

    suffix = path.suffix.lower()
    name = path.name.lower()

    if suffix == ".pdf":
        return "PDF"

    if suffix == ".epub":
        return "EPUB"

    if suffix in BINARY_EXTENSIONS:
        return None

    if suffix in TEXT_EXTENSIONS or suffix in LANGUAGE_MAP:
        return "TEXT"

    if name in SPECIAL_TEXT_NAMES:
        return "TEXT"

    # Unbekannte Endungen werden nur akzeptiert, wenn die Datei
    # nicht offensichtlich binär ist.
    if looks_like_binary(path):
        return None

    return "TEXT"


def detect_encoding(path):
    """Erkennt gängige Textkodierungen anhand einer Stichprobe."""

    with path.open("rb") as handle:
        sample = handle.read(1024 * 1024)

    if sample.startswith(b"\xef\xbb\xbf"):
        return "utf-8-sig"

    if sample.startswith((b"\xff\xfe", b"\xfe\xff")):
        return "utf-16"

    try:
        sample.decode("utf-8")
        return "utf-8"
    except UnicodeDecodeError:
        return "cp1252"


# ============================================================
# 8. HASH UND ÄNDERUNGSERKENNUNG
# ============================================================

def sha256_file(path):
    """Berechnet SHA256 blockweise."""

    digest = hashlib.sha256()

    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)

    return digest.hexdigest()


def source_signature(path):
    """Liefert die Metadaten für die Änderungsprüfung."""

    stat = path.stat()

    signature = {
        "size": stat.st_size,
        "mtime_ns": stat.st_mtime_ns,
    }

    if VERIFY_WITH_SHA256:
        signature["sha256"] = sha256_file(path)

    return signature


def load_previous_manifest():
    """Lädt das vorherige Manifest, falls es vorhanden ist."""

    path = TARGET_DIR / "manifest.json"

    if not path.is_file():
        return {}

    try:
        data = json.loads(path.read_text(encoding="utf-8"))
        return data.get("files", {})
    except (OSError, ValueError):
        log.exception("Vorheriges Manifest konnte nicht gelesen werden.")
        return {}


# ============================================================
# 9. SICHERES SCHREIBEN UND PDF-PRÜFUNG
# ============================================================

def write_markdown_atomic(destination, text):
    """
    Schreibt zuerst in eine temporäre Datei und ersetzt die Zieldatei
    erst nach vollständigem Erfolg. Eine bereits vorhandene gute
    Markdown-Datei bleibt bei jedem Fehler unangetastet.
    """

    destination.parent.mkdir(parents=True, exist_ok=True)

    fd, temporary_name = tempfile.mkstemp(
        prefix=destination.name + ".",
        suffix=".tmp",
        dir=str(destination.parent),
    )
    temporary_path = Path(temporary_name)

    try:
        with os.fdopen(fd, "w", encoding="utf-8") as handle:
            handle.write(text)

        os.replace(temporary_path, destination)

    except BaseException:
        try:
            temporary_path.unlink(missing_ok=True)
        except OSError:
            log.exception(
                "Temporäre Datei konnte nicht entfernt werden: %s",
                temporary_path,
            )
        raise


def qpdf_check(path):
    """
    Führt "qpdf --check" aus.
    Rückgabe: (ok, Meldung).
    Exit-Code 0 = fehlerfrei, 3 = nur Warnungen (Datei ist nutzbar),
    2 = Fehler.
    """

    try:
        result = subprocess.run(
            [QPDF_PATH, "--check", str(path)],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            errors="replace",
            timeout=QPDF_TIMEOUT_SECONDS,
            check=False,
        )
    except subprocess.TimeoutExpired:
        return False, "qpdf --check: Zeitüberschreitung"
    except OSError as exc:
        return False, f"qpdf nicht ausführbar: {exc}"

    if result.returncode in (0, 3):
        return True, ""

    lines = (result.stderr or result.stdout).strip().splitlines()
    detail = lines[-1].strip() if lines else f"Exit-Code {result.returncode}"
    return False, detail


def qpdf_repair(source, repaired):
    """
    Schreibt eine neu aufgebaute Kopie. qpdf rekonstruiert beschädigte
    Strukturen (xref-Tabelle usw.) beim Einlesen automatisch; eine eigene
    Option --repair gibt es dafür nicht.
    """

    try:
        result = subprocess.run(
            [QPDF_PATH, str(source), str(repaired)],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            errors="replace",
            timeout=QPDF_TIMEOUT_SECONDS,
            check=False,
        )
    except (subprocess.TimeoutExpired, OSError):
        return False

    return (
        result.returncode in (0, 3)
        and repaired.is_file()
        and repaired.stat().st_size > 0
    )


# ============================================================
# 10. MARKDOWN-KONVERTIERUNG
# ============================================================

def markdown_header(relative_path, file_type):
    return (
        f"# {relative_path.name}\n\n"
        f"**Quellpfad:** `{relative_path.as_posix()}`\n\n"
        f"**Dateityp:** `{file_type}`\n\n"
        f"**Generiert:** {datetime.now().isoformat(timespec='seconds')}\n\n"
        "---\n\n"
    )


def convert_pdf(pdf_path, destination, relative_path):
    parts = [markdown_header(relative_path, "PDF")]
    failed_pages = 0

    report_status("Seiten lesen")

    with pdfplumber.open(pdf_path) as pdf:
        page_count = len(pdf.pages)
        report_progress(0, page_count)

        for number, page in enumerate(pdf.pages, start=1):
            report_progress(number, page_count)
            check_stop()

            try:
                text = page.extract_text(layout=False)
            except Exception:
                # Einzelne defekte Seite überspringen, Rest behalten.
                failed_pages += 1
                log.warning(
                    "PDF-Seite %d nicht lesbar: %s",
                    number, relative_path,
                )
                continue

            if text:
                parts.append(f"## Seite {number}\n\n{text}\n\n")

            # Seitenobjekt freigeben, spart Speicher bei großen PDFs.
            try:
                page.flush_cache()
            except Exception:
                pass

    if page_count > 0 and failed_pages == page_count:
        raise DamagedPdfError("Keine Seite des PDFs war lesbar")

    write_markdown_atomic(destination, "".join(parts))


def convert_pdf_checked(source, destination, relative_path):
    """
    Prüft das PDF mit qpdf, repariert es bei Bedarf und konvertiert es.
    Rückgabe: True, wenn eine reparierte Kopie verwendet wurde.
    """

    if not (CHECK_PDF_WITH_QPDF and QPDF_PATH):
        convert_pdf(source, destination, relative_path)
        return False

    report_status("qpdf prüft")
    ok, detail = qpdf_check(source)

    if ok:
        convert_pdf(source, destination, relative_path)
        return False

    if not REPAIR_BROKEN_PDFS:
        raise DamagedPdfError(f"PDF beschädigt: {detail}")

    report_status("qpdf repariert")

    with tempfile.TemporaryDirectory(prefix="ai-knowledge-pdf-") as tmp:
        repaired = Path(tmp) / "repaired.pdf"

        if not qpdf_repair(source, repaired):
            raise DamagedPdfError(
                f"PDF beschädigt und nicht reparierbar: {detail}"
            )

        repaired_ok, repaired_detail = qpdf_check(repaired)

        if not repaired_ok:
            raise DamagedPdfError(
                "PDF beschädigt, Reparatur unvollständig: "
                f"{repaired_detail or detail}"
            )

        convert_pdf(repaired, destination, relative_path)
        return True


def convert_epub(source, destination, relative_path):
    report_status("EPUB lesen")

    book = epub.read_epub(str(source))
    parts = [markdown_header(relative_path, "EPUB")]

    documents = [
        item for item in book.get_items()
        if item.get_type() == ebooklib.ITEM_DOCUMENT
    ]
    report_progress(0, len(documents))

    for number, item in enumerate(documents, start=1):
        report_progress(number, len(documents))
        check_stop()

        soup = BeautifulSoup(item.get_content(), "html.parser")

        for element in soup(["script", "style", "head", "title"]):
            element.decompose()

        text = soup.get_text(separator="\n").strip()

        if text:
            parts.append(text + "\n\n---\n\n")

    write_markdown_atomic(destination, "".join(parts))


def convert_text(source, destination, relative_path):
    encoding = detect_encoding(source)

    text = source.read_text(
        encoding=encoding,
        errors="replace",
    )

    suffix = source.suffix.lower()
    language = LANGUAGE_MAP.get(suffix)

    if language is None:
        language = SPECIAL_TEXT_NAMES.get(source.name.lower())

    # Markdown selbst nicht in einen Codeblock einschließen.
    if suffix in {".md", ".markdown"}:
        output = markdown_header(relative_path, "Markdown") + text
    elif language:
        output = (
            markdown_header(relative_path, "Quellcode")
            + f"```{language}\n"
            + text
            + ("" if text.endswith("\n") else "\n")
            + "```\n"
        )
    else:
        output = markdown_header(relative_path, "Text") + text

    write_markdown_atomic(destination, output)


# ============================================================
# 11. DATEI VERARBEITEN
# ============================================================

def _process_file(task):
    source_string, relative_string, file_type, signature = task

    source = Path(source_string)
    relative = Path(relative_string)

    # Endung beibehalten und .md ergänzen:
    # script.py -> script.py.md
    destination = TARGET_DIR / relative.parent / (relative.name + ".md")

    original_destination = TARGET_DIR / relative

    try:
        if source.is_symlink() and not FOLLOW_SYMLINKS:
            return {"status": "skipped", "relative": relative.as_posix(),
                    "reason": "symlink"}

        if source.stat().st_size > MAX_FILE_SIZE_MB * 1024 * 1024:
            return {"status": "skipped", "relative": relative.as_posix(),
                    "reason": "too_large"}

        repaired = False

        if file_type == "PDF":
            repaired = convert_pdf_checked(source, destination, relative)
        elif file_type == "EPUB":
            convert_epub(source, destination, relative)
        else:
            convert_text(source, destination, relative)

        if COPY_ORIGINAL_FILES:
            original_destination.parent.mkdir(parents=True, exist_ok=True)

            if not (
                PRESERVE_EXISTING_ORIGINALS
                and original_destination.exists()
            ):
                shutil.copy2(source, original_destination)

        result = {
            "status": "success",
            "relative": relative.as_posix(),
            "markdown": destination.relative_to(TARGET_DIR).as_posix(),
            "type": file_type,
            **signature,
        }

        if repaired:
            result["repaired"] = True
            log.info("PDF repariert und verarbeitet: %s", source)

        return result

    except StopRequested:
        # Vom Benutzer gestoppt: nichts schreiben, nichts löschen.
        return {
            "status": "cancelled",
            "relative": relative.as_posix(),
        }

    except DamagedPdfError as exc:
        # Erwarteter Fall: kurze Warnung statt Traceback.
        # Eine bereits vorhandene gute .md-Datei wird NICHT angefasst.
        log.warning("PDF übersprungen (%s): %s", exc, source)

        return {
            "status": "error_skipped",
            "relative": relative.as_posix(),
            "markdown": None,
            "type": file_type,
            **signature,
            "error": str(exc),
        }

    except Exception as exc:
        # Defekte/unlesbare Dateien überspringen, statt den Gesamtlauf
        # abzubrechen. Die Zieldatei wird nie gelöscht: Geschrieben wird
        # atomar, ein Fehler hinterlässt also höchstens nichts Neues.
        log.exception("Datei übersprungen (Verarbeitungsfehler): %s", source)

        return {
            "status": "error_skipped",
            "relative": relative.as_posix(),
            "markdown": None,
            "type": file_type,
            **signature,
            "error": f"{type(exc).__name__}: {exc}",
        }


def process_file(task):
    """Reserviert eine Fortschrittszeile und verarbeitet die Datei."""

    if STOP_EVENT.is_set():
        return {"status": "cancelled", "relative": task[1]}

    bars = WORKER_BARS
    slot = None

    if bars is not None:
        try:
            slot = bars.acquire(Path(task[1]).name)
        except Exception:
            slot = None

    _thread_state.slot = slot

    try:
        return _process_file(task)
    finally:
        _thread_state.slot = None

        if slot is not None and bars is not None:
            try:
                bars.release(slot)
            except Exception:
                pass


# ============================================================
# 12. DATEIEN REKURSIV ERFASSEN
# ============================================================

def scan_source_totals(source_dir):
    """Ermittelt Gesamtzahl und Gesamtgröße aller sichtbaren Quelldateien.

    Dabei werden keine Dateiinhalte gelesen, sondern nur Verzeichnisse
    aufgelistet und Dateimetadaten abgefragt.
    """
    total_files = 0
    total_bytes = 0

    for root, dirs, files in os.walk(
        source_dir,
        topdown=True,
        followlinks=FOLLOW_SYMLINKS,
    ):
        dirs[:] = [
            name for name in dirs
            if name not in EXCLUDED_DIRECTORIES
        ]

        root_path = Path(root)

        for filename in files:
            if filename in EXCLUDED_FILES:
                continue

            path = root_path / filename

            try:
                if path.is_symlink() and not FOLLOW_SYMLINKS:
                    continue
                if not path.is_file():
                    continue

                stat = path.stat()
                total_files += 1
                total_bytes += stat.st_size
            except OSError as exc:
                log.warning("Dateigröße nicht erfassbar: %s: %s", path, exc)

    return total_files, total_bytes


def is_known_bad(old, signature):
    """
    True, wenn diese Datei in einem früheren Lauf nicht verarbeitet werden
    konnte und sich seitdem nicht verändert hat.
    """

    if not SKIP_KNOWN_BAD_FILES or not old:
        return False

    if old.get("status") in {"error", "error_skipped"}:
        candidate = old
    else:
        # Frühere gute Markdown-Datei vorhanden, aber die geänderte
        # Quelle ließ sich nicht verarbeiten.
        candidate = old.get("failed_signature")

    if not candidate:
        return False

    if (
        candidate.get("size") != signature["size"]
        or candidate.get("mtime_ns") != signature["mtime_ns"]
    ):
        return False

    if VERIFY_WITH_SHA256 and candidate.get("sha256") != signature.get("sha256"):
        return False

    return True


def discover_files(source_dir, previous_manifest):
    """Erfasst geeignete Dateien und überspringt unveränderte."""

    tasks = []
    unchanged = 0
    skipped = 0
    known_bad = 0

    for root, dirs, files in os.walk(
        source_dir,
        topdown=True,
        followlinks=FOLLOW_SYMLINKS,
    ):
        dirs[:] = [
            name for name in dirs
            if name not in EXCLUDED_DIRECTORIES
        ]

        root_path = Path(root)

        for filename in files:
            if filename in EXCLUDED_FILES:
                continue

            path = root_path / filename

            try:
                if path.is_symlink() and not FOLLOW_SYMLINKS:
                    skipped += 1
                    continue

                if not path.is_file():
                    continue

                # Lesbarkeit wirklich testen. os.access() allein ist bei
                # CIFS/Netzwerkfreigaben nicht immer zuverlässig.
                try:
                    with path.open("rb") as test_handle:
                        test_handle.read(1)
                except OSError as exc:
                    skipped += 1
                    log.warning("Unlesbare Datei übersprungen: %s: %s", path, exc)
                    continue

                if path.stat().st_size > MAX_FILE_SIZE_MB * 1024 * 1024:
                    skipped += 1
                    continue

                file_type = get_file_type(path)

                if file_type is None:
                    skipped += 1
                    continue

                relative = path.relative_to(source_dir)
                signature = source_signature(path)

                destination = (
                    TARGET_DIR / relative.parent / (relative.name + ".md")
                )

                old = previous_manifest.get(relative.as_posix(), {})

                if is_known_bad(old, signature):
                    known_bad += 1
                    continue

                if (
                    SKIP_UNCHANGED
                    and destination.is_file()
                    and old.get("size") == signature["size"]
                    and old.get("mtime_ns") == signature["mtime_ns"]
                    and (
                        not VERIFY_WITH_SHA256
                        or old.get("sha256") == signature.get("sha256")
                    )
                ):
                    unchanged += 1
                    continue

                tasks.append((
                    str(path),
                    relative.as_posix(),
                    file_type,
                    signature,
                ))

            except OSError as exc:
                skipped += 1
                log.warning("Datei übersprungen: %s: %s", path, exc)

    return tasks, unchanged, skipped, known_bad


# ============================================================
# 13. INDEX, MANIFEST UND FEHLERPROTOKOLL
# ============================================================

def write_index(results, source_dir):
    """Erstellt einen navigierbaren Markdown-Index."""

    index = [
        "# Lokale KI-Wissensdatenbank\n\n",
        f"**Quelle:** `{source_dir}`\n\n",
        f"**Erstellt:** {datetime.now().isoformat(timespec='seconds')}\n\n",
        "## Dateien\n\n",
    ]

    for item in sorted(
        results.values(),
        key=lambda entry: entry.get("relative", "").lower(),
    ):
        if item.get("status") not in {"success", "unchanged"}:
            continue

        md_path = item.get("markdown")

        if md_path:
            index.append(f"- [{item['relative']}]({md_path})\n")

    write_markdown_atomic(TARGET_DIR / "INDEX.md", "".join(index))


def write_manifest(results, source_dir):
    """Schreibt Metadaten aller aktuell erfassten Dateien."""

    manifest = {
        "generated": datetime.now().isoformat(timespec="seconds"),
        "source": str(source_dir),
        "target": str(TARGET_DIR),
        "copy_original_files": COPY_ORIGINAL_FILES,
        "files": results,
    }

    write_markdown_atomic(
        TARGET_DIR / "manifest.json",
        json.dumps(manifest, indent=2, ensure_ascii=False),
    )


def write_error_report(results):
    """
    Schreibt alle bekannten defekten Dateien als TSV-Fehlerprotokoll,
    auch die aus früheren Läufen (sie werden ja nicht erneut versucht).
    """

    lines = ["datei\ttyp\tfehler\tvorhandene_markdown_behalten\n"]
    rows = []

    for key, entry in results.items():
        if entry.get("status") in {"error", "error_skipped"}:
            rows.append((key, entry.get("type", ""), entry.get("error", ""), "nein"))
        elif entry.get("failed_signature"):
            rows.append((key, entry.get("type", ""), entry.get("last_error", ""), "ja"))

    for key, file_type, error, kept in sorted(rows, key=lambda row: row[0].lower()):
        error_text = str(error or "").replace("\t", " ").replace("\n", " ")
        lines.append(f"{key}\t{file_type}\t{error_text}\t{kept}\n")

    write_markdown_atomic(ERROR_REPORT_FILE, "".join(lines))


# ============================================================
# 14. HAUPTPROGRAMM
# ============================================================

def main():
    global WORKER_BARS

    mounted_by_this_run = False

    try:
        print("=" * 72)
        print(" AI KNOWLEDGE SYNC – SMB / MARKDOWN / CLAUDE CODE")
        print("=" * 72)
        print(f"Server:      {SMB_SERVER}")
        print(f"Freigabe:    {SMB_SHARE}")
        print(f"Unterordner: {SMB_REMOTE_SUBDIR}")
        print(f"Mountpunkt:  {MOUNT_POINT}")
        print(f"Lokales Ziel:{TARGET_DIR}")
        print(f"Worker:      {MAX_WORKERS}")
        print(f"Originale:   {COPY_ORIGINAL_FILES}")

        if CHECK_PDF_WITH_QPDF and not QPDF_PATH:
            print(
                "WARNUNG:     qpdf nicht gefunden – PDF-Prüfung und "
                "Reparatur sind deaktiviert (sudo apt install qpdf)."
            )
            log.warning("qpdf nicht installiert, PDF-Vorprüfung entfällt.")
        elif CHECK_PDF_WITH_QPDF:
            print(
                f"PDF-Prüfung: qpdf (Reparatur: "
                f"{'an' if REPAIR_BROKEN_PDFS else 'aus'})"
            )

        print()

        mounted_by_this_run = mount_smb()
        source_dir = get_source_dir()

        print(f"\nLese Quelle: {source_dir}")
        print(f"Schreibe Ziel: {TARGET_DIR}\n")

        previous = load_previous_manifest()

        print("Ermittle Gesamtgröße und Dateianzahl der Netzwerkquelle ...")
        total_files, total_bytes = scan_source_totals(source_dir)

        tasks, unchanged, skipped, known_bad = discover_files(
            source_dir,
            previous,
        )

        task_bytes = sum(task[3].get("size", 0) for task in tasks)
        initial_bytes = max(0, total_bytes - task_bytes)
        total_bytes = max(total_bytes, task_bytes)
        initially_counted_files = max(0, total_files - len(tasks))

        print(f"Gesamtbestand:  {total_files:,} Dateien, {total_bytes / (1024 ** 3):.2f} GiB")
        print(f"Unverändert:    {unchanged}")
        print(f"Übersprungen:   {skipped}")
        print(f"Bekannt defekt: {known_bad} (werden übersprungen)")
        print(f"Zu verarbeiten: {len(tasks)} Dateien ({task_bytes / (1024 ** 3):.2f} GiB)")
        print("Hinweis: Nicht unterstützte Dateien und unveränderte Dateien")
        print("werden beim Gesamtfortschritt als bereits berücksichtigt gezählt.\n")

        results = {}

        # Alte Einträge zunächst übernehmen, neue Ergebnisse ersetzen sie.
        for key, value in previous.items():
            results[key] = dict(value)

        for value in results.values():
            if value.get("markdown"):
                value["status"] = "unchanged"

        # Strg+C: erstes Mal sauber stoppen, zweites Mal sofort beenden.
        def request_stop(signum, frame):
            if STOP_EVENT.is_set():
                raise KeyboardInterrupt

            STOP_EVENT.set()
            tqdm.write(
                "\nSTOPP angefordert: laufende Dateien werden abgebrochen, "
                "Fortschritt wird gespeichert ... "
                "(nochmal Strg+C = sofort beenden)"
            )

        try:
            previous_handler = signal.signal(signal.SIGINT, request_stop)
        except ValueError:
            previous_handler = None

        completed_files = initially_counted_files
        error_count = 0
        repaired_count = 0
        error_entries = []

        with tqdm(
            total=max(total_bytes, 1),
            initial=min(initial_bytes, max(total_bytes, 1)),
            desc="Gesamtdaten",
            unit="B",
            unit_scale=True,
            unit_divisor=1024,
            ncols=110,
            dynamic_ncols=False,
        ) as progress:
            progress.set_postfix_str(
                f"Dateien {completed_files}/{total_files} | Fehler 0"
            )

            if SHOW_WORKER_BARS and tasks:
                terminal_lines = shutil.get_terminal_size((110, 24)).lines
                bar_count = min(MAX_WORKERS, len(tasks), terminal_lines - 4)

                if bar_count >= 1:
                    WORKER_BARS = WorkerBars(bar_count)

            with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
                futures = {
                    executor.submit(process_file, task): task
                    for task in tasks
                }

                for future in as_completed(futures):
                    task = futures[future]
                    try:
                        result = future.result()
                    except Exception as exc:
                        # Zusätzliche Sicherung: ein unerwarteter Worker-Fehler
                        # darf die Verarbeitung der übrigen Dateien nicht stoppen.
                        relative = task[1]
                        log.exception("Unerwarteter Worker-Fehler bei %s", relative)
                        result = {
                            "status": "error_skipped",
                            "relative": relative,
                            "markdown": None,
                            "type": task[2],
                            "error": f"{type(exc).__name__}: {exc}",
                        }

                    if result.get("status") == "cancelled":
                        # Gestoppt: nicht erfasst, nächster Lauf macht weiter.
                        continue

                    relative = result.get("relative", "")
                    is_error = result["status"] in {"error", "error_skipped"}

                    if relative:
                        old = previous.get(relative)

                        if (
                            is_error
                            and old
                            and old.get("markdown")
                            and (TARGET_DIR / old["markdown"]).is_file()
                        ):
                            # Frühere gute Markdown-Datei bleibt erhalten
                            # und bleibt im Index. Der Eintrag behält die
                            # alte Signatur, damit die Datei beim nächsten
                            # Lauf erneut versucht wird.
                            kept = dict(old)
                            kept["status"] = "unchanged"
                            kept["last_error"] = result.get("error")
                            # Signatur der fehlgeschlagenen Quellversion,
                            # damit sie beim nächsten Lauf übersprungen wird.
                            kept["failed_signature"] = {
                                key: result[key]
                                for key in ("size", "mtime_ns", "sha256")
                                if key in result
                            }
                            kept["last_error_time"] = datetime.now().isoformat(
                                timespec="seconds"
                            )
                            results[relative] = kept
                            result["kept_previous"] = True
                        else:
                            results[relative] = result

                    completed_files += 1

                    if is_error:
                        error_count += 1
                        error_entries.append(result)
                        tqdm.write(
                            f"ÜBERSPRUNGEN: {relative}: {result.get('error')}"
                        )
                    elif result.get("repaired"):
                        repaired_count += 1

                    # Auch bei einem Verarbeitungsfehler wurde diese Datei
                    # versucht und zählt daher als bearbeitet.
                    file_size = task[3].get("size", 0)
                    progress.update(file_size)
                    progress.set_postfix_str(
                        f"Dateien {completed_files}/{total_files} | "
                        f"Fehler {error_count}"
                    )

            if WORKER_BARS is not None:
                WORKER_BARS.close()
                WORKER_BARS = None

        if previous_handler is not None:
            signal.signal(signal.SIGINT, previous_handler)

        write_index(results, source_dir)
        write_manifest(results, source_dir)
        write_error_report(results)

        success = sum(
            1 for item in results.values()
            if item.get("status") == "success"
        )

        print("\n" + "=" * 72)
        print("GESTOPPT" if STOP_EVENT.is_set() else "FERTIG")
        print("=" * 72)
        print(f"Neu verarbeitet:     {success}")
        print(f"davon repariert:     {repaired_count}")
        print(f"Unverändert:         {unchanged}")
        print(f"Bekannt defekt:      {known_bad}")
        print(f"Fehler/übersprungen: {error_count}")
        print(f"Index:               {TARGET_DIR / 'INDEX.md'}")
        print(f"Manifest:            {TARGET_DIR / 'manifest.json'}")
        print(f"Fehlerprotokoll:     {ERROR_REPORT_FILE}")
        print(f"Log:                 {LOG_FILE}")

        if STOP_EVENT.is_set():
            print(
                "\nVorzeitig gestoppt: Fortschritt ist gespeichert, "
                "der nächste Lauf macht dort weiter."
            )
            return 130

        return 1 if error_count else 0

    except KeyboardInterrupt:
        print("\nAbbruch durch Benutzer.")
        return 130

    except Exception as exc:
        log.exception("Abbruch: %s", exc)
        print(f"\nFEHLER: {exc}", file=sys.stderr)
        return 1

    finally:
        unmount_smb(mounted_by_this_run)


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