import argparse
import ezdxf
import json
import os.path
from dataclasses import dataclass, asdict, field
from dacite import from_dict
from typing import List, Dict
from datetime import datetime
from openpyxl import Workbook
import math
from collections import defaultdict
import configparser
import updateconfignames as uc
from pathlib import Path

from error_collector import ErrorCollector, write_json_file
from utils import check_file_in_work


@dataclass
class Point:
    x: float
    y: float


@dataclass
class Polyline:
    id: str
    s_artinr: str
    coords: List[Point]
    length: float

    def to_tuple(self):
        ret = list()
        for p in self.coords:
            ret.append( (p.x, p.y) )
        return ret

@dataclass
# Fehlgeschlagene Anbindung von einem Sensor / Dist zu einem Rack
class Error_Connection:
    name: str
    coords: Point

@dataclass
# Felgeschlagene Verbindung von einem Dist zu Sensor(en) aus beliebigem Grund
class Error_Routing:
    unterverteiler: str
    sensoren: List[str]

@dataclass
class Coordinate:
    x: float
    y: float
    z: float

@dataclass
class RackGeometry:
    length: float
    coordinates: List[Coordinate]


@dataclass
class Polylines:
    """Enthält alle Kabel-Polylinien und Rack-Geometrien (ohne Fehlerbehandlung)."""
    kabel: List[Polyline]
    rack_geometry: Dict[str, RackGeometry] = field(default_factory=dict)
 

def add_polyline(msp, points:Polyline, dxf_attribs):
    pts = points.to_tuple()
    pline = msp.add_lwpolyline(points=pts, dxfattribs=dxf_attribs)
    pline.rgb = (255, 128, 0)

def new_dxf(plines, out_path):
    """
    Erstellt eine neue DXF-Datei mit den gegebenen Polylinien und speichert sie unter dem angegebenen Pfad.

    Args:
        plines (Polylines): Die Polylinien- und Geometriedaten, die gezeichnet werden sollen.
        out_path (str): Der Pfad, unter dem die neue DXF-Datei gespeichert wird.
    """
    doc = ezdxf.new('R2018', setup=True)
    doc.header['$INSUNITS'] = 4  # Millimeter
    draw_cables(plines, doc)
    draw_sensors(plines, doc)
    draw_subdists(plines, doc)
    draw_racks(plines, doc)

    doc.saveas(out_path)
    print("Cable-Routes exported to new dxf-file")
    
def modify_original_dxf(plines, originaldxf):
    """
    Fügt einer bestehenden DXF-Datei einen neuen Layer mit Kabeln hinzu und speichert das Ergebnis.

    Args:
        plines (Polylines): Die Polylinien- und Geometriedaten, die gezeichnet werden sollen.
        originaldxf (str): Pfad zur bestehenden DXF-Datei, die modifiziert werden soll.
    """
    print("adding cables into original .dxf ..")

    doc = ezdxf.readfile(originaldxf)
    draw_cables(plines, doc)
    
    doc.saveas(out_path)
    print("done")


def draw_cables(plines, doc):
    """
    Zeichnet alle Kabel-Polylinien in das DXF-Dokument auf einen eigenen Layer.

    Args:
        plines (Polylines): Die Polylinien- und Geometriedaten.
        doc (ezdxf.document.Drawing): Das zu bearbeitende DXF-Dokument.
    """
    msp = doc.modelspace()
    timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M")
    cable_layer = f"cables_{timestamp}"

    # Kabel-Layer anlegen
    if cable_layer not in doc.layers:
        doc.layers.add(name=cable_layer, color=7)
    
    dxfattribs_cable={"layer": cable_layer}
    
    # Kabel zeichnen
    for pl in plines.kabel:
        # Polyline für Kabel zeichnen
        add_polyline(msp, pl, dxfattribs_cable)

def draw_racks(plines, doc):
    """
    Zeichnet die Racks als 3D-Polylinien und platziert Rack-Namen als Text im DXF-Dokument.

    Args:
        plines (Polylines): Die Polylinien- und Geometriedaten.
        doc (ezdxf.document.Drawing): Das zu bearbeitende DXF-Dokument.
    """
    msp = doc.modelspace()
    timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M")
    rack_layer = f"racks_{timestamp}"

    # Rack-Layer anlegen (Farbe 3 = grün z.B.)
    if rack_layer not in doc.layers:
        doc.layers.add(name=rack_layer, color=3, lineweight=200)  # lineweight für Dicke

    dxfattribs_rack = {
        "layer": rack_layer,
        "color": 3,
        "lineweight": 200  # Lineweight (in 1/100 mm)
    }

    for rack_name, rack_geom in plines.rack_geometry.items():
        classifier = rack_name[0]

        if classifier in ("t", "v", "d"):
            continue

        coords = [(pt.x, pt.y, pt.z) for pt in rack_geom.coordinates]
        if not coords:
            continue

        polyline = msp.add_polyline3d(coords, dxfattribs=dxfattribs_rack)

        if classifier == "c":
            continue
        
        # Textplatzierung
        x, y, z = coords[0] # Get the first coordinate for text placement

        # Orientierung bestimmen (horizontal oder vertikal)
        if len(coords) >= 2:
            x2, y2, _ = coords[1]
            dx = x2 - x
            dy = y2 - y

            is_vertical = abs(dy) > abs(dx)
        else:
            is_vertical = False  # Standard: horizontal

        # Text platzieren
        text_entity = msp.add_text(
            rack_name,
            dxfattribs={
                "layer": rack_layer,
                "height": 75,
                "color": 3,
                "rotation": 90 if is_vertical else 0,
            }
        )

        # Offset definieren
        offset_x = -50 if is_vertical else 50
        offset_y = 100 if is_vertical else 50

        text_entity.set_placement((x + offset_x, y + offset_y))
          

def find_close_key(pos2sensors, x, y, tolerance=10):        # !!! Toleranz nicht in Config !!!
    ''' Funktion überprüft ob Sensoren nahezu identisch an der gleichen Stelle liegen und legt sie in diesem fall aufeinander
        Wird benötigt, um zusammengehörige Sensoren gestaffelt auf dxf zu zeichen
    '''
    for (px, py) in pos2sensors:
        if abs(px - x) <= tolerance and abs(py - y) <= tolerance:
            return (px, py)
    return None

def draw_sensors(plines, doc):
    """
    Zeichnet die Sensoren als Textobjekte an den Endpunkten der Kabel und gruppiert sie ggf. gestaffelt.

    Args:
        plines (Polylines): Die Polylinien- und Geometriedaten.
        doc (ezdxf.document.Drawing): Das zu bearbeitende DXF-Dokument.
    """
    msp = doc.modelspace()
    timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M")
    sensor_layer = f"sensors_{timestamp}"
    # Sensor-Layer erzeugen
    if sensor_layer not in doc.layers:
        doc.layers.add(name=sensor_layer, color=5)

    dxfattribs_sensors={"layer": sensor_layer, "height": 100}

    # Sensoren nach Endpunkten gruppieren -> mehrfacheinträge gestaffelt zeichnen
    pos2sensors = defaultdict(list)
    for pl in plines.kabel:
        pt2 = pl.coords[-1]     #Endpunkt des Kabels = Sensor Position
        pos_key = find_close_key(pos2sensors, pt2.x, pt2.y)
        if pos_key:
            pos2sensors[pos_key].append(pl)
        else:
            pos2sensors[(pt2.x, pt2.y)].append(pl)

    # Sensor Blöcke zeichnen
    for (x,y), pls in pos2sensors.items(): 
        for i, pl in enumerate(pls): 
            sensor_name = pl.id.split('-')[-1]
            pt1, pt2 = pl.coords[-2], pl.coords[-1]

            # Platzierungsinfo über Hilfsfunktion
            placement_info = _calculate_text_placement(pt1, pt2, 'sensor', i, len(pls))

            text = msp.add_text(sensor_name, dxfattribs=dxfattribs_sensors)
            text.dxf.halign = placement_info["halign"]
            text.dxf.valign = placement_info["valign"]
            text.set_placement(placement_info["placement"])
    
def draw_subdists(plines, doc):
    """
    Zeichnet die Unterverteiler (Subdistributoren) als Textobjekte an den Startpunkten der Kabel.

    Args:
        plines (Polylines): Die Polylinien- und Geometriedaten.
        doc (ezdxf.document.Drawing): Das zu bearbeitende DXF-Dokument.
    """
    msp = doc.modelspace()
    timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M")
    subdist_layer = f"subdists_{timestamp}"

    # Sensor-Layer erzeugen
    if subdist_layer not in doc.layers:
        doc.layers.add(name=subdist_layer, color=3)

    dxfattribs_subdists={"layer": subdist_layer, "height": 100}

    subdist_positions = set()

    for pl in plines.kabel:
        pt1 = pl.coords[1]      # Startposition = UV-Position
        pos = (pt1.x, pt1.y)

        if pos in subdist_positions:
            continue
        subdist_positions.add(pos)

        subdist_name = pl.id.split('-')[0]

        pt2 = pl.coords[0]
        placement_info = _calculate_text_placement(pt1, pt2, 'subdist')

        text = msp.add_text(subdist_name, dxfattribs=dxfattribs_subdists)
        text.dxf.halign = placement_info["halign"]
        text.dxf.valign = placement_info["valign"]
        text.set_placement(placement_info["placement"])

def _calculate_text_placement(pt1, pt2, text_type='sensor', item_index=0, total_items=1):
    """
    Berechnet die optimale Position und Ausrichtung für Beschriftungen.
    Gibt ein Dictionary mit Platzierungskoordinaten und Ausrichtungs-Flags zurück.
    """
    dx = pt2.x - pt1.x
    dy = pt2.y - pt1.y
    
    # Lese Offsets aus der Konfiguration, mit Fallback-Werten
    offset_h_dist = 50.0
    offset_v_dist = 50.0
    offset_y_center = -80.0
    offset_y_stack = 110.0

    offsetx, offsety = 0, 0
    
    if abs(dx) > abs(dy):  # Kabel verläuft hauptsächlich horizontal
        valign = 1  # BOTTOM
        if text_type == 'sensor':
            center_offset = item_index - (total_items - 1) / 2
            offsety = offset_y_center + center_offset * offset_y_stack
        else: # subdist
            offsety = offset_y_center

        if dx > 0:
            halign = 0  # LEFT
            offsetx = offset_h_dist
        else:
            halign = 2  # RIGHT
            offsetx = -offset_h_dist
            
    else:  # Kabel verläuft hauptsächlich vertikal
        halign = 1  # CENTER
        if dy > 0:
            valign = 0  # BASELINE
            offsety = offset_v_dist + item_index * offset_y_stack
        else:
            valign = 3  # TOP
            offsety = -offset_v_dist - item_index * offset_y_stack

    return {
        "placement": (pt2.x + offsetx, pt2.y + offsety),
        "halign": halign,
        "valign": valign,
    }

def model_from_json(json_file):
    """
    Lädt die Polylinien- und Geometriedaten aus einer JSON-Datei und gibt Polylines-Objekt und ErrorCollector zurück.

    Args:
        json_file (str): Pfad zur JSON-Datei.

    Returns:
        tuple: (Polylines-Objekt, ErrorCollector-Objekt)
    """
    with open(json_file, encoding='utf-8') as fh:
        data = json.load(fh)

    # ErrorCollector für Fehler und Warnungen initialisieren
    error_collector = ErrorCollector()

    # Fehler-Daten aus JSON extrahieren und in ErrorCollector speichern
    error_fields = {
        'errors_routing': [],
        'errors_sensors': [],
        'errors_dists': [],
        'errors_dists_not_in_layout': [],
        'errors_sensors_not_in_layout': [],
        'errors_missing_attributes': {},
        'errors_tunnels': []
    }

    # Fehler aus data extrahieren
    for field in error_fields.keys():
        if field in data:
            error_fields[field] = data.pop(field)

    # Fehler zum ErrorCollector hinzufügen
    errors_dict = {}
    warnings_dict = {}

    # Kritische Fehler
    if error_fields['errors_routing']:
        errors_dict['routing'] = error_fields['errors_routing']
    if error_fields['errors_sensors']:
        errors_dict['sensors_connection'] = error_fields['errors_sensors']
    if error_fields['errors_dists']:
        errors_dict['dists_connection'] = error_fields['errors_dists']
    if error_fields['errors_dists_not_in_layout']:
        errors_dict['dists_not_in_layout'] = error_fields['errors_dists_not_in_layout']
    if error_fields['errors_sensors_not_in_layout']:
        errors_dict['sensors_not_in_layout'] = error_fields['errors_sensors_not_in_layout']
    if error_fields['errors_tunnels']:
        errors_dict['tunnels'] = error_fields['errors_tunnels']

    # Warnungen
    if error_fields['errors_missing_attributes']:
        warnings_dict['missing_attributes'] = error_fields['errors_missing_attributes']

    # Warnungen aus getpositions und routing übernehmen
    if 'warnings' in data:
        warnings_from_upstream = data.pop('warnings')
        if isinstance(warnings_from_upstream, dict):
            warnings_dict.update(warnings_from_upstream)

    if errors_dict:
        error_collector.add_errors(errors_dict)
    if warnings_dict:
        error_collector.add_warnings(warnings_dict)

    # Polylines-Objekt aus den verbleibenden Daten erstellen
    plines = from_dict(
        data_class=Polylines,
        data=data
    )
    return plines, error_collector

def parse_sensors_from_json(positions_json):
    """
    Liest Sensordaten aus einer JSON-Datei und gibt ein Dictionary mit Sensorobjekten zurück.

    Args:
        positions_json (str): Pfad zur JSON-Datei mit Sensorpositionen.

    Returns:
        dict: Dictionary mit Sensorname als Key und Sensorobjekt als Value.
    """
    with open(positions_json, encoding='utf-8') as fh:
        data = json.load(fh)
    
    sensors = {}
    for name, data in data.get("sensors", {}).items():
        sensor = Sensors(
            name=name,
            artinr=data.get("ARTINR", ""),
            pos=data.get("pos", [0.0, 0.0]),
        )
        sensors[name] = sensor
    return sensors

def get_all_artnrs(plines: Polylines, sens2cable: dict) -> set:
    """
    Sammelt alle verwendeten Artikelnummern aus den Polylinien und der Sensor-Kabel-Zuordnung.

    Args:
        plines (Polylines): Die Polylinien- und Geometriedaten.
        sens2cable (dict): Mapping von Sensor-IDs zu Kabel-Artikelnummern.

    Returns:
        set: Menge aller gefundenen Artikelnummern.
    """
    all_artnrs = set()
    for pl in plines.kabel:
        if pl.s_artinr:
            all_artnrs.add(pl.s_artinr)

    for cablelist in sens2cable.values():
        for artnr in cablelist:
            if isinstance(artnr, str) and artnr.isdigit():
                all_artnrs.add(artnr)
    return all_artnrs

def mark_missings(all_artnrs):
    """
    Markiert fehlende Artikelnummern im Bezeichner-Config, falls sie nicht vorhanden sind.

    Args:
        all_artnrs (set): Menge aller verwendeten Artikelnummern.
    """
    if "Sivasnummern" not in bezeichner_cfg:
        bezeichner_cfg["Sivasnummern"] = {}
    if "Missing" not in bezeichner_cfg:
        bezeichner_cfg["Missing"] = {}

    for artnr in all_artnrs:
        if artnr not in bezeichner_cfg["Sivasnummern"]:
            bezeichner_cfg["Missing"][artnr] = ""

def write_excel_from_json(plines: Polylines, sens2cable: dict, outpath: str, error_collector: ErrorCollector = None, with_bom=True):
    """
    Erstellt Excel-Reports (Kabelübersicht, Fehlerlisten, Stückliste) aus den Polylinien- und Zuordnungsdaten.

    Args:
        plines (Polylines): Die Polylinien- und Geometriedaten.
        sens2cable (dict): Mapping von Sensor-IDs zu Kabel-Artikelnummern.
        outpath (str): Pfad zur Ausgabedatei.
        error_collector (ErrorCollector, optional): ErrorCollector mit Fehlern und Warnungen.
        with_bom (bool, optional): Ob zusätzlich eine Stückliste (BOM) erzeugt werden soll. Default: True.
    """
    # 1. Daten aggregieren und für die Reports vorbereiten
    processed_data = _process_cable_data(plines, sens2cable)

    # 2. Haupt-Excel-Datei (Kabellängen und Fehler) erstellen
    wb_main = Workbook()

    _create_cable_list_sheet(wb_main.active, plines, sens2cable, bezeichner_cfg)
    _create_cable_summary_sheet(wb_main.create_sheet(), processed_data)
    _create_rack_lengths_sheet(wb_main.create_sheet(), plines)
    _create_error_sheets(wb_main, error_collector)

    wb_main.save(outpath)
    print("Cable-Summary exported to Excel-file")

    # 3. Optionale Stücklisten-Datei (BOM) erstellen
    if with_bom:
        _create_bom_workbook(outpath, processed_data, bezeichner_cfg)

def _get_sivas_name(artnr, bezeichner_cfg):
    """Holt den SIVAS-Namen für eine gegebene Artikelnummer aus der Konfiguration."""
    if not artnr:
        return "Keine Artikelnummer vergeben. Layout prüfen."
    
    name = bezeichner_cfg["Sivasnummern"].get(artnr, "")
    if not name:
        return f"Kein Eintrag zu Art.-Nr: {artnr} in bezeichner.cfg."
    return name

def _process_cable_data(plines: Polylines, sens2cable: dict):
    """
    Aggregiert und bereitet die Kabeldaten für die Excel-Ausgabe auf.

    Args:
        plines (Polylines): Die Polylinien- und Geometriedaten.
        sens2cable (dict): Mapping von Sensor-IDs zu Kabel-Artikelnummern.

    Returns:
        dict: Verschiedene aggregierte Auswertungen für die Excel-Ausgabe.
    """
    # Globale Zusammenfassungen
    artnr_to_stückware_count = defaultdict(int)
    artnr_to_meterware_length = defaultdict(float)
    artnr_to_sensor_count = defaultdict(int)
    
    # UV-spezifische (Unterverteiler) Zusammenfassungen
    uv_to_artnr_to_stückware_count = defaultdict(lambda: defaultdict(int))
    uv_to_artnr_to_meterware_length = defaultdict(lambda: defaultdict(float))
    uv_to_artnr_to_sensor_count = defaultdict(lambda: defaultdict(int))
    
    seen_sensors = set()
    uv_sensor_mapping = defaultdict(dict)

    for pl in plines.kabel:
        length_m = round(pl.length / 1000, 1)
        uv_name, sensor_name = pl.id.split("-")[0], pl.id.split("-")[-1]
        
        # Die Unterscheidung zwischen Stück- und Meterware wird hier explizit gemacht
        is_meterware = "MA" in pl.id

        # Globaler Sensor-Count (einmal pro Sensor)
        if sensor_name not in seen_sensors:
            seen_sensors.add(sensor_name)
            artnr_to_sensor_count[pl.s_artinr] += 1

        # UV-spezifisches Sensor-Mapping für präzise Zählung pro UV
        if sensor_name not in uv_sensor_mapping[uv_name]:
            uv_sensor_mapping[uv_name][sensor_name] = pl.s_artinr

        for artnr in sens2cable[pl.id]:
            if is_meterware:
                artnr_to_meterware_length[artnr] += math.ceil(length_m)
                uv_to_artnr_to_meterware_length[uv_name][artnr] += math.ceil(length_m)
            else: # Stückware
                artnr_to_stückware_count[artnr] += 1
                uv_to_artnr_to_stückware_count[uv_name][artnr] += 1

    # UV-spezifische Sensorzählung basierend auf dem Mapping
    for uv_name, sensor_dict in uv_sensor_mapping.items():
        for artnr in sensor_dict.values():
            uv_to_artnr_to_sensor_count[uv_name][artnr] += 1
            
    return {
        "artnr_to_stückware_count": artnr_to_stückware_count,
        "artnr_to_meterware_length": artnr_to_meterware_length,
        "artnr_to_sensor_count": artnr_to_sensor_count,
        "uv_to_artnr_to_stückware_count": uv_to_artnr_to_stückware_count,
        "uv_to_artnr_to_meterware_length": uv_to_artnr_to_meterware_length,
        "uv_to_artnr_to_sensor_count": uv_to_artnr_to_sensor_count,
    }

def _create_cable_list_sheet(ws, plines, sens2cable, bezeichner_cfg):
    """
    Erstellt das Arbeitsblatt 'Length by ID' mit allen Kabeln und deren Längen.

    Args:
        ws (Worksheet): Das Excel-Arbeitsblatt.
        plines (Polylines): Die Polylinien- und Geometriedaten.
        sens2cable (dict): Mapping von Sensor-IDs zu Kabel-Artikelnummern.
        bezeichner_cfg (ConfigParser): Konfiguration mit Artikelnummern-Namen.
    """
    ws.title = "Length by ID"
    ws.append(["Cable-ID", "True Length (m)", "Cable-ArtNr", "Cable-Name (short)"])
    ws.column_dimensions['A'].width = 18
    ws.column_dimensions['B'].width = 15
    ws.column_dimensions['C'].width = 15
    ws.column_dimensions['D'].width = 25

    for pl in plines.kabel:
        length = round(pl.length / 1000, 1)
        for artnr in sens2cable[pl.id]:
            cable_name = bezeichner_cfg["Sivasnummern"].get(artnr, "") if artnr.isdigit() else ""
            ws.append([pl.id, length, artnr, cable_name])

def _create_cable_summary_sheet(ws, processed_data):
    """
    Erstellt das Arbeitsblatt 'Cables SIVAS' mit einer Zusammenfassung der Kabel nach Artikelnummer.

    Args:
        ws (Worksheet): Das Excel-Arbeitsblatt.
        processed_data (dict): Aggregierte Kabeldaten.
    """
    ws.title = "Cables SIVAS"
    ws.append(["Cable-ArtNr", "Amount (pcs)", "Cumm. Length (m)"])
    ws.column_dimensions['A'].width = 20
    ws.column_dimensions['B'].width = 12
    ws.column_dimensions['C'].width = 15

    count_summary = processed_data["artnr_to_stückware_count"]
    length_summary = processed_data["artnr_to_meterware_length"]
    all_artnrs = sorted(set(count_summary.keys()) | set(length_summary.keys()))

    for artnr in all_artnrs:
        ws.append([
            artnr,
            count_summary.get(artnr, ""),
            length_summary.get(artnr, "")
        ])

def _create_rack_lengths_sheet(ws, plines):
    """
    Erstellt das Arbeitsblatt 'Rack-Lengths' mit den Längen der Racks.

    Args:
        ws (Worksheet): Das Excel-Arbeitsblatt.
        plines (Polylines): Die Polylinien- und Geometriedaten.
    """
    ws.title = "Rack-Lengths"
    ws.append(["Rack-ID", "Length (m)"])
    ws.column_dimensions['A'].width = 18
    ws.column_dimensions['B'].width = 15

    for rackname, rack_geom in plines.rack_geometry.items():
        classifier = rackname[0]
        if classifier in ("t", "v", "c", "d"):
            continue
        else:
            ws.append([rackname, rack_geom.length])

def _create_error_sheets(wb, error_collector: ErrorCollector = None):
    """
    Erstellt die Arbeitsblätter für alle aufgetretenen Fehler (Equipment-Connection, Routing, Attribute).

    Args:
        wb (Workbook): Das Excel-Workbook.
        error_collector (ErrorCollector, optional): ErrorCollector mit Fehlern und Warnungen.
    """
    if not error_collector:
        return

    errors = error_collector.errors
    warnings = error_collector.warnings

    # Sheet: ERR-Equipment-Connection
    errors_sensors = errors.get('sensors_connection', [])
    errors_dists = errors.get('dists_connection', [])

    if errors_sensors or errors_dists:
        ws = wb.create_sheet("ERR-Equipment-Connection")
        ws.append(["Type", "ID", "x", "y"])
        ws.column_dimensions['A'].width = 20
        for error in errors_sensors:
            ws.append(["Sensor / Actuator", error['name'], int(error['coords']['x']), int(error['coords']['y'])])
        for error in errors_dists:
            ws.append(["Subdistributor", error['name'], int(error['coords']['x']), int(error['coords']['y'])])

    # Sheet: ERR-Routing
    errors_routing = errors.get('routing', [])
    errors_dists_not_in_layout = errors.get('dists_not_in_layout', [])

    if errors_routing:
        ws = wb.create_sheet("ERR-Routing")
        ws.append(["Subdistributor", "Sensor / Actuator", "Details"])
        ws.column_dimensions['A'].width = 20
        ws.column_dimensions['B'].width = 20
        ws.column_dimensions['C'].width = 50

        nicht_angebunden = {e['name'] for e in errors_sensors + errors_dists}

        for error in errors_routing:
            uv = error['unterverteiler']
            uv_nicht_angebunden = uv in nicht_angebunden

            if uv in errors_dists_not_in_layout:
                ws.append([uv, "-", "Distributor not found in given layout."])
                continue

            for sensor in error['sensoren']:
                sensor_nicht_angebunden = sensor in nicht_angebunden
                if sensor_nicht_angebunden and uv_nicht_angebunden:
                    grund = "Subdistributor and sensor / actuator not connected to racks"
                elif sensor_nicht_angebunden:
                    grund = "Sensor / actuator not connected to racks"
                elif uv_nicht_angebunden:
                    grund = "Subdistributor not connected to racks"
                else:
                    grund = "Failed routing (not caused by missing connection)"
                ws.append([uv, sensor, grund])

    # Sheet: ERR-Attributes
    missing_attributes = warnings.get('missing_attributes', {})

    if missing_attributes:
        ws = wb.create_sheet("ERR-Attributes")
        ws.append(["ID", "Error Detail"])
        ws.column_dimensions['B'].width = 35
        for sname, err_msg in missing_attributes.items():
            ws.append([sname, err_msg])

    # Sheet: WARNINGS (für allgemeine Warnungen wie z-Koordinaten-Abweichungen)
    general_warnings = {k: v for k, v in warnings.items() if k != 'missing_attributes'}

    if general_warnings:
        ws = wb.create_sheet("WARNINGS")
        ws.append(["Warning Type", "Details"])
        ws.column_dimensions['A'].width = 30
        ws.column_dimensions['B'].width = 80

        for warning_type, warning_msg in general_warnings.items():
            # Formatiere den Warning Type lesbarer
            readable_type = warning_type.replace('_', ' ').title()
            ws.append([readable_type, str(warning_msg)])

def _create_bom_workbook(outpath, processed_data, bezeichner_cfg):
    """
    Erstellt eine separate Excel-Arbeitsmappe für die Stückliste (BOM) und speichert sie ab.

    Args:
        outpath (str): Basis-Pfad für die Ausgabedatei.
        processed_data (dict): Aggregierte Kabel- und Sensor-Daten.
        bezeichner_cfg (ConfigParser): Konfiguration mit Artikelnummern-Namen.
    """
    wb = Workbook()
    
    # Daten aus dem verarbeiteten Dictionary extrahieren
    sensor_counts = processed_data["artnr_to_sensor_count"]
    stückware_counts = processed_data["artnr_to_stückware_count"]
    meterware_lengths = processed_data["artnr_to_meterware_length"]
    
    uv_sensor_counts = processed_data["uv_to_artnr_to_sensor_count"]
    uv_stückware_counts = processed_data["uv_to_artnr_to_stückware_count"]
    uv_meterware_lengths = processed_data["uv_to_artnr_to_meterware_length"]

    # Sheet 1: Globale BOM
    ws1 = wb.active
    ws1.title = "BOM"
    ws1.append(["Art.-Number", "Amount (pcs)", "Length (m)", "Name (SIVAS)"])
    ws1.column_dimensions['A'].width = 15
    ws1.column_dimensions['B'].width = 12
    ws1.column_dimensions['C'].width = 10
    ws1.column_dimensions['D'].width = 50

    # Sensoren auflisten
    for artnr in sorted(sensor_counts.keys()):
        ws1.append([artnr, sensor_counts[artnr], "", _get_sivas_name(artnr, bezeichner_cfg)])

    # Kabel auflisten
    all_cable_artnrs = sorted(set(stückware_counts.keys()) | set(meterware_lengths.keys()))
    for artnr in all_cable_artnrs:
        ws1.append([
            artnr,
            stückware_counts.get(artnr, ""),
            meterware_lengths.get(artnr, ""),
            _get_sivas_name(artnr, bezeichner_cfg)
        ])

    # Sheet 2: BOM nach UV
    ws2 = wb.create_sheet("BOM by UV")
    ws2.append(["UV", "Art.-Number", "Amount (pcs)", "Length (m)", "Name (SIVAS)"])
    ws2.column_dimensions['A'].width = 15
    ws2.column_dimensions['B'].width = 15
    ws2.column_dimensions['C'].width = 12
    ws2.column_dimensions['D'].width = 10
    ws2.column_dimensions['E'].width = 50
    
    all_uvs = sorted(set(uv_sensor_counts.keys()) | set(uv_stückware_counts.keys()) | set(uv_meterware_lengths.keys()))

    for uv in all_uvs:
        all_artnrs_in_uv = sorted(
            set(uv_sensor_counts.get(uv, {}).keys()) |
            set(uv_stückware_counts.get(uv, {}).keys()) |
            set(uv_meterware_lengths.get(uv, {}).keys())
        )
        for artnr in all_artnrs_in_uv:
            name = _get_sivas_name(artnr, bezeichner_cfg)
            sensor_amount = uv_sensor_counts.get(uv, {}).get(artnr, 0)
            cable_count = uv_stückware_counts.get(uv, {}).get(artnr, 0)
            cable_length = uv_meterware_lengths.get(uv, {}).get(artnr, 0)

            if sensor_amount > 0:
                ws2.append([uv, artnr, sensor_amount, "", name])
            if cable_count > 0:
                ws2.append([uv, artnr, cable_count, "", name])
            if cable_length > 0:
                ws2.append([uv, artnr, "", cable_length, name])

    bom_path = outpath.replace("_cables.xlsx", "_BOM.xlsx")
    wb.save(bom_path)
    print(f"BOM exported to Excel-file")

def copy_layers_into_dxf_by_filter(dxf_source: ezdxf.document.Drawing, dxf_target:ezdxf.document.Drawing):
    """
    Kopiert bestimmte Layer (nach Filter) von einer Quell-DXF in eine Ziel-DXF.

    Args:
        dxf_source (ezdxf.document.Drawing): Quell-DXF-Dokument.
        dxf_target (ezdxf.document.Drawing): Ziel-DXF-Dokument.
    """
    
    msp_source = dxf_source.modelspace()
    msp_target = dxf_target.modelspace()
    

    subdist_layers = set(config.options('GetPos-Layer_Distributors'))
    rack_layers = set(config.options('GetPos-Layer_Racks'))
    equipment_layers = set(config.options('GetPos-Layer_Equipment'))
    tunnel_layers = set(config.options('GetPos-Layer_Tunnel'))

    layernames = set()
    layernames.update(subdist_layers)
    layernames.update(rack_layers)
    layernames.update(equipment_layers)
    layernames.update(tunnel_layers)

    # # welche Texte existieren 
    # for layername in layernames:
    #     selectstr = f'MTEXT[layer=="{layername}"]'
    #     for text in msp_source.query(selectstr):
    #         inhalt = text.dxf.text
    #         position = text.dxf.insert
    #         print(f"Text: '{inhalt}' an Position: {position} auf Layer: {layername}")
    #         text_entity = text.copy()
    #         msp_target.add_entity(text_entity)

    layer_names_inside =  dxf_source.layers
    alle_block_defs = set(dxf_source.blocks.block_names())
    verwendete = {insert.dxf.name for insert in msp_source.query("INSERT")}

    # 1. Textstyles kopieren
    for style in dxf_source.styles:
        if style.dxf.name not in dxf_target.styles:
            dxf_target.styles.new(name=style.dxf.name)

    
        
    # 4. Filter-Layernamen bestimmen
    for layername in layernames:
        if layername not in dxf_source.layers:
            continue
        # Falls der Layer noch nicht im Zieldokument existiert, neu anlegen
        if layername not in dxf_target.layers:
            quelle_layer = dxf_source.layers.get(layername)
            dxf_target.layers.add(
                name=layername,
                color=quelle_layer.color,
                linetype=quelle_layer.dxf.linetype,
                lineweight=quelle_layer.dxf.lineweight
            )
        
        # Alle Entities auf diesem Layer kopieren
        entities = msp_source.query(f"*[layer=='{layername}']")
        for entity in entities:
            msp_target.add_entity(entity.copy())  

def get_cable_artnr(section, length):
    """
    Sucht in der angegebenen Config-Section die passende Kabel-Artikelnr. für die gegebene Länge.

    Args:
        section (str): Name der Kabel-Section in der Config.
        length (float): Kabellänge in Metern.

    Returns:
        tuple: (True, artnr) bei Erfolg, (False, Fehlermeldung) bei Fehler.
    """
    # Existiert über BMK vergebene Kabel-Kennzeichnung in kabel.cfg?
    if section not in cable_cfg:
        return False, f"Keine Kabelkennzeichnung '{section}' in kabel.cfg"

    entries = cable_cfg[section]
    length_keys = sorted([float(k) for k in entries.keys()])

    for l in length_keys:
        if length <= l:
            return True, entries[str(l)]

    return False, f"Kabel länger als max. Kabellänge in Sektion '{section}'"
           
def map_sensor_to_cable_cfg(plines):
    """
    Ordnet jedem Sensor/Kabel die passende Kabel-Artikelnummer gemäß BMK-Config und Kabel-Config zu.

    Args:
        plines (Polylines): Die Polylinien- und Geometriedaten.

    Returns:
        dict: Mapping von Kabel-IDs zu Artikelnummern.
    """
    sens2cable = defaultdict(list)

    mapping = config_BMK["Cable-Mapping"]

    for pl in plines.kabel:
        sensor_name = '-'.join(pl.id.split('-')[1:])
        cable_length = round(pl.length/1000, 1)
        sensor_artinr = pl.s_artinr
        # SPS Prefex 
        name_prefix = sensor_name[:2]

        # Suche nach Key in der BMK-Config

        key_with_artnr = f"{name_prefix}-{sensor_artinr}"   # Spezialfälle über "Key mit Artikelnummer" abgleichen
        if key_with_artnr in mapping:
            section_list = mapping[key_with_artnr]
        elif name_prefix in mapping:                        # Standardzuweisung
            section_list = mapping[name_prefix]
        else:
            sens2cable[pl.id].append("Kein Kabeltyp zugewiesen (BMK.cfg)")
            continue  # Kein Mapping gefunden, Rest überspringen

        # Liste aus evtl. mehreren Sektionen erzeugen
        sections = [s.strip() for s in section_list.split(",")]

        # Evtl. Kabelkürzung durchführen, falls Kabelschwanz vorhanden
        if config_BMK.has_section("Length-Adjustments") and config_BMK.has_option("Length-Adjustments", name_prefix):
            length_reduction = float(config_BMK.get("Length-Adjustments", name_prefix))
            cable_length = max(0.0, cable_length-length_reduction)

        # Kabel-Atikelnummer innerhalb der Sektion der kabel.cfg bestimmen
        for section in sections:
            sucess, result = get_cable_artnr(section, cable_length)
            sens2cable[pl.id].append(result)
    
    return sens2cable
        

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='draws a dxf file with the given cable coordinates', prog='drawdxf')
    parser.add_argument('-f', '--filename', action='store', required=True, help='this json file contains all cables and its coordinates which should be drawn. Saved with an unique timestamp', metavar='myfile.json')
    parser.add_argument('-d', '--dxf', action='store', help='this dxf drawing will be copied and the new layer with the cables will be added. Original file must be added with --origin', metavar='myfile.dxf')
    parser.add_argument('-n', '--new', action='store', help='create a new dxf file only with cables in it. Name is basename and a timestamp')
    parser.add_argument('-x', '--excel', action='store', help='create a xlsx file with cables data', metavar='allCables.xls')
    parser.add_argument('-o', '--origin', action='store', help='name of original .dxf file used by -d', metavar='original.dxf')
    parser.add_argument('-l', '--local', action='store_true', help='using only local data for naming of article numbers. If not set: fetching names from SIVAS.')

    args = parser.parse_args()

    config_dir = os.environ.get("PROJECT_CFG")
    work_dir = os.fspath(os.environ.get('PROJECT_WORK'))

    json_file = args.filename
    (json_path, jexists) = check_file_in_work(work_dir, json_file)
    if not jexists:
        print(f"file {json_file} does not exist")
        parser.print_help()
        exit()

    plines, error_collector = model_from_json(json_path)

    # Allgemeine Config laden
    config = configparser.ConfigParser(allow_no_value=True, delimiters=("="))
    config.optionxform = lambda option: option  # preserve case for letters
    config.read(os.path.join(config_dir, "allgemein.cfg"))

    # Config für Kabel-Artikelnummern laden
    cable_cfg = configparser.ConfigParser()
    cable_cfg.optionxform = str     #Keys case-sensitive
    with open(os.path.join(config_dir, "kabel.cfg"), encoding="utf-8") as f:
        cable_cfg.read_file(f)

    # Betriebsmittelkennzeichnungs-Config laden
    config_BMK = configparser.ConfigParser(allow_no_value=True, delimiters=("="))
    config_BMK.optionxform = lambda option: option  # preserve case for letters
    config_BMK.read(os.path.join(config_dir, "BMK.cfg"))

    bezeichner_cfg = configparser.ConfigParser()
    bezeichner_cfg.optionxform = str     #Keys case-sensitive
    with open(os.path.join(config_dir, "bezeichner.cfg"), encoding="utf-8") as f:
        bezeichner_cfg.read_file(f)


    dxf_file = args.dxf

    if args.dxf:
        (dxf_path, dexists) = check_file_in_work(work_dir, dxf_file)
        if not dexists:
            print(f"file {dxf_file} does not exist")
            parser.print_help()
            exit()
        out_path = dxf_path
        res_pos = new_dxf(plines, dxf_path)

    if args.new:
        # erzeuge dxf Datei nur mit Kabeln
        out_path = os.path.join(work_dir, args.new)
        res_pos = new_dxf(plines, out_path)

    if args.excel:
        # 1. Kabelzuordnung
        sens2cable = map_sensor_to_cable_cfg(plines)

        # 2. verwendete Artikelnummer (Sensoren + Kabel)
        all_artnrs = get_all_artnrs(plines, sens2cable)

        # 3. Fehlende Bezeichner in bezeichner.cfg markieren
        mark_missings(all_artnrs)

        # 4. Bezeichner ggf. aus SIVAS abrufen 
        if args.local == False:
            config_path=os.path.join(config_dir, "bezeichner.cfg")
            uc.update_bezeichner(bezeichner_cfg, config_path)
        
        # 5. Excel schreiben
        excel_path = os.path.join(work_dir, args.excel)
        write_excel_from_json(plines, sens2cable, excel_path, error_collector)

        # 6. Optionale Fehlerdatei schreiben
        if error_collector.has_errors():
            basename = Path(args.excel).stem
            error_filename = f"{basename}_errors.json"
            error_collector.write_errorfile(Path(work_dir), error_filename)