#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Expert Filial-Preischecker v4

EINE Eingabezeile akzeptiert:
  1) normalen expert.de Produktlink
  2) AWIN-Affiliate-Link mit expert.de als Ziel
  3) Artikelnummer / Webcode

Die AWIN-Huelle/Trackingparameter werden fuer die Produkterkennung entfernt.
Ergebnislinks werden mit den im bisherigen Worker verwendeten AWIN-IDs erzeugt.

Keine externen Python-Pakete notwendig (Python 3.9+ empfohlen).
Die Filialen werden nacheinander abgefragt. Standardpause: 3 Sekunden.
"""

from __future__ import annotations

import csv
import json
import math
import os
import re
import shutil
import ssl
import subprocess
import sys
import threading
import time
import traceback
import urllib.error
import urllib.parse
import urllib.request
import webbrowser
from dataclasses import dataclass
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple

ARTICLE_API = "https://production.brntgs.expert.de/api/search/article"
PRICE_API = "https://production.brntgs.expert.de/api/pricepds"
BRANCH_API = "https://production.brntgs.expert.de/_api/storeFinder/searchStoresByGeoLocation"

# Gleiche AWIN-Konfiguration wie im vorhandenen Worker.
AWINMID = "30007"
AWINAFFID = "1441502"
DEFAULT_DELAY = 3.0
REQUEST_TIMEOUT = 30
MAX_HTTP_RETRIES = 2

USER_AGENT = (
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
    "AppleWebKit/537.36 (KHTML, like Gecko) "
    "Chrome/120.0.0.0 Safari/537.36"
)

# Wird nach einem Python-SSL-Zertifikatsfehler automatisch aktiviert.
# curl verwendet auf macOS/Windows in der Regel den System-Zertifikatsspeicher.
_USE_CURL_FOR_HTTPS = False


@dataclass
class Store:
    exp_id: str
    internal_id: str
    name: str
    city: str

    @property
    def display_name(self) -> str:
        name = (self.name or "").strip()
        city = (self.city or "").strip()
        if not city:
            return name or self.exp_id
        if city.lower() in name.lower():
            return name
        return f"{name} {city}".strip()


@dataclass
class Offer:
    store: Store
    price: float
    shipping: float
    total: float
    store_stock: int
    online_stock: int
    on_display: bool
    url: str
    price_source: str
    strategy: str


class ExpertCheckerError(RuntimeError):
    pass


# -----------------------------------------------------------------------------
# Eingabe erkennen / Links bereinigen
# -----------------------------------------------------------------------------

def normalize_exp_id(value: Any) -> str:
    raw = str(value or "").strip()
    if not raw:
        return ""
    return raw if raw.lower().startswith("e_") else f"e_{raw}"


def numeric_exp_id(value: Any) -> str:
    return re.sub(r"^e_", "", normalize_exp_id(value), flags=re.I)


def same_store_id(a: Any, b: Any) -> bool:
    aa = numeric_exp_id(a).lower()
    bb = numeric_exp_id(b).lower()
    return bool(aa and bb and aa == bb)


def recursive_unquote(value: str, rounds: int = 3) -> str:
    out = value
    for _ in range(rounds):
        new = urllib.parse.unquote(out)
        if new == out:
            break
        out = new
    return out


def unwrap_awin_url(raw: str) -> str:
    """Extrahiert aus AWIN nur das eigentliche Ziel (ued)."""
    raw = (raw or "").strip()
    if not raw:
        return ""

    # Falls jemand einen HTML-kodierten Link hineinkopiert.
    raw = raw.replace("&amp;", "&")

    try:
        p = urllib.parse.urlsplit(raw)
        host = (p.hostname or "").lower()
        if "awin" not in host:
            return raw

        qs = urllib.parse.parse_qs(p.query, keep_blank_values=True)
        for key in ("ued", "url", "destination", "dest"):
            vals = qs.get(key)
            if vals and vals[0]:
                return recursive_unquote(vals[0])
    except Exception:
        pass

    # Fallback: ued= auch bei ungewoehnlich formatiertem Link suchen.
    m = re.search(r"(?:[?&]|^)ued=([^&]+)", raw, flags=re.I)
    if m:
        return recursive_unquote(m.group(1))
    return raw


def clean_expert_url(raw: str) -> str:
    """AWIN + Tracking komplett entfernen, nur kanonischen expert.de Produktpfad behalten."""
    target = unwrap_awin_url(raw)
    if not target:
        return ""
    try:
        p = urllib.parse.urlsplit(target)
        host = (p.hostname or "").lower()
        if not host.endswith("expert.de"):
            return ""
        scheme = p.scheme or "https"
        return urllib.parse.urlunsplit((scheme, p.netloc, p.path, "", ""))
    except Exception:
        return ""


def extract_webcode_from_url(url: str) -> str:
    try:
        p = urllib.parse.urlsplit(url)
        filename = p.path.rstrip("/").split("/")[-1]
        filename = re.sub(r"\.html$", "", filename, flags=re.I)
        first = filename.split("-")[0].strip()
        return first if re.fullmatch(r"[A-Za-z0-9_]+", first or "") else ""
    except Exception:
        return ""


def clean_article_number(raw: str) -> str:
    raw = (raw or "").strip()
    # Typische Webcodes bei expert sind numerisch; Buchstaben werden als Fallback zugelassen.
    cleaned = re.sub(r"[^A-Za-z0-9_]", "", raw)
    return cleaned


def parse_single_input(raw: str) -> Tuple[str, str, str]:
    """Return (webcode, product_url, input_type)."""
    value = (raw or "").strip()
    if not value:
        raise ExpertCheckerError("Bitte einen Expert-Link, AWIN-Link oder eine Artikelnummer eingeben.")

    # URL?
    if re.match(r"(?i)^https?://", value):
        unwrapped = unwrap_awin_url(value)
        product_url = clean_expert_url(unwrapped)
        if not product_url:
            raise ExpertCheckerError(
                "Der eingefuegte Link fuehrt nicht auf eine expert.de-Produktseite. "
                "Bitte einen Expert-Produktlink, einen AWIN-Link mit Expert-Ziel oder die Artikelnummer eingeben."
            )
        webcode = extract_webcode_from_url(product_url)
        if not webcode:
            raise ExpertCheckerError(
                "Die Artikelnummer konnte aus dem Expert-Link nicht erkannt werden. "
                "Du kannst stattdessen direkt die Artikelnummer eingeben."
            )
        input_type = "AWIN-Link" if "awin" in (urllib.parse.urlsplit(value).hostname or "").lower() else "Expert-Link"
        return webcode, product_url, input_type

    # Direkte Artikelnummer/Webcode.
    webcode = clean_article_number(value)
    if not webcode or len(webcode) < 5:
        raise ExpertCheckerError("Die eingegebene Artikelnummer/Webcode ist ungueltig.")
    return webcode, "", "Artikelnummer"


def build_store_product_url(product_url: str, exp_id: str) -> str:
    if not product_url:
        return ""
    branch = numeric_exp_id(exp_id)
    if not branch:
        return product_url
    p = urllib.parse.urlsplit(product_url)
    query = urllib.parse.urlencode([
        ("branch_ID", branch),
        ("ass_store", branch),
    ])
    return urllib.parse.urlunsplit((p.scheme or "https", p.netloc, p.path, query, ""))


def make_affiliate_url(store_product_url: str) -> str:
    if not store_product_url:
        return ""
    params = urllib.parse.urlencode({
        "awinmid": AWINMID,
        "awinaffid": AWINAFFID,
        "ued": store_product_url,
    })
    return "https://www.awin1.com/cread.php?" + params


# -----------------------------------------------------------------------------
# Datenkonvertierung
# -----------------------------------------------------------------------------

def deep_get(data: Any, path: Iterable[str], default: Any = None) -> Any:
    cur = data
    for key in path:
        if not isinstance(cur, dict) or key not in cur:
            return default
        cur = cur[key]
    return cur


def iter_dicts(obj: Any) -> Iterable[Dict[str, Any]]:
    if isinstance(obj, dict):
        yield obj
        for value in obj.values():
            yield from iter_dicts(value)
    elif isinstance(obj, list):
        for value in obj:
            yield from iter_dicts(value)


def money(value: Any) -> float:
    if value is None or value == "":
        return math.nan
    if isinstance(value, (int, float)) and not isinstance(value, bool):
        n = float(value)
        return n if math.isfinite(n) else math.nan

    s = str(value).strip().replace("€", "").replace(" ", "")
    if not s:
        return math.nan
    if re.fullmatch(r"\d{1,3}(?:\.\d{3})+,\d+", s):
        s = s.replace(".", "").replace(",", ".")
    elif re.fullmatch(r"\d+,\d+", s):
        s = s.replace(",", ".")
    try:
        n = float(s)
        return n if math.isfinite(n) else math.nan
    except ValueError:
        return math.nan


def stock(value: Any) -> int:
    try:
        return max(0, int(float(value)))
    except (TypeError, ValueError):
        return 0


def effective_price(data: Dict[str, Any]) -> Tuple[float, str]:
    candidates = [
        ("promotionPrice.checkoutPrice", deep_get(data, ["promotionPrice", "checkoutPrice"])),
        ("price.bruttoPrice", deep_get(data, ["price", "bruttoPrice"])),
    ]
    for source, raw in candidates:
        val = money(raw)
        if math.isfinite(val) and val > 0:
            return val, source
    return math.nan, "none"


def shipping_price(data: Dict[str, Any]) -> float:
    arr = deep_get(data, ["price", "shipmentArray"], [])
    if not isinstance(arr, list):
        return 0.0
    for row in arr:
        if isinstance(row, dict):
            val = money(row.get("shipmentBruttoPrice"))
            if math.isfinite(val) and val >= 0:
                return val
    return 0.0


def response_store_ids(data: Dict[str, Any]) -> List[str]:
    candidates = [
        data.get("storeId"),
        data.get("expId"),
        deep_get(data, ["price", "storeId"]),
        deep_get(data, ["price", "expId"]),
        deep_get(data, ["store", "id"]),
        deep_get(data, ["store", "expId"]),
        deep_get(data, ["price", "store", "id"]),
        deep_get(data, ["price", "store", "expId"]),
    ]
    return [str(x).strip() for x in candidates if x is not None and str(x).strip()]


# -----------------------------------------------------------------------------
# HTTP
# -----------------------------------------------------------------------------

def base_headers(referer: str = "", exp_id: str = "") -> Dict[str, str]:
    headers = {
        "User-Agent": USER_AGENT,
        "Accept": "application/json, text/plain, */*",
        "Accept-Language": "de-DE,de;q=0.9,en;q=0.7",
        "Origin": "https://www.expert.de",
        "Referer": referer or "https://www.expert.de/",
        "Cache-Control": "no-cache",
        "Pragma": "no-cache",
        "Connection": "close",
    }
    if exp_id:
        headers["Cookie"] = "fmarktcookie=" + normalize_exp_id(exp_id)
    return headers


def _ssl_context() -> ssl.SSLContext:
    """Erstellt einen verifizierenden SSL-Kontext.

    Falls certifi zufaellig installiert ist, wird dessen CA-Bundle verwendet.
    Ansonsten bleibt Python beim normalen System-/Python-Zertifikatsspeicher.
    """
    try:
        import certifi  # type: ignore
        return ssl.create_default_context(cafile=certifi.where())
    except Exception:
        return ssl.create_default_context()


def _json_from_text(text: str, label: str) -> Any:
    try:
        return json.loads(text)
    except json.JSONDecodeError as exc:
        raise ExpertCheckerError(
            f"{label}: Expert hat keine JSON-Daten geliefert. "
            f"Antwort beginnt mit: {text[:220]!r}"
        ) from exc


def _request_json_curl(
    url: str,
    *,
    method: str,
    headers: Dict[str, str],
    body: Optional[bytes],
    timeout: int,
    label: str,
) -> Any:
    """HTTPS-Fallback ueber das lokale curl, ohne Zertifikatspruefung abzuschalten."""
    curl = shutil.which("curl")
    if not curl:
        raise ExpertCheckerError(
            f"{label}: Python kann das SSL-Zertifikat nicht verifizieren und curl wurde "
            "auf diesem Computer nicht gefunden. Auf macOS bitte einmal "
            "'Install Certificates.command' aus dem Python-Ordner ausfuehren."
        )

    cmd = [
        curl,
        "--silent",
        "--show-error",
        "--location",
        "--fail-with-body",
        "--connect-timeout", str(min(timeout, 15)),
        "--max-time", str(timeout),
        "-X", method,
    ]

    for key, value in headers.items():
        # curl verwaltet die Verbindung selbst; der Header ist dort nicht notwendig.
        if key.lower() == "connection":
            continue
        cmd.extend(["-H", f"{key}: {value}"])

    input_data = None
    if body is not None:
        cmd.extend(["--data-binary", "@-"])
        input_data = body

    try:
        proc = subprocess.run(
            cmd,
            input=input_data,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            timeout=timeout + 5,
            check=False,
        )
    except subprocess.TimeoutExpired as exc:
        raise ExpertCheckerError(
            f"{label}: Zeitueberschreitung nach {timeout} Sekunden (curl)."
        ) from exc
    except Exception as exc:
        raise ExpertCheckerError(f"{label}: curl konnte nicht gestartet werden: {exc}") from exc

    stdout = proc.stdout.decode("utf-8", errors="replace")
    stderr = proc.stderr.decode("utf-8", errors="replace").strip()

    if proc.returncode != 0:
        detail = (stderr or stdout[:350] or f"curl Exit-Code {proc.returncode}")[:500]
        raise ExpertCheckerError(f"{label}: curl-Fehler: {detail}")

    return _json_from_text(stdout, label)


def _is_certificate_error(exc: BaseException) -> bool:
    reason = getattr(exc, "reason", exc)
    text = str(reason)
    return (
        isinstance(reason, ssl.SSLCertVerificationError)
        or isinstance(exc, ssl.SSLCertVerificationError)
        or "CERTIFICATE_VERIFY_FAILED" in text
        or "certificate verify failed" in text.lower()
    )


def request_json(
    url: str,
    *,
    method: str = "GET",
    headers: Optional[Dict[str, str]] = None,
    json_body: Any = None,
    timeout: int = REQUEST_TIMEOUT,
    label: str = "Expert-API",
) -> Any:
    global _USE_CURL_FOR_HTTPS

    body: Optional[bytes] = None
    h = dict(headers or {})
    if json_body is not None:
        body = json.dumps(json_body, separators=(",", ":")).encode("utf-8")
        h.setdefault("Content-Type", "application/json;charset=UTF-8")

    # Wurde auf diesem Rechner bereits ein Python-Zertifikatsproblem erkannt,
    # direkt curl benutzen. Dadurch wird nicht fuer jede Filiale erst urllib probiert.
    if _USE_CURL_FOR_HTTPS:
        return _request_json_curl(
            url, method=method, headers=h, body=body,
            timeout=timeout, label=label
        )

    last_error: Optional[Exception] = None
    for attempt in range(1, MAX_HTTP_RETRIES + 1):
        req = urllib.request.Request(url, data=body, headers=h, method=method)
        try:
            with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
                raw = resp.read()
                response_text = raw.decode("utf-8", errors="replace")
                return _json_from_text(response_text, label)

        except urllib.error.HTTPError as exc:
            try:
                detail = exc.read().decode("utf-8", errors="replace")[:350]
            except Exception:
                detail = ""
            last_error = ExpertCheckerError(f"{label}: HTTP {exc.code}. {detail}")
            if exc.code not in (408, 425, 429, 500, 502, 503, 504) or attempt >= MAX_HTTP_RETRIES:
                raise last_error

        except (ssl.SSLCertVerificationError, urllib.error.URLError) as exc:
            if _is_certificate_error(exc):
                # Sichere Alternative: Zertifikatspruefung bleibt aktiv, aber curl
                # nutzt den Zertifikatsspeicher des Betriebssystems.
                _USE_CURL_FOR_HTTPS = True
                return _request_json_curl(
                    url, method=method, headers=h, body=body,
                    timeout=timeout, label=label
                )

            reason = getattr(exc, "reason", exc)
            last_error = ExpertCheckerError(f"{label}: Netzwerkfehler: {reason}")
            if attempt >= MAX_HTTP_RETRIES:
                raise last_error

        except TimeoutError as exc:
            last_error = ExpertCheckerError(
                f"{label}: Zeitueberschreitung nach {timeout} Sekunden."
            )
            if attempt >= MAX_HTTP_RETRIES:
                raise last_error

        time.sleep(2.0)

    raise last_error or ExpertCheckerError(f"{label}: Unbekannter Netzwerkfehler")


def get_article_info(webcode: str, product_url: str = "") -> Dict[str, Any]:
    url = ARTICLE_API + "?" + urllib.parse.urlencode({"webcode": webcode})
    data = request_json(url, headers=base_headers(product_url), label="Artikelabfrage")
    return data if isinstance(data, dict) else {}


def article_title(data: Dict[str, Any]) -> str:
    title = data.get("seoPageTitle") or data.get("title") or data.get("article") or "Produkt"
    return str(title).split(" - bei expert kaufen")[0].strip() or "Produkt"


def product_url_from_article_info(data: Dict[str, Any]) -> str:
    keys = (
        "url", "productUrl", "productURL", "canonicalUrl", "canonicalURL",
        "seoUrl", "seoURL", "pageUrl", "pageURL", "link", "href",
    )
    for d in iter_dicts(data):
        for key in keys:
            v = d.get(key)
            if not isinstance(v, str) or not v.strip():
                continue
            v = v.strip()
            if v.startswith("/"):
                v = "https://www.expert.de" + v
            cleaned = clean_expert_url(v)
            if cleaned and ".html" in cleaned.lower():
                return cleaned
    for d in iter_dicts(data):
        for v in d.values():
            if isinstance(v, str) and "expert.de" in v.lower() and ".html" in v.lower():
                cleaned = clean_expert_url(v)
                if cleaned:
                    return cleaned
    return ""


def get_branches() -> List[Store]:
    payload = {
        "lat": 0,
        "lng": 0,
        "maxResults": 500,
        "device": "Mozilla/5.0",
        "source": "HTML5",
        "withWebsite": True,
        "conditions": {"storeFinderResultFilter": "ALL"},
    }
    headers = base_headers("https://www.expert.de/markt-finden")
    headers["Content-Type"] = "application/json;charset=UTF-8"
    data = request_json(
        BRANCH_API,
        method="POST",
        headers=headers,
        json_body=payload,
        label="Filialabfrage",
    )

    stores: List[Store] = []
    seen = set()
    for d in iter_dicts(data):
        candidate = d.get("store") if isinstance(d.get("store"), dict) else d
        if not isinstance(candidate, dict):
            continue
        exp = normalize_exp_id(candidate.get("expId"))
        internal = str(candidate.get("id") or "").strip()
        name = str(candidate.get("name") or "").strip()
        city = str(candidate.get("city") or "").strip()
        if not exp or not name or exp in seen:
            continue
        seen.add(exp)
        stores.append(Store(exp_id=exp, internal_id=internal, name=name, city=city))
    return stores


# -----------------------------------------------------------------------------
# Preis je Filiale - mehrere Strategien, aber widerspruechliche Ergebnisse
# werden nicht blind uebernommen.
# -----------------------------------------------------------------------------

def fetch_price_candidate(store: Store, webcode: str, product_url: str, store_id: str, strategy: str) -> Dict[str, Any]:
    referer = build_store_product_url(product_url, store.exp_id) if product_url else "https://www.expert.de/"
    params = urllib.parse.urlencode({
        "webcode": webcode,
        "storeId": store_id,
        "_": str(int(time.time() * 1000)),
    })
    url = PRICE_API + "?" + params
    try:
        data = request_json(
            url,
            headers=base_headers(referer, store.exp_id),
            label=f"Preisabfrage {store.display_name}",
        )
        if not isinstance(data, dict):
            return {"ok": False, "strategy": strategy, "store_id": store_id, "reason": "keine Objektantwort"}
        price, source = effective_price(data)
        ids = response_store_ids(data)
        id_match = None if not ids else any(same_store_id(x, store.exp_id) for x in ids)
        return {
            "ok": math.isfinite(price) and price > 0 and id_match is not False,
            "strategy": strategy,
            "store_id": store_id,
            "price": price if math.isfinite(price) else None,
            "source": source,
            "response_ids": ids,
            "id_match": id_match,
            "data": data,
        }
    except Exception as exc:
        return {"ok": False, "strategy": strategy, "store_id": store_id, "reason": str(exc)[:300]}


def fetch_price_for_store(store: Store, webcode: str, product_url: str) -> Tuple[Optional[Dict[str, Any]], str, List[Dict[str, Any]]]:
    strategies: List[Tuple[str, str]] = []

    # Bevorzugt die Fachmarkt-expId. Interne ID nur als kontrollierter Fallback.
    for strategy, store_id in (
        ("expId", normalize_exp_id(store.exp_id)),
        ("expId-ohne-e_", numeric_exp_id(store.exp_id)),
        ("interne-store-id", store.internal_id),
    ):
        if store_id and all(existing_id != store_id for _, existing_id in strategies):
            strategies.append((strategy, store_id))

    attempts = [fetch_price_candidate(store, webcode, product_url, sid, strategy) for strategy, sid in strategies]
    valid = [a for a in attempts if a.get("ok") and a.get("data")]
    if not valid:
        return None, "kein gueltiger Preis", attempts

    # Explizite Store-ID-Matches sind am sichersten.
    matched = [a for a in valid if a.get("id_match") is True]
    if matched:
        matched.sort(key=lambda a: 0 if a.get("strategy") == "expId" else 1)
        chosen = matched[0]
        return chosen["data"], str(chosen["strategy"]), attempts

    # Wenn die API keine Store-ID mitliefert: Preise der Strategien vergleichen.
    price_groups: Dict[float, List[Dict[str, Any]]] = {}
    for a in valid:
        p = round(float(a["price"]), 2)
        price_groups.setdefault(p, []).append(a)

    if len(price_groups) == 1:
        # Alle Antworten sind konsistent -> expId bevorzugen.
        valid.sort(key=lambda a: {"expId": 0, "expId-ohne-e_": 1, "interne-store-id": 2}.get(str(a.get("strategy")), 9))
        chosen = valid[0]
        return chosen["data"], str(chosen["strategy"]), attempts

    # Unterschiedliche Preise ohne nachpruefbare Store-ID: nicht als sicher ausgeben.
    return None, "widerspruechliche Preisantworten", attempts


# -----------------------------------------------------------------------------
# Gesamtabfrage
# -----------------------------------------------------------------------------

def run_check(
    user_input: str,
    *,
    include_display: bool = False,
    only_online: bool = False,
    delay_seconds: float = DEFAULT_DELAY,
    progress: Optional[Callable[[int, int, str], None]] = None,
    log: Optional[Callable[[str], None]] = None,
) -> Tuple[str, str, List[Offer], List[Dict[str, Any]]]:
    def emit(msg: str) -> None:
        if log:
            log(msg)

    webcode, product_url, input_type = parse_single_input(user_input)
    emit(f"Erkannt: {input_type} | Artikelnummer: {webcode}")

    article_info = get_article_info(webcode, product_url)
    title = article_title(article_info)

    if not product_url:
        product_url = product_url_from_article_info(article_info)
        if product_url:
            emit("Produktlink aus den Expert-Artikeldaten erkannt.")
        else:
            emit("Produktlink nicht in Artikeldaten gefunden. Preise werden trotzdem abgefragt; direkte Ergebnislinks koennen fehlen.")

    stores = get_branches()
    if not stores:
        raise ExpertCheckerError("Expert hat keine Filialdaten geliefert. Die Filial-API scheint sich geaendert zu haben oder ist momentan nicht erreichbar.")

    emit(f"{len(stores)} Filialen geladen. Start mit {delay_seconds:.1f} Sekunden Pause je Filiale.")

    offers: List[Offer] = []
    debug: List[Dict[str, Any]] = []

    for index, store in enumerate(stores, 1):
        if progress:
            progress(index, len(stores), store.display_name)

        data, strategy, attempts = fetch_price_for_store(store, webcode, product_url)
        if not data:
            debug.append({
                "store": store.display_name,
                "expId": store.exp_id,
                "status": strategy,
                "attempts": attempts,
            })
        else:
            on_display = bool(deep_get(data, ["price", "itemOnDisplay", "onDisplay"], False))
            online_stock = stock(deep_get(data, ["price", "onlineStock"], 0))
            store_stock = stock(deep_get(data, ["price", "storeStock"], 0))
            price, source = effective_price(data)

            exclusion = ""
            if not include_display and on_display:
                exclusion = "Ausstellungsstueck ausgeschlossen"
            elif only_online and online_stock <= 0:
                exclusion = "kein Online-Bestand"
            elif not math.isfinite(price) or price <= 0:
                exclusion = "ungueltiger Preis"

            if exclusion:
                debug.append({"store": store.display_name, "expId": store.exp_id, "status": exclusion, "attempts": attempts})
            else:
                shipping = shipping_price(data) if online_stock > 0 else 0.0
                total = round(price + shipping, 2)
                direct = build_store_product_url(product_url, store.exp_id) if product_url else ""
                result_url = make_affiliate_url(direct) if direct else ""
                offers.append(Offer(
                    store=store,
                    price=price,
                    shipping=shipping,
                    total=total,
                    store_stock=store_stock,
                    online_stock=online_stock,
                    on_display=on_display,
                    url=result_url,
                    price_source=source,
                    strategy=strategy,
                ))
                debug.append({"store": store.display_name, "expId": store.exp_id, "status": "OK", "price": price, "strategy": strategy, "attempts": attempts})

        if index < len(stores) and delay_seconds > 0:
            time.sleep(delay_seconds)

    offers.sort(key=lambda x: (x.total, x.store.display_name.lower()))
    return title, product_url, offers, debug


def euro(value: float) -> str:
    return f"{value:,.2f} EUR".replace(",", "X").replace(".", ",").replace("X", ".")


def save_csv(path: str, offers: List[Offer]) -> None:
    with open(path, "w", newline="", encoding="utf-8-sig") as f:
        w = csv.writer(f, delimiter=";")
        w.writerow(["Filiale", "expId", "Preis", "Versand", "Gesamt", "Filialbestand", "Onlinebestand", "Ausstellung", "Strategie", "Affiliate-Link"])
        for o in offers:
            w.writerow([
                o.store.display_name, o.store.exp_id, f"{o.price:.2f}", f"{o.shipping:.2f}", f"{o.total:.2f}",
                o.store_stock, o.online_stock, "ja" if o.on_display else "nein", o.strategy, o.url,
            ])


def save_debug(path: str, debug: List[Dict[str, Any]]) -> None:
    with open(path, "w", encoding="utf-8") as f:
        json.dump(debug, f, ensure_ascii=False, indent=2, default=str)


# -----------------------------------------------------------------------------
# Terminal
# -----------------------------------------------------------------------------

def run_cli() -> int:
    print("\nExpert Filial-Preischecker v4")
    print("=" * 36)
    raw = input("Expert-Link / AWIN-Link / Artikelnummer: ").strip()
    delay_raw = input(f"Pause in Sekunden [{DEFAULT_DELAY}]: ").strip()
    try:
        delay = max(0.0, float(delay_raw.replace(",", "."))) if delay_raw else DEFAULT_DELAY
    except ValueError:
        delay = DEFAULT_DELAY

    def progress(i: int, total: int, name: str) -> None:
        print(f"[{i:>3}/{total}] {name}")

    try:
        title, product_url, offers, debug = run_check(raw, delay_seconds=delay, progress=progress, log=print)
    except Exception as exc:
        print("\nFEHLER:", exc)
        print("\nTechnische Details:")
        traceback.print_exc()
        return 1

    print("\n", title, sep="")
    if product_url:
        print(product_url)
    print(f"\n{len(offers)} sichere Angebote gefunden:\n")
    for o in offers:
        print(f"{euro(o.total):>15} | {o.store.display_name} | Preis {euro(o.price)} | Versand {euro(o.shipping)}")
        if o.url:
            print(" " * 17 + o.url)

    stamp = int(time.time())
    csv_path = os.path.abspath(f"expert_ergebnisse_{stamp}.csv")
    debug_path = os.path.abspath(f"expert_debug_{stamp}.json")
    save_csv(csv_path, offers)
    save_debug(debug_path, debug)
    print("\nCSV:", csv_path)
    print("Debug:", debug_path)
    return 0


# -----------------------------------------------------------------------------
# GUI - genau EINE Eingabezeile fuer Produkt/AWIN/Artikelnummer
# -----------------------------------------------------------------------------

def run_gui() -> int:
    try:
        import tkinter as tk
        from tkinter import filedialog, messagebox, ttk
    except Exception:
        return run_cli()

    root = tk.Tk()
    root.title("Expert Filial-Preischecker v4")
    root.geometry("1220x790")
    root.minsize(930, 620)

    main = ttk.Frame(root, padding=14)
    main.pack(fill="both", expand=True)
    main.columnconfigure(1, weight=1)
    main.rowconfigure(7, weight=1)

    input_var = tk.StringVar()
    delay_var = tk.StringVar(value=str(DEFAULT_DELAY))
    include_display_var = tk.BooleanVar(value=False)
    only_online_var = tk.BooleanVar(value=False)
    status_var = tk.StringVar(value="Bereit")
    title_var = tk.StringVar(value="")

    ttk.Label(main, text="Expert-Link / AWIN-Link / Artikelnummer:").grid(row=0, column=0, sticky="w", padx=(0, 10), pady=5)
    entry = ttk.Entry(main, textvariable=input_var)
    entry.grid(row=0, column=1, columnspan=4, sticky="ew", pady=5)
    entry.focus_set()

    ttk.Label(main, text="Pause je Filiale:").grid(row=1, column=0, sticky="w", padx=(0, 10), pady=5)
    ttk.Entry(main, textvariable=delay_var, width=8).grid(row=1, column=1, sticky="w")
    ttk.Label(main, text="Sekunden (Standard 3,0)").grid(row=1, column=2, sticky="w")
    ttk.Checkbutton(main, text="Ausstellungsstuecke anzeigen", variable=include_display_var).grid(row=1, column=3, sticky="w", padx=8)
    ttk.Checkbutton(main, text="Nur online bestellbar", variable=only_online_var).grid(row=1, column=4, sticky="w")

    ttk.Label(
        main,
        text="Das Programm erkennt automatisch, was eingefuegt wurde. AWIN- und Trackingparameter werden fuer die Produkterkennung entfernt.",
        wraplength=1050,
    ).grid(row=2, column=0, columnspan=5, sticky="w", pady=(2, 8))

    ttk.Separator(main).grid(row=3, column=0, columnspan=5, sticky="ew", pady=5)
    ttk.Label(main, textvariable=title_var, font=("TkDefaultFont", 11, "bold")).grid(row=4, column=0, columnspan=5, sticky="w", pady=(5, 2))
    ttk.Label(main, textvariable=status_var).grid(row=5, column=0, columnspan=5, sticky="w", pady=(0, 6))

    button_frame = ttk.Frame(main)
    button_frame.grid(row=6, column=0, columnspan=5, sticky="ew", pady=(0, 8))

    columns = ("total", "price", "shipping", "store", "online", "stock", "display", "expid")
    tree = ttk.Treeview(main, columns=columns, show="headings", selectmode="browse")
    labels = {
        "total": "Gesamt", "price": "Preis", "shipping": "Versand", "store": "Filiale",
        "online": "Online", "stock": "Filialbestand", "display": "Ausstellung", "expid": "expId",
    }
    widths = {"total": 100, "price": 95, "shipping": 90, "store": 330, "online": 70, "stock": 95, "display": 90, "expid": 110}
    for c in columns:
        tree.heading(c, text=labels[c])
        tree.column(c, width=widths[c], anchor="w")
    tree.grid(row=7, column=0, columnspan=5, sticky="nsew")
    scroll = ttk.Scrollbar(main, orient="vertical", command=tree.yview)
    scroll.grid(row=7, column=5, sticky="ns")
    tree.configure(yscrollcommand=scroll.set)

    offers: List[Offer] = []
    debug_data: List[Dict[str, Any]] = []
    url_by_item: Dict[str, str] = {}

    def clear_results() -> None:
        offers.clear()
        debug_data.clear()
        url_by_item.clear()
        for item in tree.get_children():
            tree.delete(item)

    def open_selected(_event: Any = None) -> None:
        sel = tree.selection()
        if not sel:
            return
        url = url_by_item.get(sel[0], "")
        if url:
            webbrowser.open(url)
        else:
            messagebox.showinfo("Kein Direktlink", "Fuer dieses Ergebnis konnte kein direkter Produktlink ermittelt werden.")

    tree.bind("<Double-1>", open_selected)

    def save_results() -> None:
        if not offers:
            messagebox.showinfo("Keine Daten", "Es gibt noch keine Ergebnisse zum Speichern.")
            return
        path = filedialog.asksaveasfilename(title="CSV speichern", defaultextension=".csv", filetypes=[("CSV", "*.csv")], initialfile="expert_ergebnisse.csv")
        if path:
            save_csv(path, offers)
            messagebox.showinfo("Gespeichert", path)

    def save_diagnostics() -> None:
        if not debug_data:
            messagebox.showinfo("Keine Diagnose", "Noch keine Diagnosedaten vorhanden.")
            return
        path = filedialog.asksaveasfilename(title="Diagnose speichern", defaultextension=".json", filetypes=[("JSON", "*.json")], initialfile="expert_debug.json")
        if path:
            save_debug(path, debug_data)
            messagebox.showinfo("Gespeichert", path)

    start_btn = ttk.Button(button_frame, text="Suche starten")
    start_btn.pack(side="left")
    ttk.Button(button_frame, text="Ausgewaehlten Filial-Link oeffnen", command=open_selected).pack(side="left", padx=8)
    ttk.Button(button_frame, text="CSV speichern", command=save_results).pack(side="left")
    ttk.Button(button_frame, text="Diagnose speichern", command=save_diagnostics).pack(side="left", padx=8)

    def start_search() -> None:
        raw = input_var.get().strip()
        if not raw:
            messagebox.showerror("Eingabe fehlt", "Bitte Expert-Link, AWIN-Link oder Artikelnummer eingeben.")
            return
        try:
            delay = max(0.0, float(delay_var.get().replace(",", ".")))
        except ValueError:
            messagebox.showerror("Pause ungueltig", "Bitte z. B. 3 oder 3,0 eingeben.")
            return

        clear_results()
        start_btn.configure(state="disabled")
        title_var.set("")
        status_var.set("Starte ...")

        # Tkinter-Variablen nur im Hauptthread auslesen.
        # Die Werte werden danach als normale Python-Werte an den Worker uebergeben.
        include_display = bool(include_display_var.get())
        only_online = bool(only_online_var.get())

        def ui_progress(i: int, total: int, name: str) -> None:
            root.after(0, lambda i=i, total=total, name=name: status_var.set(f"Filiale {i}/{total}: {name}"))

        def ui_log(msg: str) -> None:
            root.after(0, lambda msg=msg: status_var.set(msg))

        def worker() -> None:
            nonlocal offers, debug_data
            try:
                title, _product_url, result_offers, result_debug = run_check(
                    raw,
                    include_display=include_display,
                    only_online=only_online,
                    delay_seconds=delay,
                    progress=ui_progress,
                    log=ui_log,
                )
                offers = result_offers
                debug_data = result_debug

                def finish() -> None:
                    title_var.set(title)
                    for o in offers:
                        item = tree.insert("", "end", values=(
                            euro(o.total), euro(o.price), euro(o.shipping), o.store.display_name,
                            o.online_stock, o.store_stock, "ja" if o.on_display else "nein", o.store.exp_id,
                        ))
                        url_by_item[item] = o.url
                    status_var.set(f"Fertig: {len(offers)} sichere Angebote gefunden. Doppelklick oeffnet die ausgewaehlte Filiale.")
                    start_btn.configure(state="normal")
                root.after(0, finish)
            except Exception as exc:
                # WICHTIG: Exception-Variablen werden von Python nach dem except-Block
                # geloescht. Deshalb Text + Traceback JETZT kopieren und als Default-
                # Parameter an den spaeter ausgefuehrten Tkinter-Callback binden.
                error_text = str(exc) or repr(exc)
                details = traceback.format_exc()

                def fail(error_text: str = error_text, details: str = details) -> None:
                    status_var.set("Fehler")
                    start_btn.configure(state="normal")

                    # Diagnose immer neben der Python-Datei speichern, nicht irgendwo
                    # im zufaelligen aktuellen Arbeitsordner.
                    try:
                        script_dir = os.path.dirname(os.path.abspath(__file__))
                        error_path = os.path.join(script_dir, "expert_checker_error.txt")
                        with open(error_path, "w", encoding="utf-8") as f:
                            f.write(error_text + "\n\n" + details)
                    except Exception:
                        error_path = "expert_checker_error.txt"

                    messagebox.showerror(
                        "Expert-Checker Fehler",
                        error_text
                        + "\n\nDie technische Fehlermeldung wurde gespeichert unter:\n"
                        + error_path
                        + "\n\nDu kannst mir diese Datei schicken; dann sehe ich den eigentlichen API-Fehler.",
                    )

                root.after(0, fail)

        threading.Thread(target=worker, daemon=True).start()

    start_btn.configure(command=start_search)
    entry.bind("<Return>", lambda _e: start_search())

    ttk.Label(
        main,
        text=(
            "Die Abfragen laufen nacheinander. Die Pause dient normalem Rate-Limiting. "
            "Bei einem Fehler wird zusaetzlich expert_checker_error.txt erzeugt."
        ),
        wraplength=1050,
    ).grid(row=8, column=0, columnspan=5, sticky="w", pady=(8, 0))

    root.mainloop()
    return 0


if __name__ == "__main__":
    if "--cli" in sys.argv:
        raise SystemExit(run_cli())
    raise SystemExit(run_gui())
