"""Orchestrierung des Fabric-Workspace-Bulk-Exports.

Der :class:`WorkspaceExporter` bündelt den gesamten Ablauf:

1. Enumeration des Workspaces (Berichte, Semantic Models, sonstige Elemente,
   Zugriffsberechtigungen).
2. Planung der Dateistruktur (je Semantic Model eine Datei; bei genau einem
   zugehörigen Bericht wird dieser inline dokumentiert, bei mehreren erhält
   jeder Bericht eine eigene Datei mit Referenz auf das Modell).
3. Sequenzielle Metadaten-Extraktion pro Semantic Model (Fehler werden
   übersprungen und in einer Logdatei gesammelt).
4. Bereitstellung der Formulardaten pro Dokument für die manuelle Anpassung.
5. Erzeugung aller Markdown-Dateien inkl. Übersicht und Fehler-Log unterhalb
   eines vom Benutzer gewählten Zielordners.
"""
from __future__ import annotations

import logging
import os
from dataclasses import dataclass, field
from datetime import date
from typing import Any, Callable

from core import workspace_doc_generator as wsdoc
from core import dataflow_doc_generator as dfdoc
from core import notebook_doc_generator as nbdoc
from core import copyjob_doc_generator as cjdoc
from core import lakehouse_doc_generator as lhdoc
from core import warehouse_doc_generator as whdoc
from core import eventhouse_doc_generator as ehdoc
from core import kqldatabase_doc_generator as kqldoc
from core import kqlqueryset_doc_generator as qsdoc
from core import environment_doc_generator as envdoc
from core import eventstream_doc_generator as esdoc
from core import mirroreddb_doc_generator as mddoc
from core import reflex_doc_generator as rxdoc
from core import graphqlapi_doc_generator as gqdoc
from core import sparkjob_doc_generator as sjdoc
from core import sqldatabase_doc_generator as sqdoc
from core import pipeline_doc_generator as pldoc
from core import dashboard_doc_generator as dbdoc
from core.access_generator import build_report_access_table_from_users
from core.connectors.fabric_connector import FabricConnector
from core.form_generator import FormGenerator
from core.metadata_mapper import MetadataMapper
from core.models import (
    CancelledError,
    ConnectionConfig,
    CopyJobInfo,
    DashboardInfo,
    DataflowInfo,
    DataPipelineInfo,
    EnvironmentInfo,
    EventhouseInfo,
    EventstreamInfo,
    GraphQlApiInfo,
    KqlDatabaseInfo,
    KqlQuerysetInfo,
    LakehouseInfo,
    MirroredDatabaseInfo,
    NotebookInfo,
    ParseResult,
    ReflexInfo,
    ReportMetadata,
    SparkJobDefinitionInfo,
    SqlDatabaseInfo,
    WarehouseInfo,
    WorkspaceItemInfo,
    WorkspaceMetadata,
)
from core.report_document_builder import render_report_markdown
from core.template_renderer import TemplateRenderer

logger = logging.getLogger(__name__)

ProgressCallback = Callable[[float, str], None]


def _safe_filename(name: str) -> str:
    """Bereinigt einen Namen für die Verwendung als Dateiname."""
    cleaned = "".join(c if c.isalnum() or c in " _-" else "_" for c in (name or ""))
    cleaned = cleaned.strip() or "Unbenannt"
    return cleaned


@dataclass
class ModelUnit:
    """Ein zu erzeugendes Semantic-Model-Dokument."""
    dataset_id: str
    name: str
    rel_path: str  # z.B. "Models/Umsatz.md"
    inline_report_id: str = ""  # gesetzt, wenn genau 1 Bericht inline dokumentiert wird
    metadata: ReportMetadata | None = None
    mappings: list | None = None
    manual_values: dict[str, str] = field(default_factory=dict)
    status: str = "pending"  # pending | extracted | error | done
    error: str = ""


@dataclass
class ReportUnit:
    """Ein eigenständiges Berichts-Dokument (mehrere Berichte pro Modell)."""
    report: WorkspaceItemInfo
    rel_path: str  # z.B. "Reports/Vertrieb.md"
    model_name: str = ""
    model_rel_path: str = ""  # Referenz zum Modell-Dokument (relativ)
    visuals: list = field(default_factory=list)
    report_users: list = field(default_factory=list)
    status: str = "pending"
    error: str = ""


@dataclass
class DataflowUnit:
    """Ein eigenständiges Dataflow-Dokument (Gen1 oder Gen2)."""
    item: WorkspaceItemInfo
    rel_path: str  # z.B. "Dataflows/Ladeprozess.md"
    generation: str = "Gen1"
    dataflow: DataflowInfo | None = None
    status: str = "pending"
    error: str = ""


@dataclass
class NotebookUnit:
    """Ein eigenständiges Notebook-Dokument."""
    item: WorkspaceItemInfo
    rel_path: str  # z.B. "Notebooks/Ladestrecke.md"
    notebook: NotebookInfo | None = None
    status: str = "pending"
    error: str = ""


@dataclass
class CopyJobUnit:
    """Ein eigenständiges Copyjob-Dokument."""
    item: WorkspaceItemInfo
    rel_path: str  # z.B. "Copyjobs/Ladung.md"
    copyjob: CopyJobInfo | None = None
    status: str = "pending"
    error: str = ""


@dataclass
class DataPipelineUnit:
    """Ein eigenständiges Data-Pipeline-Dokument."""
    item: WorkspaceItemInfo
    rel_path: str  # z.B. "Pipelines/DailyLoad.md"
    pipeline: DataPipelineInfo | None = None
    status: str = "pending"
    error: str = ""


@dataclass
class LakehouseUnit:
    """Ein eigenständiges Lakehouse-Dokument."""
    item: WorkspaceItemInfo
    rel_path: str  # z.B. "Lakehouses/Sales.md"
    lakehouse: LakehouseInfo | None = None
    status: str = "pending"
    error: str = ""


@dataclass
class WarehouseUnit:
    """Ein eigenständiges Warehouse-Dokument."""
    item: WorkspaceItemInfo
    rel_path: str  # z.B. "Warehouses/DWH.md"
    warehouse: WarehouseInfo | None = None
    status: str = "pending"
    error: str = ""


@dataclass
class EventhouseUnit:
    """Ein eigenständiges Eventhouse-Dokument."""
    item: WorkspaceItemInfo
    rel_path: str  # z.B. "Eventhouses/Telemetry.md"
    eventhouse: EventhouseInfo | None = None
    status: str = "pending"
    error: str = ""


@dataclass
class DashboardUnit:
    """Ein eigenständiges Dashboard-Dokument."""
    item: WorkspaceItemInfo
    rel_path: str  # z.B. "Dashboards/KPI.md"
    dashboard: DashboardInfo | None = None
    status: str = "pending"
    error: str = ""


@dataclass
class KqlDatabaseUnit:
    """Ein eigenständiges KQL-Datenbank-Dokument."""
    item: WorkspaceItemInfo
    rel_path: str  # z.B. "KQL-Datenbanken/Metrics.md"
    kqldatabase: KqlDatabaseInfo | None = None
    status: str = "pending"
    error: str = ""


@dataclass
class SqlDatabaseUnit:
    """Ein eigenständiges SQL-Database-Dokument."""
    item: WorkspaceItemInfo
    rel_path: str  # z.B. "SQL-Databases/Orders.md"
    sqldatabase: SqlDatabaseInfo | None = None
    status: str = "pending"
    error: str = ""


@dataclass
class EnvironmentUnit:
    """Ein eigenständiges Environment-Dokument."""
    item: WorkspaceItemInfo
    rel_path: str  # z.B. "Environments/Prod.md"
    environment: EnvironmentInfo | None = None
    status: str = "pending"
    error: str = ""


@dataclass
class MirroredDatabaseUnit:
    """Ein eigenständiges Mirrored-Database-Dokument."""
    item: WorkspaceItemInfo
    rel_path: str  # z.B. "Mirrored-Databases/Sales.md"
    mirroreddb: MirroredDatabaseInfo | None = None
    status: str = "pending"
    error: str = ""


@dataclass
class EventstreamUnit:
    """Ein eigenständiges Eventstream-Dokument."""
    item: WorkspaceItemInfo
    rel_path: str  # z.B. "Eventstreams/Telemetry.md"
    eventstream: EventstreamInfo | None = None
    status: str = "pending"
    error: str = ""


@dataclass
class SparkJobDefinitionUnit:
    """Ein eigenständiges Spark-Job-Definition-Dokument."""
    item: WorkspaceItemInfo
    rel_path: str  # z.B. "Spark-Job-Definitions/Load.md"
    sparkjob: SparkJobDefinitionInfo | None = None
    status: str = "pending"
    error: str = ""


@dataclass
class KqlQuerysetUnit:
    """Ein eigenständiges KQL-Queryset-Dokument."""
    item: WorkspaceItemInfo
    rel_path: str  # z.B. "KQL-Querysets/Analyse.md"
    kqlqueryset: KqlQuerysetInfo | None = None
    status: str = "pending"
    error: str = ""


@dataclass
class ReflexUnit:
    """Ein eigenständiges Reflex-Dokument."""
    item: WorkspaceItemInfo
    rel_path: str  # z.B. "Reflexe/Alarm.md"
    reflex: ReflexInfo | None = None
    status: str = "pending"
    error: str = ""


@dataclass
class GraphQlApiUnit:
    """Ein eigenständiges GraphQL-API-Dokument."""
    item: WorkspaceItemInfo
    rel_path: str  # z.B. "GraphQL-APIs/Sales.md"
    graphqlapi: GraphQlApiInfo | None = None
    status: str = "pending"
    error: str = ""


class WorkspaceExporter:
    """Stateful Orchestrator für den Workspace-Bulk-Export."""

    def __init__(
        self,
        report_parse_result: ParseResult,
        template_dir: str,
        connector: FabricConnector | None = None,
        language: str = "de",
    ) -> None:
        self._report_parse = report_parse_result
        self._template_dir = template_dir
        self._connector = connector or FabricConnector()
        self._language = language
        self._mapper = MetadataMapper()
        self._form_generator = FormGenerator(locale=language)
        self._renderer = TemplateRenderer()

        self._ws: WorkspaceMetadata | None = None
        self._target_dir: str = ""
        self._only_workspace: bool = False
        self._connection_type: str = "fabric"

        self._model_units: list[ModelUnit] = []
        self._report_units: list[ReportUnit] = []
        self._dataflow_units: list[DataflowUnit] = []
        self._notebook_units: list[NotebookUnit] = []
        self._copyjob_units: list[CopyJobUnit] = []
        self._pipeline_units: list[DataPipelineUnit] = []
        self._lakehouse_units: list[LakehouseUnit] = []
        self._warehouse_units: list[WarehouseUnit] = []
        self._eventhouse_units: list[EventhouseUnit] = []
        self._dashboard_units: list[DashboardUnit] = []
        self._kqldatabase_units: list[KqlDatabaseUnit] = []
        self._sqldatabase_units: list[SqlDatabaseUnit] = []
        self._environment_units: list[EnvironmentUnit] = []
        self._mirroreddb_units: list[MirroredDatabaseUnit] = []
        self._eventstream_units: list[EventstreamUnit] = []
        self._sparkjob_units: list[SparkJobDefinitionUnit] = []
        self._kqlqueryset_units: list[KqlQuerysetUnit] = []
        self._reflex_units: list[ReflexUnit] = []
        self._graphqlapi_units: list[GraphQlApiUnit] = []
        self._model_file_map: dict[str, str] = {}  # dataset_id -> rel path
        self._report_file_map: dict[str, str] = {}  # report_id -> rel path
        self._dataflow_file_map: dict[str, str] = {}  # dataflow_id -> rel path
        self._notebook_file_map: dict[str, str] = {}  # notebook_id -> rel path
        self._copyjob_file_map: dict[str, str] = {}  # copyjob_id -> rel path
        self._pipeline_file_map: dict[str, str] = {}  # pipeline_id -> rel path
        self._lakehouse_file_map: dict[str, str] = {}  # lakehouse_id -> rel path
        self._warehouse_file_map: dict[str, str] = {}  # warehouse_id -> rel path
        self._eventhouse_file_map: dict[str, str] = {}  # eventhouse_id -> rel path
        self._dashboard_file_map: dict[str, str] = {}  # dashboard_id -> rel path
        self._kqldatabase_file_map: dict[str, str] = {}  # kqldatabase_id -> rel path
        self._sqldatabase_file_map: dict[str, str] = {}  # sqldatabase_id -> rel path
        self._environment_file_map: dict[str, str] = {}  # environment_id -> rel path
        self._mirroreddb_file_map: dict[str, str] = {}  # mirroreddb_id -> rel path
        self._eventstream_file_map: dict[str, str] = {}  # eventstream_id -> rel path
        self._sparkjob_file_map: dict[str, str] = {}  # sparkjob_id -> rel path
        self._kqlqueryset_file_map: dict[str, str] = {}  # kqlqueryset_id -> rel path
        self._reflex_file_map: dict[str, str] = {}  # reflex_id -> rel path
        self._graphqlapi_file_map: dict[str, str] = {}  # graphqlapi_id -> rel path
        self._report_count_map: dict[str, int] = {}  # dataset_id -> #reports
        self._errors: list[str] = []

    # ------------------------------------------------------------------
    # Schritt 1: Verbinden + Enumeration + Planung
    # ------------------------------------------------------------------

    def start(
        self,
        config: ConnectionConfig,
        target_dir: str,
        only_workspace: bool = False,
    ) -> dict[str, Any]:
        """Verbindet, enumeriert den Workspace und plant die Dateistruktur."""
        if not target_dir:
            raise ValueError("Kein Zielordner angegeben.")
        self._target_dir = target_dir
        self._only_workspace = only_workspace
        self._connection_type = config.connection_type or "fabric"

        self._connector.connect_workspace(config)
        self._ws = self._connector.list_workspace_contents()
        self._plan()

        return self._summary()

    def _summary(self) -> dict[str, Any]:
        assert self._ws is not None
        return {
            "workspaceName": self._ws.workspace_name,
            "workspaceId": self._ws.workspace_id,
            "modelCount": len(self._ws.datasets),
            "reportCount": len(self._ws.reports),
            "otherCount": len(self._ws.other_items),
            "dataflowCount": len(self._dataflow_units),
            "notebookCount": len(self._notebook_units),
            "copyjobCount": len(self._copyjob_units),
            "pipelineCount": len(self._pipeline_units),
            "lakehouseCount": len(self._lakehouse_units),
            "warehouseCount": len(self._warehouse_units),
            "eventhouseCount": len(self._eventhouse_units),
            "dashboardCount": len(self._dashboard_units),
            "kqldatabaseCount": len(self._kqldatabase_units),
            "sqldatabaseCount": len(self._sqldatabase_units),
            "environmentCount": len(self._environment_units),
            "mirroreddbCount": len(self._mirroreddb_units),
            "eventstreamCount": len(self._eventstream_units),
            "sparkjobCount": len(self._sparkjob_units),
            "kqlquerysetCount": len(self._kqlqueryset_units),
            "reflexCount": len(self._reflex_units),
            "graphqlapiCount": len(self._graphqlapi_units),
            "userCount": len(self._ws.users),
            "onlyWorkspace": self._only_workspace,
            "documentCount": len(self._model_units),
            "items": [
                {
                    "name": d.name,
                    "type": "SemanticModel",
                    "reports": self._report_count_map.get(d.id, 0),
                }
                for d in self._ws.datasets
            ]
            + [
                {"name": r.name, "type": "Report", "dataset": r.dataset_id}
                for r in self._ws.reports
            ]
            + [{"name": o.name, "type": o.item_type} for o in self._ws.other_items],
        }

    def _plan(self) -> None:
        """Erstellt Modell-/Bericht-Einheiten und die Dateipfad-Zuordnungen."""
        assert self._ws is not None
        self._model_units = []
        self._report_units = []
        self._dataflow_units = []
        self._notebook_units = []
        self._copyjob_units = []
        self._pipeline_units = []
        self._lakehouse_units = []
        self._warehouse_units = []
        self._eventhouse_units = []
        self._dashboard_units = []
        self._kqldatabase_units = []
        self._sqldatabase_units = []
        self._environment_units = []
        self._mirroreddb_units = []
        self._eventstream_units = []
        self._sparkjob_units = []
        self._kqlqueryset_units = []
        self._reflex_units = []
        self._graphqlapi_units = []
        self._model_file_map = {}
        self._report_file_map = {}
        self._dataflow_file_map = {}
        self._notebook_file_map = {}
        self._copyjob_file_map = {}
        self._pipeline_file_map = {}
        self._lakehouse_file_map = {}
        self._warehouse_file_map = {}
        self._eventhouse_file_map = {}
        self._dashboard_file_map = {}
        self._kqldatabase_file_map = {}
        self._sqldatabase_file_map = {}
        self._environment_file_map = {}
        self._mirroreddb_file_map = {}
        self._eventstream_file_map = {}
        self._sparkjob_file_map = {}
        self._kqlqueryset_file_map = {}
        self._reflex_file_map = {}
        self._graphqlapi_file_map = {}
        self._report_count_map = {}

        dataset_ids = {d.id for d in self._ws.datasets}
        dataset_name_map = {d.id: d.name for d in self._ws.datasets}

        # Berichte nach Semantic Model gruppieren
        reports_by_dataset: dict[str, list[WorkspaceItemInfo]] = {}
        external_reports: list[WorkspaceItemInfo] = []
        for rep in self._ws.reports:
            if rep.dataset_id and rep.dataset_id in dataset_ids:
                reports_by_dataset.setdefault(rep.dataset_id, []).append(rep)
            else:
                external_reports.append(rep)

        used_model_names: set[str] = set()
        used_report_names: set[str] = set()

        if not self._only_workspace:
            for d in self._ws.datasets:
                reps = reports_by_dataset.get(d.id, [])
                self._report_count_map[d.id] = len(reps)
                model_rel = self._unique_rel("Models", d.name, used_model_names)
                self._model_file_map[d.id] = model_rel

                unit = ModelUnit(dataset_id=d.id, name=d.name, rel_path=model_rel)

                if len(reps) == 1:
                    # Bericht inline im Modell-Dokument dokumentieren
                    unit.inline_report_id = reps[0].id
                    self._report_file_map[reps[0].id] = model_rel
                elif len(reps) > 1:
                    # Je Bericht eine eigene Datei mit Referenz aufs Modell
                    for rep in reps:
                        rep_rel = self._unique_rel(
                            "Reports", rep.name, used_report_names
                        )
                        self._report_file_map[rep.id] = rep_rel
                        self._report_units.append(
                            ReportUnit(
                                report=rep,
                                rel_path=rep_rel,
                                model_name=d.name,
                                model_rel_path=os.path.relpath(
                                    model_rel, os.path.dirname(rep_rel)
                                ).replace("\\", "/"),
                            )
                        )
                self._model_units.append(unit)

            # Berichte mit externem/fehlendem Semantic Model -> eigenständig
            for rep in external_reports:
                rep_rel = self._unique_rel("Reports", rep.name, used_report_names)
                self._report_file_map[rep.id] = rep_rel
                self._report_units.append(
                    ReportUnit(
                        report=rep,
                        rel_path=rep_rel,
                        model_name=dataset_name_map.get(rep.dataset_id, ""),
                        model_rel_path="",
                    )
                )

            # Dataflows (Gen1 & Gen2) -> je Dataflow eine eigenständige Datei
            used_dataflow_names: set[str] = set()
            for item in self._ws.other_items:
                if item.item_type != "Dataflow":
                    continue
                df_rel = self._unique_rel(
                    "Dataflows", item.name, used_dataflow_names
                )
                self._dataflow_file_map[item.id] = df_rel
                self._dataflow_units.append(
                    DataflowUnit(
                        item=item,
                        rel_path=df_rel,
                        generation=item.subtype or "Gen1",
                    )
                )

            # Notebooks -> je Notebook eine eigenständige Datei
            used_notebook_names: set[str] = set()
            for item in self._ws.other_items:
                if item.item_type != "Notebook":
                    continue
                nb_rel = self._unique_rel(
                    "Notebooks", item.name, used_notebook_names
                )
                self._notebook_file_map[item.id] = nb_rel
                self._notebook_units.append(
                    NotebookUnit(item=item, rel_path=nb_rel)
                )

            # Copyjobs -> je Copyjob eine eigenständige Datei
            used_copyjob_names: set[str] = set()
            for item in self._ws.other_items:
                if item.item_type != "CopyJob":
                    continue
                cj_rel = self._unique_rel(
                    "Copyjobs", item.name, used_copyjob_names
                )
                self._copyjob_file_map[item.id] = cj_rel
                self._copyjob_units.append(
                    CopyJobUnit(item=item, rel_path=cj_rel)
                )

            # Data Pipelines -> je Pipeline eine eigenständige Datei
            used_pipeline_names: set[str] = set()
            for item in self._ws.other_items:
                if item.item_type != "DataPipeline":
                    continue
                pl_rel = self._unique_rel(
                    "Pipelines", item.name, used_pipeline_names
                )
                self._pipeline_file_map[item.id] = pl_rel
                self._pipeline_units.append(
                    DataPipelineUnit(item=item, rel_path=pl_rel)
                )

            # Lakehouses -> je Lakehouse eine eigenständige Datei
            used_lakehouse_names: set[str] = set()
            for item in self._ws.other_items:
                if item.item_type != "Lakehouse":
                    continue
                lh_rel = self._unique_rel(
                    "Lakehouses", item.name, used_lakehouse_names
                )
                self._lakehouse_file_map[item.id] = lh_rel
                self._lakehouse_units.append(
                    LakehouseUnit(item=item, rel_path=lh_rel)
                )

            # Warehouses -> je Warehouse eine eigenständige Datei
            used_warehouse_names: set[str] = set()
            for item in self._ws.other_items:
                if item.item_type != "Warehouse":
                    continue
                wh_rel = self._unique_rel(
                    "Warehouses", item.name, used_warehouse_names
                )
                self._warehouse_file_map[item.id] = wh_rel
                self._warehouse_units.append(
                    WarehouseUnit(item=item, rel_path=wh_rel)
                )

            # Eventhouses -> je Eventhouse eine eigenständige Datei
            used_eventhouse_names: set[str] = set()
            for item in self._ws.other_items:
                if item.item_type != "Eventhouse":
                    continue
                eh_rel = self._unique_rel(
                    "Eventhouses", item.name, used_eventhouse_names
                )
                self._eventhouse_file_map[item.id] = eh_rel
                self._eventhouse_units.append(
                    EventhouseUnit(item=item, rel_path=eh_rel)
                )

            # Dashboards -> je Dashboard eine eigenständige Datei
            used_dashboard_names: set[str] = set()
            for item in self._ws.other_items:
                if item.item_type != "Dashboard":
                    continue
                db_rel = self._unique_rel(
                    "Dashboards", item.name, used_dashboard_names
                )
                self._dashboard_file_map[item.id] = db_rel
                self._dashboard_units.append(
                    DashboardUnit(item=item, rel_path=db_rel)
                )

            # KQL-Datenbanken -> je Datenbank eine eigenständige Datei
            used_kqldatabase_names: set[str] = set()
            for item in self._ws.other_items:
                if item.item_type != "KQLDatabase":
                    continue
                kh_rel = self._unique_rel(
                    "KQL-Datenbanken", item.name, used_kqldatabase_names
                )
                self._kqldatabase_file_map[item.id] = kh_rel
                self._kqldatabase_units.append(
                    KqlDatabaseUnit(item=item, rel_path=kh_rel)
                )

            # SQL Databases -> je Datenbank eine eigenständige Datei
            used_sqldatabase_names: set[str] = set()
            for item in self._ws.other_items:
                if item.item_type != "SQLDatabase":
                    continue
                sq_rel = self._unique_rel(
                    "SQL-Databases", item.name, used_sqldatabase_names
                )
                self._sqldatabase_file_map[item.id] = sq_rel
                self._sqldatabase_units.append(
                    SqlDatabaseUnit(item=item, rel_path=sq_rel)
                )

            # Environments -> je Environment eine eigenständige Datei
            used_environment_names: set[str] = set()
            for item in self._ws.other_items:
                if item.item_type != "Environment":
                    continue
                env_rel = self._unique_rel(
                    "Environments", item.name, used_environment_names
                )
                self._environment_file_map[item.id] = env_rel
                self._environment_units.append(
                    EnvironmentUnit(item=item, rel_path=env_rel)
                )

            # Mirrored Databases -> je Datenbank eine eigenständige Datei
            used_mirroreddb_names: set[str] = set()
            for item in self._ws.other_items:
                if item.item_type != "MirroredDatabase":
                    continue
                md_rel = self._unique_rel(
                    "Mirrored-Databases", item.name, used_mirroreddb_names
                )
                self._mirroreddb_file_map[item.id] = md_rel
                self._mirroreddb_units.append(
                    MirroredDatabaseUnit(item=item, rel_path=md_rel)
                )

            # Eventstreams -> je Eventstream eine eigenständige Datei
            used_eventstream_names: set[str] = set()
            for item in self._ws.other_items:
                if item.item_type != "Eventstream":
                    continue
                es_rel = self._unique_rel(
                    "Eventstreams", item.name, used_eventstream_names
                )
                self._eventstream_file_map[item.id] = es_rel
                self._eventstream_units.append(
                    EventstreamUnit(item=item, rel_path=es_rel)
                )

            # Spark Job Definitions -> je Definition eine eigenständige Datei
            used_sparkjob_names: set[str] = set()
            for item in self._ws.other_items:
                if item.item_type != "SparkJobDefinition":
                    continue
                sj_rel = self._unique_rel(
                    "Spark-Job-Definitions", item.name, used_sparkjob_names
                )
                self._sparkjob_file_map[item.id] = sj_rel
                self._sparkjob_units.append(
                    SparkJobDefinitionUnit(item=item, rel_path=sj_rel)
                )

            # KQL Querysets -> je Queryset eine eigenständige Datei
            used_kqlqueryset_names: set[str] = set()
            for item in self._ws.other_items:
                if item.item_type != "KQLQueryset":
                    continue
                qs_rel = self._unique_rel(
                    "KQL-Querysets", item.name, used_kqlqueryset_names
                )
                self._kqlqueryset_file_map[item.id] = qs_rel
                self._kqlqueryset_units.append(
                    KqlQuerysetUnit(item=item, rel_path=qs_rel)
                )

            # Reflexe -> je Reflex eine eigenständige Datei
            used_reflex_names: set[str] = set()
            for item in self._ws.other_items:
                if item.item_type != "Reflex":
                    continue
                rx_rel = self._unique_rel(
                    "Reflexe", item.name, used_reflex_names
                )
                self._reflex_file_map[item.id] = rx_rel
                self._reflex_units.append(
                    ReflexUnit(item=item, rel_path=rx_rel)
                )

            # GraphQL APIs -> je API eine eigenständige Datei
            used_graphqlapi_names: set[str] = set()
            for item in self._ws.other_items:
                if item.item_type != "GraphQLApi":
                    continue
                gq_rel = self._unique_rel(
                    "GraphQL-APIs", item.name, used_graphqlapi_names
                )
                self._graphqlapi_file_map[item.id] = gq_rel
                self._graphqlapi_units.append(
                    GraphQlApiUnit(item=item, rel_path=gq_rel)
                )

    def _unique_rel(self, subdir: str, name: str, used: set[str]) -> str:
        base = _safe_filename(name)
        candidate = base
        i = 2
        while candidate.lower() in used:
            candidate = f"{base}_{i}"
            i += 1
        used.add(candidate.lower())
        return f"{subdir}/{candidate}.md"

    # ------------------------------------------------------------------
    # Schritt 2: Metadaten-Extraktion
    # ------------------------------------------------------------------

    def extract_all(
        self,
        progress_callback: ProgressCallback | None = None,
        cancel_check: Callable[[], bool] | None = None,
    ) -> dict[str, Any]:
        """Extrahiert Metadaten für alle Modelle und eigenständige Berichte."""
        if self._only_workspace:
            return {"extracted": 0, "errors": 0, "documents": []}

        def _progress(p: float, msg: str) -> None:
            if progress_callback:
                progress_callback(p, msg)

        def _check() -> None:
            if cancel_check and cancel_check():
                raise CancelledError("Operation wurde abgebrochen")

        total = len(self._model_units) + len(self._report_units) + len(
            self._dataflow_units
        ) + len(self._notebook_units) + len(self._copyjob_units) + len(
            self._lakehouse_units
        ) + len(self._warehouse_units) + len(self._eventhouse_units) + len(
            self._pipeline_units
        ) + len(self._dashboard_units) + len(self._kqldatabase_units) + len(
            self._sqldatabase_units
        ) + len(self._environment_units) + len(self._mirroreddb_units) + len(
            self._eventstream_units
        ) + len(self._sparkjob_units) + len(self._kqlqueryset_units) + len(
            self._reflex_units
        ) + len(self._graphqlapi_units)
        done = 0
        extracted = 0

        for unit in self._model_units:
            _check()
            done += 1
            _progress(
                (done / total * 100) if total else 100.0,
                f"Extrahiere Semantic Model '{unit.name}' …",
            )
            try:
                self._connector.set_dataset(unit.dataset_id, unit.inline_report_id)
                metadata = self._connector.extract_metadata()
                unit.metadata = metadata
                unit.mappings = self._mapper.map(
                    self._report_parse.placeholders, metadata
                )
                unit.status = "extracted"
                extracted += 1
            except Exception as exc:  # noqa: BLE001 – Fehler überspringen + loggen
                unit.status = "error"
                unit.error = str(exc)
                msg = f"Semantic Model '{unit.name}' ({unit.dataset_id}): {exc}"
                self._errors.append(msg)
                logger.warning("Extraktion fehlgeschlagen – %s", msg)

        for runit in self._report_units:
            _check()
            done += 1
            _progress(
                (done / total * 100) if total else 100.0,
                f"Extrahiere Bericht '{runit.report.name}' …",
            )
            try:
                runit.visuals = self._connector.extract_report_visuals(
                    runit.report.id
                )
                runit.report_users = self._connector.extract_report_users(
                    runit.report.id
                )
                runit.status = "extracted"
            except Exception as exc:  # noqa: BLE001
                runit.status = "error"
                runit.error = str(exc)
                msg = f"Bericht '{runit.report.name}' ({runit.report.id}): {exc}"
                self._errors.append(msg)
                logger.warning("Visual-Extraktion fehlgeschlagen – %s", msg)

        for dunit in self._dataflow_units:
            _check()
            done += 1
            _progress(
                (done / total * 100) if total else 100.0,
                f"Extrahiere Dataflow '{dunit.item.name}' …",
            )
            try:
                dunit.dataflow = self._connector.extract_dataflow(
                    dunit.item.id,
                    dunit.generation,
                    name=dunit.item.name,
                    description=dunit.item.description,
                    web_url=dunit.item.web_url,
                )
                dunit.status = "extracted"
                extracted += 1
            except Exception as exc:  # noqa: BLE001
                dunit.status = "error"
                dunit.error = str(exc)
                msg = f"Dataflow '{dunit.item.name}' ({dunit.item.id}): {exc}"
                self._errors.append(msg)
                logger.warning("Dataflow-Extraktion fehlgeschlagen – %s", msg)

        for nunit in self._notebook_units:
            _check()
            done += 1
            _progress(
                (done / total * 100) if total else 100.0,
                f"Extrahiere Notebook '{nunit.item.name}' …",
            )
            try:
                nunit.notebook = self._connector.extract_notebook(
                    nunit.item.id,
                    name=nunit.item.name,
                    description=nunit.item.description,
                    web_url=nunit.item.web_url,
                )
                nunit.status = "extracted"
                extracted += 1
            except Exception as exc:  # noqa: BLE001
                nunit.status = "error"
                nunit.error = str(exc)
                msg = f"Notebook '{nunit.item.name}' ({nunit.item.id}): {exc}"
                self._errors.append(msg)
                logger.warning("Notebook-Extraktion fehlgeschlagen – %s", msg)

        for cunit in self._copyjob_units:
            _check()
            done += 1
            _progress(
                (done / total * 100) if total else 100.0,
                f"Extrahiere Copyjob '{cunit.item.name}' …",
            )
            try:
                cunit.copyjob = self._connector.extract_copyjob(
                    cunit.item.id,
                    name=cunit.item.name,
                    description=cunit.item.description,
                    web_url=cunit.item.web_url,
                )
                cunit.status = "extracted"
                extracted += 1
            except Exception as exc:  # noqa: BLE001
                cunit.status = "error"
                cunit.error = str(exc)
                msg = f"Copyjob '{cunit.item.name}' ({cunit.item.id}): {exc}"
                self._errors.append(msg)
                logger.warning("Copyjob-Extraktion fehlgeschlagen – %s", msg)

        for punit in self._pipeline_units:
            _check()
            done += 1
            _progress(
                (done / total * 100) if total else 100.0,
                f"Extrahiere Data Pipeline '{punit.item.name}' …",
            )
            try:
                punit.pipeline = self._connector.extract_datapipeline(
                    punit.item.id,
                    name=punit.item.name,
                    description=punit.item.description,
                    web_url=punit.item.web_url,
                )
                punit.status = "extracted"
                extracted += 1
            except Exception as exc:  # noqa: BLE001
                punit.status = "error"
                punit.error = str(exc)
                msg = f"Data Pipeline '{punit.item.name}' ({punit.item.id}): {exc}"
                self._errors.append(msg)
                logger.warning("Pipeline-Extraktion fehlgeschlagen – %s", msg)

        for lunit in self._lakehouse_units:
            _check()
            done += 1
            _progress(
                (done / total * 100) if total else 100.0,
                f"Extrahiere Lakehouse '{lunit.item.name}' …",
            )
            try:
                lunit.lakehouse = self._connector.extract_lakehouse(
                    lunit.item.id,
                    name=lunit.item.name,
                    description=lunit.item.description,
                    web_url=lunit.item.web_url,
                )
                lunit.status = "extracted"
                extracted += 1
            except Exception as exc:  # noqa: BLE001
                lunit.status = "error"
                lunit.error = str(exc)
                msg = f"Lakehouse '{lunit.item.name}' ({lunit.item.id}): {exc}"
                self._errors.append(msg)
                logger.warning("Lakehouse-Extraktion fehlgeschlagen – %s", msg)

        for wunit in self._warehouse_units:
            _check()
            done += 1
            _progress(
                (done / total * 100) if total else 100.0,
                f"Extrahiere Warehouse '{wunit.item.name}' …",
            )
            try:
                wunit.warehouse = self._connector.extract_warehouse(
                    wunit.item.id,
                    name=wunit.item.name,
                    description=wunit.item.description,
                    web_url=wunit.item.web_url,
                )
                wunit.status = "extracted"
                extracted += 1
            except Exception as exc:  # noqa: BLE001
                wunit.status = "error"
                wunit.error = str(exc)
                msg = f"Warehouse '{wunit.item.name}' ({wunit.item.id}): {exc}"
                self._errors.append(msg)
                logger.warning("Warehouse-Extraktion fehlgeschlagen – %s", msg)

        for eunit in self._eventhouse_units:
            _check()
            done += 1
            _progress(
                (done / total * 100) if total else 100.0,
                f"Extrahiere Eventhouse '{eunit.item.name}' …",
            )
            try:
                eunit.eventhouse = self._connector.extract_eventhouse(
                    eunit.item.id,
                    name=eunit.item.name,
                    description=eunit.item.description,
                    web_url=eunit.item.web_url,
                )
                eunit.status = "extracted"
                extracted += 1
            except Exception as exc:  # noqa: BLE001
                eunit.status = "error"
                eunit.error = str(exc)
                msg = f"Eventhouse '{eunit.item.name}' ({eunit.item.id}): {exc}"
                self._errors.append(msg)
                logger.warning("Eventhouse-Extraktion fehlgeschlagen – %s", msg)

        for dbunit in self._dashboard_units:
            _check()
            done += 1
            _progress(
                (done / total * 100) if total else 100.0,
                f"Extrahiere Dashboard '{dbunit.item.name}' …",
            )
            try:
                dbunit.dashboard = self._connector.extract_dashboard(
                    dbunit.item.id,
                    name=dbunit.item.name,
                    description=dbunit.item.description,
                    web_url=dbunit.item.web_url,
                )
                dbunit.status = "extracted"
                extracted += 1
            except Exception as exc:  # noqa: BLE001
                dbunit.status = "error"
                dbunit.error = str(exc)
                msg = f"Dashboard '{dbunit.item.name}' ({dbunit.item.id}): {exc}"
                self._errors.append(msg)
                logger.warning("Dashboard-Extraktion fehlgeschlagen – %s", msg)

        for khunit in self._kqldatabase_units:
            _check()
            done += 1
            _progress(
                (done / total * 100) if total else 100.0,
                f"Extrahiere KQL-Datenbank '{khunit.item.name}' …",
            )
            try:
                khunit.kqldatabase = self._connector.extract_kqldatabase(
                    khunit.item.id,
                    name=khunit.item.name,
                    description=khunit.item.description,
                    web_url=khunit.item.web_url,
                )
                khunit.status = "extracted"
                extracted += 1
            except Exception as exc:  # noqa: BLE001
                khunit.status = "error"
                khunit.error = str(exc)
                msg = f"KQL-Datenbank '{khunit.item.name}' ({khunit.item.id}): {exc}"
                self._errors.append(msg)
                logger.warning("KQL-Datenbank-Extraktion fehlgeschlagen – %s", msg)

        for squnit in self._sqldatabase_units:
            _check()
            done += 1
            _progress(
                (done / total * 100) if total else 100.0,
                f"Extrahiere SQL Database '{squnit.item.name}' …",
            )
            try:
                squnit.sqldatabase = self._connector.extract_sqldatabase(
                    squnit.item.id,
                    name=squnit.item.name,
                    description=squnit.item.description,
                    web_url=squnit.item.web_url,
                )
                squnit.status = "extracted"
                extracted += 1
            except Exception as exc:  # noqa: BLE001
                squnit.status = "error"
                squnit.error = str(exc)
                msg = f"SQL Database '{squnit.item.name}' ({squnit.item.id}): {exc}"
                self._errors.append(msg)
                logger.warning("SQL-Database-Extraktion fehlgeschlagen – %s", msg)

        for envunit in self._environment_units:
            _check()
            done += 1
            _progress(
                (done / total * 100) if total else 100.0,
                f"Extrahiere Environment '{envunit.item.name}' …",
            )
            try:
                envunit.environment = self._connector.extract_environment(
                    envunit.item.id,
                    name=envunit.item.name,
                    description=envunit.item.description,
                    web_url=envunit.item.web_url,
                )
                envunit.status = "extracted"
                extracted += 1
            except Exception as exc:  # noqa: BLE001
                envunit.status = "error"
                envunit.error = str(exc)
                msg = f"Environment '{envunit.item.name}' ({envunit.item.id}): {exc}"
                self._errors.append(msg)
                logger.warning("Environment-Extraktion fehlgeschlagen – %s", msg)

        for mdunit in self._mirroreddb_units:
            _check()
            done += 1
            _progress(
                (done / total * 100) if total else 100.0,
                f"Extrahiere Mirrored Database '{mdunit.item.name}' …",
            )
            try:
                mdunit.mirroreddb = self._connector.extract_mirroreddb(
                    mdunit.item.id,
                    name=mdunit.item.name,
                    description=mdunit.item.description,
                    web_url=mdunit.item.web_url,
                )
                mdunit.status = "extracted"
                extracted += 1
            except Exception as exc:  # noqa: BLE001
                mdunit.status = "error"
                mdunit.error = str(exc)
                msg = f"Mirrored Database '{mdunit.item.name}' ({mdunit.item.id}): {exc}"
                self._errors.append(msg)
                logger.warning("Mirrored-Database-Extraktion fehlgeschlagen – %s", msg)

        for esunit in self._eventstream_units:
            _check()
            done += 1
            _progress(
                (done / total * 100) if total else 100.0,
                f"Extrahiere Eventstream '{esunit.item.name}' …",
            )
            try:
                esunit.eventstream = self._connector.extract_eventstream(
                    esunit.item.id,
                    name=esunit.item.name,
                    description=esunit.item.description,
                    web_url=esunit.item.web_url,
                )
                esunit.status = "extracted"
                extracted += 1
            except Exception as exc:  # noqa: BLE001
                esunit.status = "error"
                esunit.error = str(exc)
                msg = f"Eventstream '{esunit.item.name}' ({esunit.item.id}): {exc}"
                self._errors.append(msg)
                logger.warning("Eventstream-Extraktion fehlgeschlagen – %s", msg)

        for sjunit in self._sparkjob_units:
            _check()
            done += 1
            _progress(
                (done / total * 100) if total else 100.0,
                f"Extrahiere Spark Job Definition '{sjunit.item.name}' …",
            )
            try:
                sjunit.sparkjob = self._connector.extract_sparkjob(
                    sjunit.item.id,
                    name=sjunit.item.name,
                    description=sjunit.item.description,
                    web_url=sjunit.item.web_url,
                )
                sjunit.status = "extracted"
                extracted += 1
            except Exception as exc:  # noqa: BLE001
                sjunit.status = "error"
                sjunit.error = str(exc)
                msg = f"Spark Job Definition '{sjunit.item.name}' ({sjunit.item.id}): {exc}"
                self._errors.append(msg)
                logger.warning("Spark-Job-Definition-Extraktion fehlgeschlagen – %s", msg)

        for qsunit in self._kqlqueryset_units:
            _check()
            done += 1
            _progress(
                (done / total * 100) if total else 100.0,
                f"Extrahiere KQL-Queryset '{qsunit.item.name}' …",
            )
            try:
                qsunit.kqlqueryset = self._connector.extract_kqlqueryset(
                    qsunit.item.id,
                    name=qsunit.item.name,
                    description=qsunit.item.description,
                    web_url=qsunit.item.web_url,
                )
                qsunit.status = "extracted"
                extracted += 1
            except Exception as exc:  # noqa: BLE001
                qsunit.status = "error"
                qsunit.error = str(exc)
                msg = f"KQL-Queryset '{qsunit.item.name}' ({qsunit.item.id}): {exc}"
                self._errors.append(msg)
                logger.warning("KQL-Queryset-Extraktion fehlgeschlagen – %s", msg)

        for rxunit in self._reflex_units:
            _check()
            done += 1
            _progress(
                (done / total * 100) if total else 100.0,
                f"Extrahiere Reflex '{rxunit.item.name}' …",
            )
            try:
                rxunit.reflex = self._connector.extract_reflex(
                    rxunit.item.id,
                    name=rxunit.item.name,
                    description=rxunit.item.description,
                    web_url=rxunit.item.web_url,
                )
                rxunit.status = "extracted"
                extracted += 1
            except Exception as exc:  # noqa: BLE001
                rxunit.status = "error"
                rxunit.error = str(exc)
                msg = f"Reflex '{rxunit.item.name}' ({rxunit.item.id}): {exc}"
                self._errors.append(msg)
                logger.warning("Reflex-Extraktion fehlgeschlagen – %s", msg)

        for gqunit in self._graphqlapi_units:
            _check()
            done += 1
            _progress(
                (done / total * 100) if total else 100.0,
                f"Extrahiere GraphQL-API '{gqunit.item.name}' …",
            )
            try:
                gqunit.graphqlapi = self._connector.extract_graphqlapi(
                    gqunit.item.id,
                    name=gqunit.item.name,
                    description=gqunit.item.description,
                    web_url=gqunit.item.web_url,
                )
                gqunit.status = "extracted"
                extracted += 1
            except Exception as exc:  # noqa: BLE001
                gqunit.status = "error"
                gqunit.error = str(exc)
                msg = f"GraphQL-API '{gqunit.item.name}' ({gqunit.item.id}): {exc}"
                self._errors.append(msg)
                logger.warning("GraphQL-API-Extraktion fehlgeschlagen – %s", msg)

        return {
            "extracted": extracted,
            "errors": len(self._errors),
            "documents": self.get_document_list(),
        }

    # ------------------------------------------------------------------
    # Schritt 3: Sequenzielle Formulare (nur Modell-Dokumente)
    # ------------------------------------------------------------------

    def get_document_list(self) -> list[dict[str, Any]]:
        """Liste der Dokumente, die manuell angepasst werden können."""
        docs: list[dict[str, Any]] = []
        for i, unit in enumerate(self._model_units):
            if unit.status not in ("extracted", "done"):
                continue
            docs.append(
                {
                    "index": i,
                    "name": unit.name,
                    "relPath": unit.rel_path,
                    "hasInlineReport": bool(unit.inline_report_id),
                }
            )
        return docs

    def get_document_form(self, index: int) -> dict[str, Any]:
        """Formulardaten für ein Modell-Dokument (analog AppController.get_form_data)."""
        unit = self._model_units[index]
        if not unit.mappings:
            return {"sections": [], "summary": None, "name": unit.name}

        form_sections = self._form_generator.generate_form_data(unit.mappings)
        sections = []
        for section in form_sections:
            fields = []
            for f in section.fields:
                fields.append(
                    {
                        "name": f.placeholder_name,
                        "label": f.label,
                        "value": unit.manual_values.get(
                            f.placeholder_name, f.current_value
                        ),
                        "source": "auto" if f.is_auto_filled else "manual",
                        "tooltip": f.context or None,
                        "options": None,
                        "required": f.is_required,
                        "tableGroup": None,
                    }
                )
            sections.append({"title": section.section_name, "fields": fields})

        auto_count = sum(1 for m in unit.mappings if m.source == "auto")
        manual_count = sum(1 for m in unit.mappings if m.source == "manual")
        partial_count = sum(1 for m in unit.mappings if m.source == "partial")
        summary = {
            "autoCount": auto_count,
            "manualCount": manual_count,
            "partialCount": partial_count,
        }
        return {"sections": sections, "summary": summary, "name": unit.name}

    def save_document_form(self, index: int, values: dict[str, str]) -> None:
        """Speichert die manuell angepassten Werte für ein Dokument (Übernehmen)."""
        unit = self._model_units[index]
        unit.manual_values = dict(values or {})

    # ------------------------------------------------------------------
    # Schritt 4: Dateien erzeugen
    # ------------------------------------------------------------------

    def finalize(self) -> dict[str, Any]:
        """Rendert und schreibt alle Dateien inkl. Übersicht und Fehler-Log."""
        assert self._ws is not None
        ws_dir = os.path.join(
            self._target_dir, _safe_filename(self._ws.workspace_name)
        )
        os.makedirs(ws_dir, exist_ok=True)

        written: list[str] = []

        if not self._only_workspace:
            self._write_model_documents(ws_dir, written)
            self._write_report_documents(ws_dir, written)
            self._write_dataflow_documents(ws_dir, written)
            self._write_notebook_documents(ws_dir, written)
            self._write_copyjob_documents(ws_dir, written)
            self._write_pipeline_documents(ws_dir, written)
            self._write_lakehouse_documents(ws_dir, written)
            self._write_warehouse_documents(ws_dir, written)
            self._write_eventhouse_documents(ws_dir, written)
            self._write_dashboard_documents(ws_dir, written)
            self._write_kqldatabase_documents(ws_dir, written)
            self._write_sqldatabase_documents(ws_dir, written)
            self._write_environment_documents(ws_dir, written)
            self._write_mirroreddb_documents(ws_dir, written)
            self._write_eventstream_documents(ws_dir, written)
            self._write_sparkjob_documents(ws_dir, written)
            self._write_kqlqueryset_documents(ws_dir, written)
            self._write_reflex_documents(ws_dir, written)
            self._write_graphqlapi_documents(ws_dir, written)

        # Übersicht immer erzeugen
        overview_path = self._write_overview(ws_dir)
        written.append(overview_path)

        # Fehler-Log
        log_path = ""
        if self._errors:
            log_path = self._write_error_log(ws_dir)

        return {
            "outputDir": ws_dir,
            "overviewPath": overview_path,
            "fileCount": len(written),
            "errorCount": len(self._errors),
            "errorLogPath": log_path,
            "errors": list(self._errors),
        }

    def _write_model_documents(self, ws_dir: str, written: list[str]) -> None:
        for unit in self._model_units:
            if unit.status not in ("extracted", "done") or unit.metadata is None:
                continue
            try:
                content = render_report_markdown(
                    self._report_parse,
                    unit.metadata,
                    unit.mappings,
                    unit.manual_values,
                    self._connection_type,
                    language=self._language,
                    form_generator=self._form_generator,
                    renderer=self._renderer,
                )
                out = os.path.join(ws_dir, unit.rel_path.replace("/", os.sep))
                os.makedirs(os.path.dirname(out), exist_ok=True)
                with open(out, "w", encoding="utf-8") as f:
                    f.write(content)
                unit.status = "done"
                written.append(out)
            except Exception as exc:  # noqa: BLE001
                unit.status = "error"
                msg = f"Rendern des Modells '{unit.name}': {exc}"
                self._errors.append(msg)
                logger.warning(msg)

    def _write_report_documents(self, ws_dir: str, written: list[str]) -> None:
        template = wsdoc.load_template(
            self._template_dir,
            wsdoc.REPORT_TEMPLATE_FILENAME,
            wsdoc.DEFAULT_REPORT_TEMPLATE,
        )
        export_date = date.today().isoformat()

        class _VisualsHolder:
            def __init__(self, visuals: list) -> None:
                self.visuals = visuals

        for runit in self._report_units:
            try:
                access_md = build_report_access_table_from_users(
                    runit.report_users, "fabric", self._language
                )
                content = wsdoc.render_standalone_report(
                    template,
                    runit.report,
                    runit.model_name or "(externes Semantic Model)",
                    runit.model_rel_path,
                    _VisualsHolder(runit.visuals),
                    export_date,
                    report_access_md=access_md,
                )
                out = os.path.join(ws_dir, runit.rel_path.replace("/", os.sep))
                os.makedirs(os.path.dirname(out), exist_ok=True)
                with open(out, "w", encoding="utf-8") as f:
                    f.write(content)
                runit.status = "done"
                written.append(out)
            except Exception as exc:  # noqa: BLE001
                runit.status = "error"
                msg = f"Rendern des Berichts '{runit.report.name}': {exc}"
                self._errors.append(msg)
                logger.warning(msg)

    def _write_dataflow_documents(self, ws_dir: str, written: list[str]) -> None:
        template = dfdoc.load_template(
            self._template_dir,
            dfdoc.DATAFLOW_TEMPLATE_FILENAME,
            dfdoc.DEFAULT_DATAFLOW_TEMPLATE,
        )
        export_date = date.today().isoformat()

        for dunit in self._dataflow_units:
            if dunit.status not in ("extracted", "done") or dunit.dataflow is None:
                continue
            try:
                content = dfdoc.render_dataflow(
                    template,
                    dunit.dataflow,
                    export_date,
                    language=self._language,
                )
                out = os.path.join(ws_dir, dunit.rel_path.replace("/", os.sep))
                os.makedirs(os.path.dirname(out), exist_ok=True)
                with open(out, "w", encoding="utf-8") as f:
                    f.write(content)
                dunit.status = "done"
                written.append(out)
            except Exception as exc:  # noqa: BLE001
                dunit.status = "error"
                msg = f"Rendern des Dataflows '{dunit.item.name}': {exc}"
                self._errors.append(msg)
                logger.warning(msg)

    def _write_notebook_documents(self, ws_dir: str, written: list[str]) -> None:
        template = nbdoc.load_template(
            self._template_dir,
            nbdoc.NOTEBOOK_TEMPLATE_FILENAME,
            nbdoc.DEFAULT_NOTEBOOK_TEMPLATE,
        )
        export_date = date.today().isoformat()

        for nunit in self._notebook_units:
            if nunit.status not in ("extracted", "done") or nunit.notebook is None:
                continue
            try:
                content = nbdoc.render_notebook(
                    template,
                    nunit.notebook,
                    export_date,
                    language=self._language,
                )
                out = os.path.join(ws_dir, nunit.rel_path.replace("/", os.sep))
                os.makedirs(os.path.dirname(out), exist_ok=True)
                with open(out, "w", encoding="utf-8") as f:
                    f.write(content)
                nunit.status = "done"
                written.append(out)
            except Exception as exc:  # noqa: BLE001
                nunit.status = "error"
                msg = f"Rendern des Notebooks '{nunit.item.name}': {exc}"
                self._errors.append(msg)
                logger.warning(msg)

    def _write_copyjob_documents(self, ws_dir: str, written: list[str]) -> None:
        template = cjdoc.load_template(
            self._template_dir,
            cjdoc.COPYJOB_TEMPLATE_FILENAME,
            cjdoc.DEFAULT_COPYJOB_TEMPLATE,
        )
        export_date = date.today().isoformat()

        for cunit in self._copyjob_units:
            if cunit.status not in ("extracted", "done") or cunit.copyjob is None:
                continue
            try:
                content = cjdoc.render_copyjob(
                    template,
                    cunit.copyjob,
                    export_date,
                    language=self._language,
                )
                out = os.path.join(ws_dir, cunit.rel_path.replace("/", os.sep))
                os.makedirs(os.path.dirname(out), exist_ok=True)
                with open(out, "w", encoding="utf-8") as f:
                    f.write(content)
                cunit.status = "done"
                written.append(out)
            except Exception as exc:  # noqa: BLE001
                cunit.status = "error"
                msg = f"Rendern des Copyjobs '{cunit.item.name}': {exc}"
                self._errors.append(msg)
                logger.warning(msg)

    def _write_pipeline_documents(self, ws_dir: str, written: list[str]) -> None:
        template = pldoc.load_template(
            self._template_dir,
            pldoc.PIPELINE_TEMPLATE_FILENAME,
            pldoc.DEFAULT_PIPELINE_TEMPLATE,
        )
        export_date = date.today().isoformat()

        for punit in self._pipeline_units:
            if punit.status not in ("extracted", "done") or punit.pipeline is None:
                continue
            try:
                content = pldoc.render_pipeline(
                    template, punit.pipeline, export_date, language=self._language
                )
                out = os.path.join(ws_dir, punit.rel_path.replace("/", os.sep))
                os.makedirs(os.path.dirname(out), exist_ok=True)
                with open(out, "w", encoding="utf-8") as f:
                    f.write(content)
                punit.status = "done"
                written.append(out)
            except Exception as exc:  # noqa: BLE001
                punit.status = "error"
                msg = f"Rendern der Pipeline '{punit.item.name}': {exc}"
                self._errors.append(msg)
                logger.warning(msg)

    def _write_lakehouse_documents(self, ws_dir: str, written: list[str]) -> None:
        template = lhdoc.load_template(
            self._template_dir,
            lhdoc.LAKEHOUSE_TEMPLATE_FILENAME,
            lhdoc.DEFAULT_LAKEHOUSE_TEMPLATE,
        )
        export_date = date.today().isoformat()

        for lunit in self._lakehouse_units:
            if lunit.status not in ("extracted", "done") or lunit.lakehouse is None:
                continue
            try:
                content = lhdoc.render_lakehouse(
                    template,
                    lunit.lakehouse,
                    export_date,
                    language=self._language,
                )
                out = os.path.join(ws_dir, lunit.rel_path.replace("/", os.sep))
                os.makedirs(os.path.dirname(out), exist_ok=True)
                with open(out, "w", encoding="utf-8") as f:
                    f.write(content)
                lunit.status = "done"
                written.append(out)
            except Exception as exc:  # noqa: BLE001
                lunit.status = "error"
                msg = f"Rendern des Lakehouses '{lunit.item.name}': {exc}"
                self._errors.append(msg)
                logger.warning(msg)

    def _write_warehouse_documents(self, ws_dir: str, written: list[str]) -> None:
        template = whdoc.load_template(
            self._template_dir,
            whdoc.WAREHOUSE_TEMPLATE_FILENAME,
            whdoc.DEFAULT_WAREHOUSE_TEMPLATE,
        )
        export_date = date.today().isoformat()

        for wunit in self._warehouse_units:
            if wunit.status not in ("extracted", "done") or wunit.warehouse is None:
                continue
            try:
                content = whdoc.render_warehouse(
                    template, wunit.warehouse, export_date, language=self._language
                )
                out = os.path.join(ws_dir, wunit.rel_path.replace("/", os.sep))
                os.makedirs(os.path.dirname(out), exist_ok=True)
                with open(out, "w", encoding="utf-8") as f:
                    f.write(content)
                wunit.status = "done"
                written.append(out)
            except Exception as exc:  # noqa: BLE001
                wunit.status = "error"
                msg = f"Rendern des Warehouses '{wunit.item.name}': {exc}"
                self._errors.append(msg)
                logger.warning(msg)

    def _write_eventhouse_documents(self, ws_dir: str, written: list[str]) -> None:
        template = ehdoc.load_template(
            self._template_dir,
            ehdoc.EVENTHOUSE_TEMPLATE_FILENAME,
            ehdoc.DEFAULT_EVENTHOUSE_TEMPLATE,
        )
        export_date = date.today().isoformat()

        for eunit in self._eventhouse_units:
            if eunit.status not in ("extracted", "done") or eunit.eventhouse is None:
                continue
            try:
                content = ehdoc.render_eventhouse(
                    template, eunit.eventhouse, export_date, language=self._language
                )
                out = os.path.join(ws_dir, eunit.rel_path.replace("/", os.sep))
                os.makedirs(os.path.dirname(out), exist_ok=True)
                with open(out, "w", encoding="utf-8") as f:
                    f.write(content)
                eunit.status = "done"
                written.append(out)
            except Exception as exc:  # noqa: BLE001
                eunit.status = "error"
                msg = f"Rendern des Eventhouses '{eunit.item.name}': {exc}"
                self._errors.append(msg)
                logger.warning(msg)

    def _write_dashboard_documents(self, ws_dir: str, written: list[str]) -> None:
        template = dbdoc.load_template(
            self._template_dir,
            dbdoc.DASHBOARD_TEMPLATE_FILENAME,
            dbdoc.DEFAULT_DASHBOARD_TEMPLATE,
        )
        export_date = date.today().isoformat()

        for dbunit in self._dashboard_units:
            if dbunit.status not in ("extracted", "done") or dbunit.dashboard is None:
                continue
            try:
                content = dbdoc.render_dashboard(
                    template, dbunit.dashboard, export_date, language=self._language
                )
                out = os.path.join(ws_dir, dbunit.rel_path.replace("/", os.sep))
                os.makedirs(os.path.dirname(out), exist_ok=True)
                with open(out, "w", encoding="utf-8") as f:
                    f.write(content)
                dbunit.status = "done"
                written.append(out)
            except Exception as exc:  # noqa: BLE001
                dbunit.status = "error"
                msg = f"Rendern des Dashboards '{dbunit.item.name}': {exc}"
                self._errors.append(msg)
                logger.warning(msg)

    def _write_kqldatabase_documents(self, ws_dir: str, written: list[str]) -> None:
        template = kqldoc.load_template(
            self._template_dir,
            kqldoc.KQLDATABASE_TEMPLATE_FILENAME,
            kqldoc.DEFAULT_KQLDATABASE_TEMPLATE,
        )
        export_date = date.today().isoformat()

        for khunit in self._kqldatabase_units:
            if khunit.status not in ("extracted", "done") or khunit.kqldatabase is None:
                continue
            try:
                content = kqldoc.render_kqldatabase(
                    template,
                    khunit.kqldatabase,
                    export_date,
                    language=self._language,
                )
                out = os.path.join(ws_dir, khunit.rel_path.replace("/", os.sep))
                os.makedirs(os.path.dirname(out), exist_ok=True)
                with open(out, "w", encoding="utf-8") as f:
                    f.write(content)
                khunit.status = "done"
                written.append(out)
            except Exception as exc:  # noqa: BLE001
                khunit.status = "error"
                msg = f"Rendern der KQL-Datenbank '{khunit.item.name}': {exc}"
                self._errors.append(msg)
                logger.warning(msg)

    def _write_sqldatabase_documents(self, ws_dir: str, written: list[str]) -> None:
        template = sqdoc.load_template(
            self._template_dir,
            sqdoc.SQLDATABASE_TEMPLATE_FILENAME,
            sqdoc.DEFAULT_SQLDATABASE_TEMPLATE,
        )
        export_date = date.today().isoformat()

        for squnit in self._sqldatabase_units:
            if squnit.status not in ("extracted", "done") or squnit.sqldatabase is None:
                continue
            try:
                content = sqdoc.render_sqldatabase(
                    template,
                    squnit.sqldatabase,
                    export_date,
                    language=self._language,
                )
                out = os.path.join(ws_dir, squnit.rel_path.replace("/", os.sep))
                os.makedirs(os.path.dirname(out), exist_ok=True)
                with open(out, "w", encoding="utf-8") as f:
                    f.write(content)
                squnit.status = "done"
                written.append(out)
            except Exception as exc:  # noqa: BLE001
                squnit.status = "error"
                msg = f"Rendern der SQL Database '{squnit.item.name}': {exc}"
                self._errors.append(msg)
                logger.warning(msg)

    def _write_environment_documents(self, ws_dir: str, written: list[str]) -> None:
        template = envdoc.load_template(
            self._template_dir,
            envdoc.ENVIRONMENT_TEMPLATE_FILENAME,
            envdoc.DEFAULT_ENVIRONMENT_TEMPLATE,
        )
        export_date = date.today().isoformat()

        for envunit in self._environment_units:
            if envunit.status not in ("extracted", "done") or envunit.environment is None:
                continue
            try:
                content = envdoc.render_environment(
                    template,
                    envunit.environment,
                    export_date,
                    language=self._language,
                )
                out = os.path.join(ws_dir, envunit.rel_path.replace("/", os.sep))
                os.makedirs(os.path.dirname(out), exist_ok=True)
                with open(out, "w", encoding="utf-8") as f:
                    f.write(content)
                envunit.status = "done"
                written.append(out)
            except Exception as exc:  # noqa: BLE001
                envunit.status = "error"
                msg = f"Rendern des Environments '{envunit.item.name}': {exc}"
                self._errors.append(msg)
                logger.warning(msg)

    def _write_mirroreddb_documents(self, ws_dir: str, written: list[str]) -> None:
        template = mddoc.load_template(
            self._template_dir,
            mddoc.MIRROREDDB_TEMPLATE_FILENAME,
            mddoc.DEFAULT_MIRROREDDB_TEMPLATE,
        )
        export_date = date.today().isoformat()

        for mdunit in self._mirroreddb_units:
            if mdunit.status not in ("extracted", "done") or mdunit.mirroreddb is None:
                continue
            try:
                content = mddoc.render_mirroreddb(
                    template,
                    mdunit.mirroreddb,
                    export_date,
                    language=self._language,
                )
                out = os.path.join(ws_dir, mdunit.rel_path.replace("/", os.sep))
                os.makedirs(os.path.dirname(out), exist_ok=True)
                with open(out, "w", encoding="utf-8") as f:
                    f.write(content)
                mdunit.status = "done"
                written.append(out)
            except Exception as exc:  # noqa: BLE001
                mdunit.status = "error"
                msg = f"Rendern der Mirrored Database '{mdunit.item.name}': {exc}"
                self._errors.append(msg)
                logger.warning(msg)

    def _write_eventstream_documents(self, ws_dir: str, written: list[str]) -> None:
        template = esdoc.load_template(
            self._template_dir,
            esdoc.EVENTSTREAM_TEMPLATE_FILENAME,
            esdoc.DEFAULT_EVENTSTREAM_TEMPLATE,
        )
        export_date = date.today().isoformat()

        for esunit in self._eventstream_units:
            if esunit.status not in ("extracted", "done") or esunit.eventstream is None:
                continue
            try:
                content = esdoc.render_eventstream(
                    template,
                    esunit.eventstream,
                    export_date,
                    language=self._language,
                )
                out = os.path.join(ws_dir, esunit.rel_path.replace("/", os.sep))
                os.makedirs(os.path.dirname(out), exist_ok=True)
                with open(out, "w", encoding="utf-8") as f:
                    f.write(content)
                esunit.status = "done"
                written.append(out)
            except Exception as exc:  # noqa: BLE001
                esunit.status = "error"
                msg = f"Rendern des Eventstreams '{esunit.item.name}': {exc}"
                self._errors.append(msg)
                logger.warning(msg)

    def _write_sparkjob_documents(self, ws_dir: str, written: list[str]) -> None:
        template = sjdoc.load_template(
            self._template_dir,
            sjdoc.SPARKJOB_TEMPLATE_FILENAME,
            sjdoc.DEFAULT_SPARKJOB_TEMPLATE,
        )
        export_date = date.today().isoformat()

        for sjunit in self._sparkjob_units:
            if sjunit.status not in ("extracted", "done") or sjunit.sparkjob is None:
                continue
            try:
                content = sjdoc.render_sparkjob(
                    template,
                    sjunit.sparkjob,
                    export_date,
                    language=self._language,
                )
                out = os.path.join(ws_dir, sjunit.rel_path.replace("/", os.sep))
                os.makedirs(os.path.dirname(out), exist_ok=True)
                with open(out, "w", encoding="utf-8") as f:
                    f.write(content)
                sjunit.status = "done"
                written.append(out)
            except Exception as exc:  # noqa: BLE001
                sjunit.status = "error"
                msg = f"Rendern der Spark Job Definition '{sjunit.item.name}': {exc}"
                self._errors.append(msg)
                logger.warning(msg)

    def _write_kqlqueryset_documents(self, ws_dir: str, written: list[str]) -> None:
        template = qsdoc.load_template(
            self._template_dir,
            qsdoc.KQLQUERYSET_TEMPLATE_FILENAME,
            qsdoc.DEFAULT_KQLQUERYSET_TEMPLATE,
        )
        export_date = date.today().isoformat()

        for qsunit in self._kqlqueryset_units:
            if qsunit.status not in ("extracted", "done") or qsunit.kqlqueryset is None:
                continue
            try:
                content = qsdoc.render_kqlqueryset(
                    template,
                    qsunit.kqlqueryset,
                    export_date,
                    language=self._language,
                )
                out = os.path.join(ws_dir, qsunit.rel_path.replace("/", os.sep))
                os.makedirs(os.path.dirname(out), exist_ok=True)
                with open(out, "w", encoding="utf-8") as f:
                    f.write(content)
                qsunit.status = "done"
                written.append(out)
            except Exception as exc:  # noqa: BLE001
                qsunit.status = "error"
                msg = f"Rendern des KQL-Querysets '{qsunit.item.name}': {exc}"
                self._errors.append(msg)
                logger.warning(msg)

    def _write_reflex_documents(self, ws_dir: str, written: list[str]) -> None:
        template = rxdoc.load_template(
            self._template_dir,
            rxdoc.REFLEX_TEMPLATE_FILENAME,
            rxdoc.DEFAULT_REFLEX_TEMPLATE,
        )
        export_date = date.today().isoformat()

        for rxunit in self._reflex_units:
            if rxunit.status not in ("extracted", "done") or rxunit.reflex is None:
                continue
            try:
                content = rxdoc.render_reflex(
                    template,
                    rxunit.reflex,
                    export_date,
                    language=self._language,
                )
                out = os.path.join(ws_dir, rxunit.rel_path.replace("/", os.sep))
                os.makedirs(os.path.dirname(out), exist_ok=True)
                with open(out, "w", encoding="utf-8") as f:
                    f.write(content)
                rxunit.status = "done"
                written.append(out)
            except Exception as exc:  # noqa: BLE001
                rxunit.status = "error"
                msg = f"Rendern des Reflex '{rxunit.item.name}': {exc}"
                self._errors.append(msg)
                logger.warning(msg)

    def _write_graphqlapi_documents(self, ws_dir: str, written: list[str]) -> None:
        template = gqdoc.load_template(
            self._template_dir,
            gqdoc.GRAPHQLAPI_TEMPLATE_FILENAME,
            gqdoc.DEFAULT_GRAPHQLAPI_TEMPLATE,
        )
        export_date = date.today().isoformat()

        for gqunit in self._graphqlapi_units:
            if gqunit.status not in ("extracted", "done") or gqunit.graphqlapi is None:
                continue
            try:
                content = gqdoc.render_graphqlapi(
                    template,
                    gqunit.graphqlapi,
                    export_date,
                    language=self._language,
                )
                out = os.path.join(ws_dir, gqunit.rel_path.replace("/", os.sep))
                os.makedirs(os.path.dirname(out), exist_ok=True)
                with open(out, "w", encoding="utf-8") as f:
                    f.write(content)
                gqunit.status = "done"
                written.append(out)
            except Exception as exc:  # noqa: BLE001
                gqunit.status = "error"
                msg = f"Rendern der GraphQL-API '{gqunit.item.name}': {exc}"
                self._errors.append(msg)
                logger.warning(msg)

    def _write_overview(self, ws_dir: str) -> str:
        assert self._ws is not None
        template = wsdoc.load_template(
            self._template_dir,
            wsdoc.WORKSPACE_TEMPLATE_FILENAME,
            wsdoc.DEFAULT_WORKSPACE_TEMPLATE,
        )
        content = wsdoc.render_overview(
            template,
            self._ws,
            self._model_file_map,
            self._report_file_map,
            self._report_count_map,
            self._dataflow_file_map,
            self._notebook_file_map,
            self._copyjob_file_map,
            self._lakehouse_file_map,
            self._warehouse_file_map,
            self._eventhouse_file_map,
            self._pipeline_file_map,
            self._dashboard_file_map,
            self._kqldatabase_file_map,
            self._sqldatabase_file_map,
            self._environment_file_map,
            self._mirroreddb_file_map,
            self._eventstream_file_map,
            self._sparkjob_file_map,
            self._kqlqueryset_file_map,
            self._reflex_file_map,
            self._graphqlapi_file_map,
        )
        out = os.path.join(ws_dir, "Workspace-Uebersicht.md")
        with open(out, "w", encoding="utf-8") as f:
            f.write(content)
        return out

    def _write_error_log(self, ws_dir: str) -> str:
        out = os.path.join(ws_dir, "fehler-log.txt")
        with open(out, "w", encoding="utf-8") as f:
            f.write("Workspace-Export – Fehlerprotokoll\n")
            f.write("=" * 40 + "\n\n")
            for err in self._errors:
                f.write(f"- {err}\n")
        return out
