import argparse
import configparser
import json
import os
import re
import sys
from pathlib import Path

from shapely.geometry import Point

from error_collector import ErrorCollector
from utils import (
    check_environment_var,
    check_file_in_work,
    get_dxf_file,
    merge_two_dicts,
    to_json,
    write_results,
    extract_attributes_with_positions as attribs_to_dicts,
)


"""
Dieses Programm:
- liest die dxf Datei und holt sich von den Layern der dxf Datei die Positionen
    + der Motoren, Sensoren und Aktoren
    + der Unterverteiler
    + der Polylinien der Kabelpritschen
- erzeugt daraus eine .json Datei im Work Ordner
"""


def get_type_of_name_cfg(name: str) -> str:
    prefix = name[:2]

    if config_BMK.has_option("Routing-Include", prefix):
        return "Sensor"
    elif config_BMK.has_option("Routing-Ignore", prefix):
        return "Schaltschrankelement"
    else:
        return "unknown"


def matches_cabinet_pattern(name: str) -> [bool, str]:
    """Check if the given name matches any Cabinet-Pattern from BMK.cfg"""
    if not config_BMK.has_section("Cabinet-Pattern"):
        return (False, "")
    
    patterns = [pattern for pattern, _ in config_BMK.items("Cabinet-Pattern")]
    
    for pattern in patterns:

        m = re.search(pattern, name)
        if m:
            res = m.group(1)
            return (True, res)
    
    return (False, "")


def matches_tunnel_pattern(name: str) -> bool:
    """Check if the given name matches any Tunnel-Pattern from BMK.cfg"""
    if not config_BMK.has_section("Tunnel-Pattern"):
        return False
    
    patterns = [pattern for pattern, _ in config_BMK.items("Tunnel-Pattern")]
    
    for pattern in patterns:
        m = re.search(pattern, name)
        if m:
            return True
    
    return False


def get_attributes_of_insert(d_insert: dict, d_pos: dict) -> tuple[dict, str, str]:
    """
    Diese Funktion schaut nach den aktuell definierten Attributen in den Blöcken
    
    Bei den Sensoren in den alten Layouts gibt zwei immer übereinanderliegende Blöcke mit den Attributen: 
    - A, B (z.B. MA0062), C, ARTINR (z.B. 790902001), BESCHR (E-Teile für SEW Motor ASE1..), MENGE, POSITION, ...
    - IO (z.B. MA0062), ID , VERW (z.B. CV-M0062_0,75), BEZEICHNUNG (Motor MA0062), KENNZEICHNUNG (z.B.=A01+UH01-KF1DQ04), ...
    Die Adresse für das Routing kommt aus "KENNZEICHNUNG", während die Sivas Nummer aus "ARTINR" geholt werden muss
    Einmal wird B zur ID, beim anderen IO

    Erdungssymbole erhalten die ID aus dem Eintrag unter "NAME"

    Hier in Zukunft weniger Abfragen: IO und B und Reale_Position wird überflüssig wenn jeder Sensor nur noch ein Block mit allen Attributen!

    """
    id_ = ""
    ld = d_insert
    typ = 'unknown'

    # die neueren Bläcke heissen nicht IO, sondern haben einen Namen
    if "IO" in d_insert:
        pos = d_pos["IO"]
        typ = get_type_of_name_cfg(d_insert["IO"])

        # neuer Block: Enthält alles was nötig is
        if "ARTINR" in d_insert and "SPS" in d_insert:
            id_ = d_insert["IO"]+"@"+d_insert["SPS"]
            ld["pos"] = (pos[0], pos[1])
            
            if "REALE_POSITION" in d_insert and d_insert["REALE_POSITION"] == 'x':
                pos = d_pos["REALE_POSITION"]
               
        else:

            id_ = d_insert["IO"]
            # Sensoren werden später gemerged mit den anderen Blöcken des Rahmens mit A,B,C, usw
            if "SPS" in d_insert and typ != "Sensor":
                id_ = id_+"@"+d_insert["SPS"] 

            if "REALE_POSITION" in d_insert and d_insert["REALE_POSITION"] == 'x':
                pos = d_pos["REALE_POSITION"]

                # Hoehe und Breite von "x" addieren, um Mittelpunkt zu finden
                breite_marker = config.getfloat("GetPos-Geom-Sensor", "Breite")
                hoehe_marker = config.getfloat("GetPos-Geom-Sensor", "Hoehe")
                midx = pos[0] + breite_marker * 0.5
                midy = pos[1] + hoehe_marker * 0.5
                ld["pos"] = (round(midx, 1), round(midy, 1))
            else:
                ld["pos"] = (pos[0], pos[1])

    # die neueren Blöcke heissen nicht IO, sondern haben einen Namen
    if "NAME" in d_insert:
        typ = get_type_of_name_cfg(d_insert["NAME"])
        id_ = d_insert["NAME"]
        pos = d_pos["NAME"]
        ld["pos"] = (pos[0], pos[1])

    if "B" in d_insert and "IO" not in d_insert:
        attr_text = d_insert["B"]
        typ = get_type_of_name_cfg(attr_text)
        id_ = attr_text
        #Position aufzeichnen und bei Bedarf später mit REAL_POS überschreiben
        pos = d_pos["B"]
        ld["pos"] = (pos[0], pos[1])
  
    return ld, id_, typ
    

class CompareBuffer:
    """Ein Puffer um alle als Doppelt zugewiesenen Blöcke zwischenzulagern."""
    def __init__(self) -> None:
        self._wartepuffer = dict()

    def add_block(self, id_: str, buffer: dict) -> None:
        """Adds one block under the buffer list under this id."""
        if id_ not in self._wartepuffer:
            self._wartepuffer[id_] = list()
        self._wartepuffer[id_].append(buffer)

    def get_blocks(self, id_: str) -> list:
        if id_ in self._wartepuffer:
            return self._wartepuffer[id_]
        else:
            return []
    
    def get_block_ids(self) -> list[str]: 
        return list(self._wartepuffer.keys())


    def set_block(self, id_: str, buffer: list) -> None:
        if id_ in self._wartepuffer:
            del self._wartepuffer[id_]
        self._wartepuffer[id_] = buffer

    def dict_compare(self, d1: dict, d2: dict) -> bool:
        str1 =  json.dumps(d1) 
        str2 =  json.dumps(d2) 
        return str1 == str2
    
    def remove_block(self, id_: str, to_remove: dict) -> None:
        """Removes this block from the list under the given id."""
        buffers = self.get_blocks(id_)
        l = list()
        for b in buffers:
            if self.dict_compare(b, to_remove):
                pass
            else:
                l.append(b)
        self.set_block(id_, l)
    
    def positions_are_close(self, dict1: dict, dict2: dict, border: float) -> bool:
        pos1 = None
        pos2 = None
        if "pos" in dict1:
            pos1 = dict1["pos"]
        if "pos" in dict2:
            pos2 = dict2["pos"]
        if not (pos1 and pos2):
            return False
        p1 = Point(pos1[0], pos1[1]) 
        p2 = Point(pos2[0], pos2[1] )
        dist = p1.distance(p2)
        if dist < border:
            return True
        return False
           
    def get_all_sps_blocks(self, id_: str) -> list:
        """Gives back all blocks with SPS inside to the given id."""
        buffers = self.get_blocks(id_)
        l = list()
        for b in buffers:
            if "SPS" in b:
                l.append(b)
        return l

    def get_non_sps_blocks(self, id_: str) -> list:
        """Gives back all blocks without SPS inside to the given id."""
        buffers = self.get_blocks(id_)
        l = list()
        for b in buffers:
            if "SPS" not in b:
                l.append(b)
        return l

def extract_input_positions(all_inserts, all_positions, error_collector: ErrorCollector = None) -> tuple[dict, dict]:
    """
    Extracts and organizes input positions from an iterable of inserts.

    This function processes a list of insert objects (e.g., from a DXF file), classifies them by type
    (Sensor, Kabel, Schaltschrankelement, or unknown), and organizes them into dictionaries keyed by their IDs.
    For sensors, it handles the special case where sensor information may be split across multiple blocks
    (e.g., IO and A,B,C blocks) and merges them if necessary. It also collects information about missing
    attributes and duplicate IDs for further error handling.

    Args:
        all_inserts: List of insert objects to process.
        all_positions: List of position objects corresponding to inserts.
        error_collector: ErrorCollector instance to collect errors and warnings.

    Returns:
        A tuple containing:
            - all_sensors: dict of sensor IDs to sensor attribute dicts
            - all_schaltschrank: dict of Schaltschrankelement IDs to their attribute dicts
    """
    all_sensors = dict()
    all_cables = dict()
    all_schaltschrank = dict()
    all_unknowns = list()

    wp = CompareBuffer()

    for insert, pos in zip(all_inserts, all_positions):
        ld, id_, typ = get_attributes_of_insert(insert, pos)

        if typ == "Sensor":
            # wenn NAME enthalten ist, z.B. bei Erdungslayouts, dann ist das ein eindeutiger Bezeichner
            if "NAME" in ld:
                all_sensors[id_] = ld
            # neuer Block der alle nötigen Infos enthält
            elif "IO" in ld and "ARTINR" in ld :
                all_sensors[id_] = ld
            # alle anderen Sachen werden dann aus mehreren Rahmen zusammen gesammelt
            else:
                wp.add_block(id_, ld)

        elif typ == "Kabel":
            all_cables[id_] = ld
        elif typ == "Schaltschrankelement":
            all_schaltschrank[id_] = ld
        else:
            all_unknowns.append(ld) 

    # spezialbehandlung der Sensoren, da diese in IO und A,B,C Blöcke geteilt sind
    # Die Funktion sucht die übereinanderliegenen Elemente und baut ein Dict daraus 
    allocate_blocks_together(all_sensors, wp, error_collector)

    set_single_frames_with_unique_sps(all_schaltschrank, wp)

    # die noch übrigen Blöcke melden
    get_errors_double_and_attributes(wp, error_collector)

    return all_sensors, all_schaltschrank

def get_errors_double_and_attributes(wp: CompareBuffer, error_collector: ErrorCollector = None):
    """
    Analyze the CompareBuffer for blocks that could not be merged or assigned.

    This function inspects the CompareBuffer for:
      - IDs with only a single associated block, which likely indicates missing or incomplete attributes.
      - IDs with multiple associated blocks, which may indicate duplicate or ambiguous entries.

    Args:
        wp: CompareBuffer to analyze
        error_collector: ErrorCollector instance to collect errors and warnings
    """
    missing_attribs = dict()
    double_ids = dict()
    for id_ in wp.get_block_ids():
        blocks = wp.get_blocks(id_)
        # einzelne Blöcke deren Zuordnung nicht gelungen ist, fehlen Angaben in den Attributen, z.B. SPS, B, u.ä.
        if len(blocks) == 1:
            given_keys = str(blocks[0].keys())
            missing_attribs[id_] = f"Nur ein Block und/oder fehlende Attribute: {given_keys}"
            wp.remove_block(id_, blocks[0])
        # alle anderen Angaben sind mindestens zwei oder mehr Blöcke mit derselben Id
        else:
            for block in blocks:
                if id_ not in double_ids:
                    double_ids[id_] = []
                double_ids[id_].append(block['pos'])

    # Füge Fehler und Warnungen zum ErrorCollector hinzu, falls vorhanden
    if error_collector:
        if double_ids:
            error_collector.add_errors({"double_ids": double_ids})
        if missing_attribs:
            error_collector.add_warnings({"missing_attributes": missing_attribs})

def set_single_frames_with_unique_sps(all_sensors: dict, wp: CompareBuffer):
    all_sensors_ids = wp.get_block_ids() 
    for id_ in all_sensors_ids:
        blks_sps = wp.get_all_sps_blocks(id_) 

        for block_with_sps in blks_sps:
            # schaue ob es keinen Konflikt zu bestehenden Angaben
            if "IO" in block_with_sps and "SPS" in block_with_sps:
                sps_praefix = block_with_sps["SPS"]
                new_id = f"{id_}@{sps_praefix}"

                all_sensors[new_id] = block_with_sps
                wp.remove_block(id_, block_with_sps)

def allocate_blocks_together(all_sensors: dict, wp: CompareBuffer, error_collector: ErrorCollector = None) -> None:
    """
    Merge sensor blocks with the same ID that are split across multiple DXF blocks.

    This function iterates over all sensor IDs stored in the CompareBuffer. For each ID, it looks for blocks
    with and without an "SPS" prefix. If two such blocks have positions that are close to each other (within a
    specified tolerance), they are considered to represent the same physical sensor. The function then merges
    their information into a single dictionary, creates a new unique sensor ID by appending the SPS prefix,
    and adds the merged sensor to the all_sensors dictionary. The merged blocks are then removed from the buffer.

    Args:
        all_sensors (dict): Dictionary to store merged sensor information, keyed by unique sensor IDs.
        wp (CompareBuffer): Buffer containing blocks that could not be directly assigned, grouped by ID.
    """
    #geht alle gemerkten Sensoren durch die gleich heissen. 
    # Falls ein SPS Präfix angegeben wird, wird es zur Id hinzugefügt und als neuer Name gemerkt
    all_sensors_ids = wp.get_block_ids() 
    for id_ in all_sensors_ids:
        blks_sps = wp.get_all_sps_blocks(id_) 
        blks_other = wp.get_non_sps_blocks(id_)

        for block_with_sps in blks_sps:
            for block_without_sps in blks_other:
                # Vergleiche alle Blöcke mit SPS und denen ohne auf die gleiche Position
                if wp.positions_are_close(block_with_sps, block_without_sps, 1000):
                    new_id = create_new_id(id_, block_with_sps, block_without_sps, error_collector) # hier das Präfix davor
                    all_sensors[new_id] = merge_two_dicts(block_without_sps, block_with_sps) #Kombiniert alle infos aus dxf und "pos"
                    wp.remove_block(id_, block_with_sps)
                    wp.remove_block(id_, block_without_sps)
    
def create_new_id(id_: str, dict1: dict, dict2: dict, error_collector: ErrorCollector = None) -> str:
    sps_praefix = None
    if "SPS" in dict1:
        sps_praefix = dict1["SPS"]
    if "SPS" in dict2:
        sps_praefix = dict2["SPS"]
    if not sps_praefix:
        if error_collector:
            error_collector.add_errors({"missing_sps_prefix": {id_: "SPS Präfix fehlt für Block-ID"}})
        return f"{id_}@UNKNOWN"  # Fallback für fehlenden SPS-Präfix
    return f"{id_}@{sps_praefix}"

def create_mappings(positions: dict) -> tuple[dict, dict]:
    unterverteiler_pfad = ""
    dnamen = dict()
    # s 
    sensor2unterverteiler = dict()
    warnings = dict()
    for sensorname,v in positions.items():
        if "KENNZEICHNUNG" not in v:
            warnings[sensorname] = "keine KENNZEICHNUNG vorhanden"
            continue

        unterverteiler_pfad = v["KENNZEICHNUNG"]
        #print(unterverteiler_pfad)

        # Pfad zur Karte splitten. Dieser hat z.B. den Inhalt "=AH01+UH02-KF1FDI7" 
        matches = re.findall(r'[^\-+=]+', unterverteiler_pfad.lstrip('='))
        if matches:
            if len(matches) == 3:
                anlage = matches[0]
                verteiler = matches[1]
                karte = matches[2]
            else:
                warnings[sensorname] = f"Ungültiger Pfad in Kennzeichnung: {unterverteiler_pfad}: +, - oder = fehlen an entsprechender Stelle, keine drei Teile sichtbar"
                continue
        else:
            warnings[sensorname] = f"Ungültiger Pfad in Kennzeichnung: {unterverteiler_pfad}"
            continue

        if  verteiler not in dnamen:
            dnamen[verteiler] = True

        sensor2unterverteiler[sensorname] = verteiler

    # jetzt zu jedem  Unterverteiler die zugehörigen Sensoren merken
    uv2sensor = dict()
    for sensorname,verteiler in sensor2unterverteiler.items():
        if verteiler not in uv2sensor:
            uv2sensor[verteiler] = list()
        uv2sensor[verteiler].append(sensorname)  

    return (uv2sensor, warnings)

def get_subdistributor_position_of_symbol(d_insert: dict, d_pos: dict) -> tuple[dict, str]:
    """
    Diese Funktion schaut nach den aktuell definierten Attributen in allen Unterverteiler Blöcken
    Sie müssen auch UH heissen

    """
    id_ = ""
    ld = d_insert

    # die neueren Blöcke haben einen Namen
    if "NAME" in d_insert:
        id_ = d_insert["NAME"]
        pos = d_pos["NAME"]
        ld["pos"] = (pos[0], pos[1])

        if "REALE_POSITION" in d_insert and d_insert["REALE_POSITION"] == 'x':
            pos = d_pos["REALE_POSITION"]

            # Hoehe und Breite von "x" addieren, um Mittelpunkt zu finden
            breite_marker = config.getfloat("GetPos-Geom-Sensor", "Breite")
            hoehe_marker = config.getfloat("GetPos-Geom-Sensor", "Hoehe")
            midx = pos[0] + breite_marker * 0.5
            midy = pos[1] + hoehe_marker * 0.5
            ld["pos"] = (round(midx, 1), round(midy, 1))
        return ld, id_
    return None, None


def get_tunnel_position_of_symbol(d_insert: dict, d_pos: dict) -> tuple[dict, str]:
    """
    Diese Funktion schaut nach den aktuell definierten Attributen in allen Tunnel Blöcken
    Tunnel haben einen Namen der den Tunnel-Mustern aus BMK.cfg entspricht

    """
    id_ = ""
    ld = d_insert

    # Tunnel haben einen Namen
    if "NAME" in d_insert:
        id_ = d_insert["NAME"]
        pos = d_pos["NAME"]
        ld["pos"] = (pos[0], pos[1])

        # Länge des Tunnels aus dem LAENGE Attribut extrahieren
        if "LAENGE" in d_insert:
            ld["laenge"] = d_insert["LAENGE"]

        if "REALE_POSITION" in d_insert and d_insert["REALE_POSITION"] == 'x':
            pos = d_pos["REALE_POSITION"]

            # Hoehe und Breite von "x" addieren, um Mittelpunkt zu finden
            breite_marker = config.getfloat("GetPos-Geom-Sensor", "Breite")
            hoehe_marker = config.getfloat("GetPos-Geom-Sensor", "Hoehe")
            midx = pos[0] + breite_marker * 0.5
            midy = pos[1] + hoehe_marker * 0.5
            ld["pos"] = (round(midx, 1), round(midy, 1))
        return ld, id_
    return None, None

def get_subdistributor_positions_from_symbols(all_inserts:list, all_positions:list, dist2sensors: dict) -> dict:
    """Ermittelt Unterverteiler-Positionen aus allen Symbolen
    """
    ret = {}
    all_distributors = dist2sensors.keys()
    for insert, pos in zip(all_inserts, all_positions):
        if "NAME" not in insert: # Unterverteiler haben immer einen eindeutigen Namen
            continue
        ld, id_ = get_subdistributor_position_of_symbol(insert, pos)

        # Check if id_ matches Cabinet-Pattern from BMK.cfg
        (matches, res) = matches_cabinet_pattern(id_)
        if not matches:
            continue

        if res in dist2sensors:
            ret[res] = ld["pos"]
    
    return ret


def get_tunnel_positions_from_symbols(all_inserts: list, all_positions: list, error_collector: ErrorCollector = None) -> dict:
    """Ermittelt Tunnel-Positionen aus allen Symbolen

    Args:
        all_inserts: Liste von Dictionaries mit Attribut-Tags und deren Textwerten
        all_positions: Liste von Dictionaries mit Attribut-Tags und deren (x, y, z)-Positionen
        error_collector: ErrorCollector instance to collect errors and warnings

    Returns:
        Dictionary mit Tunnelnamen als Keys und Listen von Positionen als Values
        Format: {'Tunnel1': [(x1, y1), (x2, y2)], ..., 'length': {'Tunnel1': 'laenge_wert', ...}}
    """
    ret = {}
    tunnel_length = {}
    tunnel_missing_length = {}

    for insert, pos in zip(all_inserts, all_positions):
        if "NAME" not in insert:  # Tunnel haben immer einen eindeutigen Namen
            continue

        ld, id_ = get_tunnel_position_of_symbol(insert, pos)
        if not ld or not id_:
            continue

        # Check if id_ matches Tunnel-Pattern from BMK.cfg
        if not matches_tunnel_pattern(id_):
            continue

        # Sammle alle Positionen für den gleichen Tunnelnamen
        if id_ not in ret:
            ret[id_] = []
        ret[id_].append(ld["pos"])

        # Sammle Längeninformation falls vorhanden, sonst Fehler melden
        if "laenge" in ld and ld["laenge"]:
            tunnel_length[id_] = ld["laenge"]
        else:
            error_msg = f"Tunnel '{id_}' hat keine LAENGE-Angabe. Bitte LAENGE-Attribut im DXF-Symbol setzen."
            print(f"FEHLER: {error_msg}")
            tunnel_missing_length[id_] = error_msg

    # Fehlende LAENGE als Fehler melden (stoppt die Verarbeitung)
    if error_collector and tunnel_missing_length:
        error_collector.add_errors({"tunnel_missing_length": tunnel_missing_length})

    # Füge Längeninformation hinzu, falls Tunnel gefunden wurden
    if len(tunnel_length) > 0:
        ret['length'] = tunnel_length

    return ret


def get_subdistributor_positions_from_entities(entities, dist2sensors: dict) -> dict:
    """Ermittelt Unterverteiler-Positionen aus einer beliebigen Entity-Iterable.

    Erwartet eine Iterable von DXF-Entities (z. B. aus `msp.query("MTEXT")`)
    und filtert nach den in der Config erlaubten Layern. Es werden beide bisher
    verwendeten Suchmuster in der MTEXT-Zeile unterstützt ("-<distname>" und
    "+<distname>").
    """
    ret = {}
    all_distributors = dist2sensors.keys()
    allowed_layers = {layer for (layer, _) in config.items('GetPos-Layer_Distributors')}

    for entity in entities:
        if entity.dxftype() != "MTEXT":
            continue
        layer = entity.dxf.layer
        if layer not in allowed_layers:
            continue
        text = entity.dxf.text
        insert_point = entity.dxf.insert

        for distname in all_distributors:
            if f"-{distname}" in text or f"+{distname}" in text:
                ret[distname] = (round(insert_point[0], 1), round(insert_point[1], 1))

    return ret

def get_tunnel_positions_from_entities(entities) -> dict:
    """Ermittelt Tunnel-Ein/Ausgangs-Positionen aus einer beliebigen Entity-Iterable.

    Erwartet eine Iterable von DXF-Entities (z. B. aus `msp.query("MTEXT")`)
    und filtert nach den in der Config erlaubten Layern. Erkennt Tunnel anhand
    des Musters "TUNNEL<nr>-<laenge>" und sammelt pro Tunnelname die gefundenen
    Positionen sowie die Länge.
    """
    all_tunnels = dict()
    tunnel_length = dict()
    allowed_layers = {layer for (layer, _) in config.items('GetPos-Layer_Tunnel')}

    for entity in entities:
        if entity.dxftype() != "MTEXT":
            continue
        if entity.dxf.layer not in allowed_layers:
            continue

        txt = entity.dxf.text
        insert = entity.dxf.insert

        pattern = r"(TUNNEL\d+)-(\d+)"
        match = re.search(pattern, txt)
        if not match:
            continue

        tunnelname = match.group(1)
        laenge = match.group(2)
        pos = (round(insert[0], 1), round(insert[1], 1))

        if tunnelname not in all_tunnels:
            all_tunnels[tunnelname] = []
        all_tunnels[tunnelname].append(pos)
        tunnel_length[tunnelname] = laenge

    if len(tunnel_length.keys()) > 0:
        all_tunnels['length'] = tunnel_length
    return all_tunnels


def get_rack_positions(msp) -> dict:
    """Hole alle Positionen aller Kabelpritschen und nummeriere Racks."""
    ret = dict()
    rack_counter = 1
    all_layers = list(config.items('GetPos-Layer_Racks'))

    for layer, _ in all_layers:
        lw_query = f'LWPOLYLINE[layer=="{layer}"]'
        for entity in msp.query(lw_query):
            rack_key = f"Rack_{rack_counter}"
            handle_lwpolyline(entity, rack_key, ret)
            rack_counter += 1

        pl_query = f'POLYLINE[layer=="{layer}"]'
        for entity in msp.query(pl_query):
            rack_key = f"Rack_{rack_counter}"
            handle_polyline(entity, rack_key, ret)
            rack_counter += 1

    return ret


def handle_lwpolyline(entity, rack_key: str, ret: dict) -> None:
    """Verarbeitet eine 2D LWPOLYLINE mit globalem Z-Wert (elevation)."""
    z = getattr(entity.dxf, "elevation", 0.0)
    ret[rack_key] = []
    for point in entity.vertices():
        x, y, *_ = point
        ret[rack_key].append([round(x, 1), round(y, 1), round(z, 1)])

def handle_polyline(entity, rack_key: str, ret: dict) -> None:
    """Verarbeitet eine klassische POLYLINE – inklusive 3D-Polylinien mit individuellen Z-Werten."""
    ret[rack_key] = []
    for vertex in entity.vertices:
        x = vertex.dxf.location.x
        y = vertex.dxf.location.y
        z = vertex.dxf.location.z
        ret[rack_key].append([round(x, 1), round(y, 1), round(z, 1)])


def check_rack_z_coordinates(res_racks: dict, error_collector, config) -> None:
    """
    Prüft die z-Koordinaten aller Racks und gibt eine Warnung aus,
    wenn die Differenz zwischen min und max größer als der konfigurierte Schwellwert ist.

    Args:
        res_racks: Dictionary mit Rack-Daten (key: rack_name, value: list of coordinates)
        error_collector: ErrorCollector Instanz zum Hinzufügen von Warnungen
        config: ConfigParser Objekt mit allgemein.cfg
    """
    if not res_racks:
        return

    # Lese Schwellwert aus Config (Standard: 2000.0 mm)
    max_height_diff = config.getfloat("Racks", "MaximalTotalHeightDifferences", fallback=2000.0)

    all_z_coords = []

    # Sammle alle z-Koordinaten aus allen Racks
    for rack_name, coordinates in res_racks.items():
        if isinstance(coordinates, list):
            for coord in coordinates:
                if isinstance(coord, (list, tuple)) and len(coord) >= 3:
                    all_z_coords.append(coord[2])
                elif isinstance(coord, dict) and 'z' in coord:
                    all_z_coords.append(coord['z'])

    if not all_z_coords:
        return

    # Berechne Min und Max
    min_z = min(all_z_coords)
    max_z = max(all_z_coords)
    diff = max_z - min_z

    # Prüfe, ob Differenz > Schwellwert
    if diff > max_height_diff:
        warning_msg = f"The z-coordinates of the racks differ strong between each other from {min_z:.1f} to {max_z:.1f}"
        error_collector.add_warnings({"rack_z_coordinate_deviation": warning_msg})
        #print(f"WARNING: {warning_msg}")

def check_existance(res_mappings: dict, res_dist: dict, res_pos: dict, res_tunnel: dict) -> dict:
    ret = dict()

    for dname in res_mappings.keys():
        if dname not in res_dist:
            if "missing_distributors" not in ret:
                ret["missing_distributors"] = list()
            ret["missing_distributors"].append(dname)
    for sname, lofsensors in res_mappings.items():
        for s in lofsensors:
            if s not in res_pos:
                if "missing_sensors" not in ret:
                    ret["missing_sensors"] = list()
                ret['missing_sensors'].append(s)
    for tname in res_tunnel.keys():
        if tname == "length": # dict enthält auch die Länge aller Tunnels in einem Key
            continue
        if len(res_tunnel[tname]) < 2 :
            if "missing_tunnel" not in ret:
                ret["missing_tunnel"] = list()
            ret["missing_tunnel"].append(tname)
        if len(res_tunnel[tname]) > 2 :
            if "overdefined_tunnel" not in ret:
                ret["overdefined_tunnel"] = list()
            ret["overdefined_tunnel"].append(tname)
    return ret

def validate_configs() -> None:
    errors = []

    print("\nValidating given configs: Checking for inconsistency.")

    if config_BMK.has_section("Routing-Include") and config_BMK.has_section("Cable-Mapping"):
        for prefix in config_BMK.options("Routing-Include"):
            if prefix not in config_BMK["Cable-Mapping"]:
                errors.append(f"No Cable-Mapping for Prefix '{prefix}' within 'Routing-Include'")

    if config_BMK.has_section("Cable-Mapping"):
        for mapping_key, value in config_BMK.items("Cable-Mapping"):
            sections = [s.strip() for s in value.split(",")]
            for section in sections:
                if not config_cables.has_section(section):
                    errors.append(f"Cable-Section '{section}' from Cable-Mapping ({mapping_key}) missing in kabel.cfg")

    if config_BMK.has_section("Length-Adjustments"):
        for prefix, value in config_BMK.items("Length-Adjustments"):
            try:
                f = float(value)
                if f < 0:
                    errors.append(f"Negative Value in Length-Adjustments for {prefix}: {value}")
            except ValueError:
                errors.append(f"Invalid Value in Length-Adjustments for {prefix}: {value}")

    if errors:
        print("Inconsistencies found:")
        for e in errors:
            print(f"- {e}")
        print("\ncontinuing with routing process")
    else:
        print("No inconsistencies found. Continuing with routing process.")

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='fetches the x/y positions from a dxf file', prog='getpositions')
    parser.add_argument('-f', '--filename', action='store', required=True, default="ST_6300_Steuerungstestlayout1_neueBloecke.dwg", help='which file should be fetched', metavar='myfile.dxf')
    parser.add_argument('-s', '--sensors', action='store_true', help='fetch all position of sensors, motors, actors and subdistributors')
    parser.add_argument('-r', '--rack', action='store_true', help='fetch all positions of all cable racks')
    parser.add_argument('-w', '--write', action='store', help='write results into a json file')
    parser.add_argument('-c', '--console', action='store_true', help='print results to output')
    parser.add_argument('-e', '--errors', action='store', help='write an error file in case of double defined items in layout')
    parser.add_argument('-n', '--scan', action='store_true', help='print all layer of racs, distributes and equiment not empty')

    args = parser.parse_args()

    out_dir = check_environment_var('PROJECT_DATA')
    work_dir = check_environment_var('PROJECT_WORK')
    config_dir = check_environment_var("PROJECT_CFG")
    
    filename = Path(args.filename)
    if not filename.suffix == ".dxf":
        print("only available for .dxf files")
        exit()

    (dxf_path, dexists) = check_file_in_work(work_dir, filename)
    if dexists == False:
        print("no such file ")
        parser.print_help()
        exit()

    doc = get_dxf_file(dxf_path)
    msp = doc.modelspace()

    res_sens = dict()
    res_dist = dict()
    res_rac = dict()
    res_mappings = dict()

    if args.sensors or args.dists or args.rack:
        
        # Allgemeine Config Laden
        config = configparser.ConfigParser(allow_no_value=True, delimiters=("="))
        config.optionxform = lambda optionstr: optionstr  # preserve case for letters
        config.read(os.path.join(config_dir, "allgemein.cfg"))

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

        # Kabel-Config laden
        config_cables = configparser.ConfigParser(allow_no_value=True, delimiters=("="))
        config_cables.optionxform = lambda optionstr: optionstr  
        config_cables.read(os.path.join(config_dir, "kabel.cfg"))

        validate_configs()
       
        output_results = dict()

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

        if args.sensors:
            # Sensoren auslesen aus den Symbolen

            all_inserts, all_positions = attribs_to_dicts(msp)
            res_sens, res_schaltschrank_elemente = extract_input_positions(all_inserts, all_positions, error_collector)

            output_results['sensors'] = res_sens
            output_results['schaltschrank_elemente'] = res_schaltschrank_elemente

            if args.console:
                print(to_json(res_sens))

            # Mapping zu Sensoren auslesen
            (res_mappings, warnings) = create_mappings(res_sens)
            output_results['mappings'] = res_mappings
            # Mapping-Warnungen zum ErrorCollector hinzufügen
            if warnings:
                error_collector.add_warnings({"mapping_warnings": warnings})
            if args.console:
                print(to_json(res_mappings))

            # Distributoren auslesen (generisch über Entities)
            entities = msp.query('MTEXT')
            # die Infos aus den Texten (alter Stil)
            res_dist = get_subdistributor_positions_from_entities(entities, res_mappings)   
            # die Infos aus den Blöcken
            symbol_dists = get_subdistributor_positions_from_symbols(all_inserts, all_positions, res_mappings) 
            res_dist = merge_two_dicts(res_dist, symbol_dists)
            output_results['distributors'] = res_dist
            if args.console:
                print(to_json(res_dist))

            # Tunnel auslesen (generisch über Entities)
            t_entities = msp.query('MTEXT')
            # die Infos aus den Texten (alter Stil)
            res_tunnel = get_tunnel_positions_from_entities(t_entities)
            # die Infos aus den Blöcken (neuer Stil)
            symbol_tunnels = get_tunnel_positions_from_symbols(all_inserts, all_positions, error_collector)
            res_tunnel = merge_two_dicts(res_tunnel, symbol_tunnels)
            output_results['tunnels'] = res_tunnel
            if args.console:
                print(to_json(res_tunnel))

        if args.rack:
            res_rac = get_rack_positions(msp)

            output_results['racks'] = res_rac

            # Prüfe z-Koordinaten der Racks auf starke Abweichungen
            check_rack_z_coordinates(res_rac, error_collector, config)

            if args.console:
                print(to_json(res_rac))


        if args.write:
            basename = os.path.splitext(args.write)[0]

            res_not_found = check_existance(res_mappings, res_dist, res_sens, res_tunnel)

            # Alle weiteren Fehler zum ErrorCollector hinzufügen
            error_collector.add_errors(res_not_found)

            # Warnings immer in output_results schreiben (auch wenn leer)
            output_results["warnings"] = error_collector.warnings if error_collector.warnings else {}

            # ErrorCollector schreibt Datei nur wenn Fehler/Warnungen vorhanden
            if args.errors:
                error_collector.write_errorfile(work_dir, args.errors)

            # Für Kompatibilität mit Ausgabe-JSON: Fehler und Warnungen aus ErrorCollector holen
            all_issues = error_collector.get_all_issues()
            if "errors" in all_issues:
                output_results["not_found"] = all_issues["errors"].get("not_found", {})
                if "double_ids" in all_issues["errors"]:
                    output_results["double_ids"] = all_issues["errors"]["double_ids"]
            

            write_results(to_json(output_results), work_dir, f"{basename}.json")

    else:
        parser.print_help()
