# CRBX-Converter · v3.2 (01-07-2025)
"""
Windows-Desktop-Tool (PyQt5) zum lokalen Umwandeln von *.crbx* / SIA 451 / E1S / 01S
in strukturierte Excel-Listen inkl. Preisspiegel.

• Robustes E1S/01S-Parsing (Whitespace / Semikolon / Fixed-Width, mit führenden Nummern wie "000 P …")
• XML (IfA18/SIA 451) mit oder ohne Namespace
• Bereits konvertierte .xlsx mit Mapping (Header in Zeile 3)
• Drag & Drop + Dateidialog
• Ausgabe im selben Ordner; Versions-Hochzählen bei Kollision
• Preisspiegel je NPK (Aggregation)
"""

import re
import sys
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Dict, List, Optional

import pandas as pd
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QPixmap, QBrush, QPalette, QDragEnterEvent, QDropEvent
from PyQt5.QtWidgets import (
    QApplication, QMainWindow, QWidget, QVBoxLayout,
    QLabel, QPushButton, QFileDialog, QMessageBox, QStatusBar
)

# ──────────────────────────────────────────────────────────────────────
# Hilfsfunktionen
# ──────────────────────────────────────────────────────────────────────

def safe_out_path(src: Path, suffix: str) -> Path:
    """Erzeuge eindeutigen Ausgabepfad im gleichen Ordner wie src."""
    if src.suffix:  # Datei
        base = src.with_suffix("")
        folder = src.parent
        candidate = folder / (base.name + suffix)
    else:  # Basisname ohne Suffix
        folder = src.parent
        candidate = (folder / (src.name + suffix)).with_suffix(".xlsx")
    i = 2
    while candidate.exists():
        stem, ext = candidate.stem, candidate.suffix
        if "_v" in stem:
            stem = stem[:stem.rfind("_v")]
        candidate = candidate.with_name(f"{stem}_v{i}{ext}")
        i += 1
    return candidate


def _to_number(txt: str | None) -> float:
    """Normalisiert CH/DE/FR-Zahlen (Apostroph-Tausender, NBSP, Dezimal-Komma)."""
    if txt is None:
        return 0.0
    s = (
        str(txt)
        .replace("\u00A0", " ")  # NBSP
        .replace("\u202F", " ")  # NNBSP
        .replace("'", "")         # Tausendertrenner
        .replace(" ", "")
        .strip()
    )
    if s.count(",") == 1 and s.count(".") == 0:
        s = s.replace(",", ".")
    # schneide nicht-numerisches Ende ab
    while len(s) > 0 and not s[-1].isdigit():
        s = s[:-1]
    try:
        return float(s)
    except ValueError:
        return 0.0

# Breite Regex-Varianten für P-Zeilen
_RX_WS = re.compile(
    r"^[\s0-9]*P\s+(\S+)\s+([0-9\-\+\s',\.]+)\s+([A-Za-z0-9%°/]+)\s+([0-9\-\+\s',\.]+)\s+(.*)$"
)
_RX_SC = re.compile(
    r"^[\s0-9]*P\s*;\s*([^;]+)\s*;\s*([^;]*)\s*;\s*([^;]*)\s*;\s*([^;]+)\s*;\s*([^;]+)\s*;\s*([^;]+)"
)

def _try_fixed(line: str):
    """Fallback: Fixed-Width nach häufigem E1S-Schema.
    Erwartet, dass `line` irgendwo ein 'P' enthält; wir schneiden davor ab."""
    m = re.search(r"[Pp]", line)
    if not m:
        return None
    base = line[m.start():]
    if len(base) < 46:
        return None
    npk   = base[6:16].strip()
    qty   = _to_number(base[16:28])
    unit  = base[28:34].strip()
    price = _to_number(base[34:46])
    text  = base[46:].strip()
    if not npk:
        return None
    return npk, qty, unit, price, text

# ──────────────────────────────────────────────────────────────────────
# Parser
# ──────────────────────────────────────────────────────────────────────

def parse_e1s_text(content: str) -> pd.DataFrame:
    """
    Robustes E1S/01S-Parsing:
     • toleriert BOM & Einrückung sowie führende Ordnungszahlen (z.B. "000 P …")
     • erkennt Whitespace-, Semikolon- und Fixed-Width-Format
     • versteht CH/DE-Zahlenformate
     • liest C070 (Rabatt), C099 (MwSt), C998 (Total)
    """
    records: list[dict] = []
    rabatt = mwst = total = 0.0
    cnt_p_seen = cnt_c_seen = 0

    for raw in content.splitlines():
        if raw is None:
            continue
        line = str(raw).lstrip("\ufeff").rstrip("\r\n")
        if not line.strip():
            continue
        mtype = re.match(r"^[\s0-9]*([A-Za-z])", line)
        if not mtype:
            continue
        t = mtype.group(1).upper()

        if t == "P":
            cnt_p_seen += 1
            rec = None
            m_sc = _RX_SC.match(line)
            if m_sc:
                npk = m_sc.group(1).strip()
                kurz = (m_sc.group(2) or '').strip()
                qty = _to_number(m_sc.group(4))
                unit = m_sc.group(5).strip()
                price = _to_number(m_sc.group(6))
                text = kurz
                rec = (npk, qty, unit, price, text)
            if rec is None:
                m_ws = _RX_WS.match(line)
                if m_ws:
                    npk   = m_ws.group(1).strip()
                    qty   = _to_number(m_ws.group(2))
                    unit  = m_ws.group(3).strip()
                    price = _to_number(m_ws.group(4))
                    text  = (m_ws.group(5) or '').strip()
                    rec = (npk, qty, unit, price, text)
            if rec is None:
                rec = _try_fixed(line)
            if rec is None:
                continue
            npk, menge, einheit, preis, kurz = rec
            records.append({
                'NPK': npk,
                'Kurztext': kurz,
                'Menge': menge,
                'Einheit': einheit,
                'Einzelpreis': preis,
                'Gesamt': menge * preis,
            })
        elif t == "C":
            cnt_c_seen += 1
            m = re.search(r"^[\s0-9]*C(\d{3}).*?([-+]?\d[\d\s'\u00A0\u202F\.,]*)\s*$", line)
            if not m:
                continue
            code = m.group(1)
            amt  = _to_number(m.group(2))
            if code == '070':
                rabatt = amt
            elif code == '099':
                mwst = amt
            elif code == '998':
                total = amt

    df = pd.DataFrame(records)
    if df.empty:
        raise ValueError(
            f"Keine Positionen in E1S erkannt (P-Zeilen gesehen: {cnt_p_seen}, C-Zeilen gesehen: {cnt_c_seen}). "
            "Das Layout weicht von bekannten Mustern ab."
        )
    summary = []
    if rabatt:
        summary.append({'NPK': 'Rabatt', 'Kurztext': 'Rabattbetrag', 'Menge': '', 'Einheit': '', 'Einzelpreis': '', 'Gesamt': -rabatt})
    if mwst:
        summary.append({'NPK': 'MwSt', 'Kurztext': 'Mehrwertsteuer', 'Menge': '', 'Einheit': '', 'Einzelpreis': '', 'Gesamt': mwst})
    total_val = total if total else float(df['Gesamt'].sum())
    summary.append({'NPK': 'Total', 'Kurztext': 'Endsumme inkl. MwSt', 'Menge': '', 'Einheit': '', 'Einzelpreis': '', 'Gesamt': total_val})
    return pd.concat([df, pd.DataFrame(summary)], ignore_index=True)


def parse_xml_root(root: ET.Element) -> pd.DataFrame:
    """Parst IfA18/SIA451-XML (mit oder ohne Namespace)."""
    tag = root.tag
    if "}" in tag:
        ns_uri = tag.split("}")[0].strip("{")
        def xp(xname: str) -> str: return f".//{{{ns_uri}}}{xname}"
        def ftext(el: ET.Element, name: str, default: str = "0") -> str:
            return el.findtext(xp(name)) or default
        positions = root.findall(xp("Position"))
    else:
        def xp(xname: str) -> str: return f".//{xname}"
        def ftext(el: ET.Element, name: str, default: str = "0") -> str:
            return el.findtext(xp(name)) or default
        positions = root.findall(xp("Position"))

    rows = []
    for pos in positions:
        npk  = (pos.findtext(xp("CatalogueNumber")) or "").strip()
        kurz = (pos.findtext(xp("ShortText")) or "").strip()
        menge = _to_number(ftext(pos, "Quantity", "0").replace(" ", ""))
        preis = _to_number(ftext(pos, "Price", "0").replace(" ", ""))
        einh  = (pos.findtext(xp("Unit")) or "").strip()
        rows.append({
            "NPK": npk, "Kurztext": kurz, "Menge": menge,
            "Einheit": einh, "Einzelpreis": preis, "Gesamt": menge * preis
        })

    df = pd.DataFrame(rows)
    if df.empty:
        raise ValueError("Keine Positionen im XML-LV gefunden.")
    return df


def parse_excel_template(path: Path) -> pd.DataFrame:
    df = pd.read_excel(str(path), header=2)
    mapping = {
        "Ordnungszahl": "NPK",
        "Kurztext": "Kurztext",
        "Langtext": "Langtext",
        "Menge": "Menge",
        "Einheit": "Einheit",
        "Einheitspreis": "Einzelpreis",
        "Gesamtpreis": "Gesamt",
        "Nachlass": "Rabatt",
        "Preis nach Nachlass": "PreisNachNachlass",
        "MwSt.": "MwSt",
    }
    df = df.rename(columns=mapping)
    required = ["NPK", "Kurztext", "Menge", "Einheit", "Einzelpreis", "Gesamt"]
    missing = [c for c in required if c not in df.columns]
    if missing:
        raise ValueError(f"Excel ohne notwendige Spalten: {missing}")
    return df[required]


def parse_path(path: Path) -> pd.DataFrame:
    ext = path.suffix.lower()
    if ext == ".xlsx":
        return parse_excel_template(path)
    if ext in {".xml", ".sia451", ".01s"}:
        # .01s kann XML oder Text sein
        try:
            root = ET.parse(str(path)).getroot()
            return parse_xml_root(root)
        except ET.ParseError:
            try:
                text = path.read_text(encoding="cp1252")
            except UnicodeDecodeError:
                text = path.read_text(encoding="utf-8", errors="ignore")
            return parse_e1s_text(text)
    if zipfile.is_zipfile(path):
        with zipfile.ZipFile(path) as zf:
            names = zf.namelist()
            # XML-Dateien zuerst
            for n in names:
                ln = n.lower()
                if ln.endswith((".xml", ".sia451", ".01s")):
                    try:
                        data = zf.read(n)
                        root = ET.fromstring(data)
                        return parse_xml_root(root)
                    except ET.ParseError:
                        continue
            # E1S/01S als Text
            for n in names:
                ln = n.lower()
                if ln.endswith((".e1s", ".01s")):
                    try:
                        content = zf.read(n).decode("cp1252")
                    except UnicodeDecodeError:
                        content = zf.read(n).decode("utf-8", errors="ignore")
                    return parse_e1s_text(content)
        raise ValueError("Keine LV-Daten im Archiv gefunden.")
    raise ValueError("Unbekanntes Dateiformat.")


def write_offer_excel(df: pd.DataFrame, out_path: Path) -> None:
    with pd.ExcelWriter(str(out_path), engine="openpyxl") as w:
        df.to_excel(w, index=False, sheet_name="Positionen")
        pd.DataFrame([{"Total": float(df["Gesamt"].sum())}]).to_excel(
            w, index=False, sheet_name="Zusammenfassung"
        )


def build_preisspiegel(dfs: Dict[str, pd.DataFrame], out_path: Path) -> None:
    merged: Optional[pd.DataFrame] = None
    for name, df in dfs.items():
        grp = df.groupby("NPK", as_index=False)["Gesamt"].sum().rename(columns={"Gesamt": name})
        merged = grp if merged is None else merged.merge(grp, on="NPK", how="outer")
    if merged is None:
        raise ValueError("Keine Daten für Preisspiegel.")
    merged.fillna(0, inplace=True)
    totals = {name: float(df["Gesamt"].sum()) for name, df in dfs.items()}
    merged = pd.concat([merged, pd.DataFrame([{"NPK": "Total", **totals}])], ignore_index=True)
    merged.to_excel(str(out_path), index=False, sheet_name="Preisspiegel")

# ──────────────────────────────────────────────────────────────────────
# GUI (PyQt5)
# ──────────────────────────────────────────────────────────────────────

class DropLabel(QLabel):
    def __init__(self, parent):
        super().__init__("Dateien hierher ziehen\n(.crbx / .e1s / .01s / .xml / .sia451 / .xlsx)")
        self.setAlignment(Qt.AlignCenter)
        self.setAcceptDrops(True)
        self.setStyleSheet("border:2px dashed #888; padding:40px; font-size:16px;")
        self.main = parent

    def dragEnterEvent(self, e: QDragEnterEvent):
        if e.mimeData().hasUrls():
            e.acceptProposedAction()

    def dropEvent(self, e: QDropEvent):
        files = [Path(u.toLocalFile()) for u in e.mimeData().urls()]
        self.main.process_files(files)


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("CRBX → XLSX Converter")
        self.setFixedSize(800, 600)

        bg = Path("resources/placeholder.png")
        if bg.exists():
            pix = QPixmap(str(bg))
            pal = self.palette()
            pal.setBrush(QPalette.Window, QBrush(pix))
            self.setPalette(pal)
            self.setAutoFillBackground(True)

        central = QWidget()
        vbox = QVBoxLayout(central)
        self.drop = DropLabel(self)
        vbox.addWidget(self.drop)
        btn = QPushButton("Dateien auswählen …", clicked=self.open_dialog)
        vbox.addWidget(btn)
        self.setCentralWidget(central)
        self.status = QStatusBar()
        self.setStatusBar(self.status)

    def open_dialog(self):
        files, _ = QFileDialog.getOpenFileNames(
            self,
            "Dateien wählen",
            "",
            "CRBX/E1S/01S/XML/XLSX/SIA451 (*.crbx *.e1s *.01s *.xml *.xlsx *.sia451)"
        )
        if files:
            self.process_files([Path(f) for f in files])

    def process_files(self, paths: List[Path]):
        valid_ext = {".crbx", ".e1s", ".01s", ".xml", ".xlsx", ".sia451"}
        valid = [p for p in paths if p.suffix.lower() in valid_ext]
        if not valid:
            QMessageBox.warning(self, "Hinweis", "Bitte unterstützte Dateien auswählen.")
            return

        dfs: Dict[str, pd.DataFrame] = {}
        errors: List[str] = []

        for p in valid:
            self.status.showMessage(f"Verarbeite {p.name} …")
            try:
                df = parse_path(p)
                out = safe_out_path(p, "_converted.xlsx")
                write_offer_excel(df, out)
                dfs[p.stem] = df
            except Exception as ex:
                errors.append(f"{p.name}: {ex}")

        if len(dfs) > 1:
            first_folder = valid[0].parent
            ps_path = safe_out_path(first_folder / "Auswertung", "_Preisspiegel.xlsx")
            try:
                build_preisspiegel(dfs, ps_path)
            except Exception as ex:
                errors.append(f"Preisspiegel: {ex}")

        self.status.showMessage("Fertig.")
        if errors:
            QMessageBox.warning(self, "Abgeschlossen – mit Hinweisen", "\n".join(errors))
        else:
            QMessageBox.information(self, "Erledigt", "Umwandlung abgeschlossen!")

# ──────────────────────────────────────────────────────────────────────
# Entry-Point
# ──────────────────────────────────────────────────────────────────────

if __name__ == "__main__":
    app = QApplication(sys.argv)
    win = MainWindow()
    win.show()
    sys.exit(app.exec_())
