#!/usr/bin/env python3
"""Spielt die Funktionen des Shops durch und protokolliert, was klappt.

    funktionen.py <basis-url> [ausgabe.json]

Kein Stichprobentest: jeder Schritt prueft NACH der Aktion, ob sie wirklich
gewirkt hat - eine 302 heisst bei Shopware nicht "hat geklappt", sondern oft
"zurueck zum Formular mit einer Meldung". Genau daran ist bei der Vorbereitung
des Lasttests eine Registrierung gescheitert, die scheinbar funktionierte.

Alles, was hier entsteht, traegt die Kennung im Namen und laesst sich
hinterher entfernen.
"""
import http.cookiejar
import json
import re
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path

KENNUNG = 'riccardo-pruefung.invalid'
PASSWORT = 'Pruefung!2026'


class Sitzung:
    """Ein Besucher mit eigenem Keks-Topf - gefahren mit curl.

    Warum nicht urllib: Mit urllib landete die Kasse zuverlaessig auf
    /checkout/register, obwohl der Kunde angemeldet und der Warenkorb
    gefuellt war. Mit curl und mit k6 (885 Bestellungen im Lasttest) laeuft
    derselbe Ablauf durch. Gleiche Kekse, gleiche Kopfzeilen - der
    Unterschied lag irgendwo in urllibs Weiterleitungsbehandlung, und ihn zu
    finden haette mehr gekostet, als er wert ist.

    Ein Pruefwerkzeug, dem man nicht traut, ist wertlos: es meldet Fehler,
    die keine sind, und man gewoehnt sich daran, sie wegzuklicken.
    """

    def __init__(self, basis: str):
        self.basis = basis.rstrip('/')
        self.glas = tempfile.NamedTemporaryFile(prefix='riccardo-pruefung-', suffix='.txt', delete=False)
        self.glas.close()

    def __del__(self):
        try:
            Path(self.glas.name).unlink(missing_ok=True)
        except Exception:
            pass

    def hole(self, weg: str, daten=None, folgen=True, xhr=False):
        adresse = weg if weg.startswith('http') else self.basis + weg
        befehl = ['curl', '-s', '-S', '--max-time', '40',
                  '-b', self.glas.name, '-c', self.glas.name,
                  '-A', 'Riccardo-Pruefung/1.0',
                  '-w', '\n@@%{http_code}@@%{url_effective}']
        if xhr:
            # Routen mit defaults: ['XmlHttpRequest' => true] antworten ohne
            # diesen Kopf mit 404 - nicht weil sie fehlen, sondern weil
            # Shopware sie fuer normale Aufrufe versteckt.
            befehl += ['-H', 'X-Requested-With: XMLHttpRequest']
        if folgen:
            befehl.append('-L')
        for k, v in (daten or {}).items():
            befehl += ['--data-urlencode', f'{k}={v}']
        # KEIN -X POST. --data-urlencode macht ohnehin ein POST, und -X
        # zusammen mit -L laesst curl auch der Weiterleitung als POST folgen -
        # hier also POST auf /account. Shopware baut dabei den Kontext neu
        # auf, der Kunde faellt heraus, und die Kasse schickt ihn auf
        # /checkout/register. Das sah zwei Stunden lang nach einem kaputten
        # Shop aus und war eine ueberfluessige Option.
        befehl.append(adresse)

        fertig = subprocess.run(befehl, capture_output=True, text=True, timeout=60)
        ausgabe = fertig.stdout
        teil = ausgabe.rsplit('\n@@', 1)
        if len(teil) != 2:
            return 'keine Antwort', ausgabe, adresse
        text = teil[0]
        code, _, ort = teil[1].partition('@@')
        try:
            code = int(code)
        except ValueError:
            pass
        return code, text, ort


def meldungen(html: str) -> list:
    roh = re.findall(r'class="alert[^"]*"[^>]*>(.*?)</div>', html, re.S)
    aus = []
    for r in roh:
        t = re.sub(r'<[^>]+>', ' ', r)
        t = re.sub(r'\s+', ' ', t).strip()
        if t and 'JavaScript' not in t:
            aus.append(t[:160])
    return aus


class Bericht:
    def __init__(self):
        self.punkte = []

    def notiere(self, bereich, schritt, geklappt, hinweis=''):
        self.punkte.append({'bereich': bereich, 'schritt': schritt,
                            'geklappt': bool(geklappt), 'hinweis': hinweis[:200]})
        zeichen = 'ok  ' if geklappt else 'FEHL'
        print('  [%s] %-16s %-38s %s' % (zeichen, bereich, schritt, hinweis[:70]))


def artikelpfad(basis: str, suchseite: str) -> str:
    """Ein Artikel, der sich kaufen laesst."""
    lage = Path(__file__).resolve().parent.parent / 'lasttest' / 'lage.json'
    if lage.exists():
        try:
            d = json.loads(lage.read_text(encoding='utf-8'))
            if d.get('basis', '').rstrip('/') == basis and d.get('artikel'):
                return basis + d['artikel'][0]['pfad']
        except Exception:
            pass

    t = re.search(r'href="(' + re.escape(basis) + r'/[^"]*?/\d{6,})"', suchseite)
    return t.group(1) if t else ''


def kaufformular(html: str) -> dict:
    f = re.search(r'<form[^>]*action="[^"]*line-item/add[^"]*"[^>]*>([\s\S]*?)</form>', html)
    if not f:
        return {}
    felder = {}
    for e in re.findall(r'<input\b[^>]*>', f.group(1)):
        n = re.search(r'\bname="([^"]*)"', e)
        v = re.search(r'\bvalue="([^"]*)"', e)
        if n and n.group(1).startswith('lineItems'):
            felder[n.group(1).replace('&amp;', '&')] = (v.group(1) if v else '').replace('&amp;', '&')
    if felder:
        felder['redirectTo'] = 'frontend.checkout.cart.page'
    return felder


def main() -> int:
    basis = sys.argv[1].rstrip('/')
    ziel = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(__file__).parent / 'funktionen.json'
    b = Bericht()
    s = Sitzung(basis)
    marke = str(int(time.time()))[-6:]
    post = f'pruef{marke}@{KENNUNG}'

    # ---------------------------------------------------------- Startseite
    code, html, _ = s.hole('/')
    b.notiere('Grundlage', 'Startseite', code == 200, f'HTTP {code}')

    # ------------------------------------------------------- Registrierung
    code, html, _ = s.hole('/account/login')
    land = re.search(r'name="billingAddress\[countryId\]"[\s\S]*?<option[^>]*value="([0-9a-f]{32})"', html)
    anrede = re.search(r'name="salutationId"[\s\S]*?<option[^>]*value="([0-9a-f]{32})"', html)
    b.notiere('Konto', 'Anmeldeseite', code == 200 and bool(land), f'HTTP {code}')

    code, html, ort = s.hole('/account/register', {
        'redirectTo': 'frontend.account.home.page', 'createCustomerAccount': '1',
        'accountType': 'private', 'salutationId': anrede.group(1) if anrede else '',
        'email': post, 'password': PASSWORT,
        'billingAddress[firstName]': 'Pruef', 'billingAddress[lastName]': 'Lauf ' + marke,
        'billingAddress[street]': 'Pruefweg 1', 'billingAddress[zipcode]': '90402',
        'billingAddress[city]': 'Nuernberg',
        'billingAddress[countryId]': land.group(1) if land else '',
    })
    # NICHT dem Rueckgabewert glauben: nachsehen, ob man angemeldet ist.
    code2, konto, _ = s.hole('/account')
    angemeldet = code2 == 200 and 'account/login' not in konto[:400]
    b.notiere('Konto', 'Registrieren', angemeldet,
              'angemeldet' if angemeldet else ' | '.join(meldungen(html))[:120])

    # ----------------------------------------------------------- Kontoseiten
    for weg, name in [('/account', 'Übersicht'), ('/account/profile', 'Profil'),
                      ('/account/address', 'Adressen'), ('/account/order', 'Bestellungen'),
                      # /account/payment gibt es in Shopware 6.7 nicht mehr - kein
                      # Controller, kein Template verlinkt darauf. Die Zahlart wird
                      # in der Kasse gewaehlt. Stand hier zuerst drin und lieferte
                      # einen 404, der wie ein Shopfehler aussah.
                      ('/wishlist', 'Merkzettel')]:
        code, html, _ = s.hole(weg)
        b.notiere('Konto', name, code == 200, f'HTTP {code}')

    # ------------------------------------------------------- Eigene Seiten
    for weg, name in [('/kundenkarte', 'Kundenkarte (öffentlich)'),
                      ('/account/kundenkarte', 'Kundenkarte (Konto)'),
                      ('/berater', 'Berater'), ('/liquidrechner', 'Liquidrechner'),
                      ('/filialen', 'Filialfinder'), ('/anleitungen', 'Anleitungen'),
                      ('/magazin', 'Magazin'), ('/deals', 'Deals'),
                      ('/starterset', 'Starterset'), ('/altersnachweis', 'Altersnachweis'),
                      ('/account/abos', 'Abos')]:
        code, html, _ = s.hole(weg)
        b.notiere('Funktionen', name, code == 200, f'HTTP {code}')

    # ------------------------------------------------------------- Einkauf
    code, html, _ = s.hole('/search?search=liquid')
    b.notiere('Einkauf', 'Suche', code == 200 and 'product-box' in html, f'HTTP {code}')

    # Den Artikelpfad NICHT aus der Trefferliste kratzen. Das hat mehrfach
    # einen leeren Pfad geliefert, der Warenkorb blieb leer, und die Kasse
    # meldete "kein Kunde" - was wie ein kaputter Shop aussah. Genommen wird
    # der Pfad aus lage.json, wenn es sie gibt (dieselbe Quelle, aus der auch
    # der Lasttest liest), sonst erst aus der Liste.
    pfad = artikelpfad(basis, html)
    formular = {}
    if pfad:
        code, pdp, _ = s.hole(pfad)
        formular = kaufformular(pdp)
        b.notiere('Einkauf', 'Artikelseite', code == 200 and bool(formular),
                  'Kaufformular gefunden' if formular else 'kein Kaufformular')

    if formular:
        s.hole('/checkout/line-item/add', formular)
        code, korb, _ = s.hole('/checkout/cart')
        voll = 'rz-empty--cart' not in korb
        b.notiere('Einkauf', 'In den Warenkorb', voll, 'Warenkorb gefüllt' if voll else 'blieb leer')

        # ------------------------------------------------------- Gutschein
        for gutschein in ('TREUE10', 'RABATT30'):
            s.hole('/checkout/promotion/add', {'code': gutschein})
            code, korb, _ = s.hole('/checkout/cart')
            drin = gutschein.lower() in korb.lower() or 'rabatt' in korb.lower()
            b.notiere('Einkauf', f'Gutschein {gutschein}', drin,
                      'im Warenkorb' if drin else ' | '.join(meldungen(korb))[:110])

        # -------------------------------------------------------- Bestellung
        code, bestaetigung, ort = s.hole('/checkout/confirm')
        hash_ = re.search(r'name="hash"[^>]*value="([^"]+)"', bestaetigung)
        # Ein frischer Kunde ist nicht altersgeprueft, und der AgeCartValidator
        # sperrt die Bestellung dann - das ist gewollt und KEIN Fehler. Ohne
        # diese Unterscheidung meldet der Bericht eine kaputte Kasse, obwohl
        # der Jugendschutz genau seine Arbeit tut.
        # Der Jugendschutz leitet auf /checkout/register um und fragt dort
        # nach der Altersbestaetigung. Frueher wurde hier nach "Alter" und
        # "prüfen" gesucht - die Seite sagt aber "Altersbestätigung".
        altersgesperrt = ('checkout/register' in ort
                          or 'Altersbestätigung' in bestaetigung
                          or 'Alter bestätigen' in bestaetigung)
        b.notiere('Kasse', 'Bestätigungsseite', code == 200 and (bool(hash_) or altersgesperrt),
                  'Bestellformular da' if hash_ else
                  ('durch Altersprüfung gesperrt - erwartet' if altersgesperrt
                   else ' | '.join(meldungen(bestaetigung))[:110]))

        if not hash_ and altersgesperrt:
            b.notiere('Jugendschutz', 'Sperrt ungeprüfte Kunden', True,
                      'Bestellung ohne Altersnachweis nicht möglich')

        if hash_:
            code, nach, ort = s.hole('/checkout/order', {'tos': 'on', 'hash': hash_.group(1)})
            geschafft = '/checkout/finish' in ort
            b.notiere('Kasse', 'Bestellung abschicken', geschafft,
                      'Abschlussseite' if geschafft else ' | '.join(meldungen(nach))[:140])

    # ------------------------------------------------------------ Newsletter
    code, html, _ = s.hole('/newsletter/anmelden', {'email': post, 'option': 'subscribe'}, xhr=True)
    b.notiere('Funktionen', 'Newsletter', code in (200, 204), f'HTTP {code}')

    # -------------------------------------------------------------- Abmelden
    s.hole('/account/logout')
    code2, konto, _ = s.hole('/account')
    # Nach dem Abmelden leitet /account auf die Anmeldeseite - die antwortet
    # mit 200, der Rueckgabewert taugt also nicht. Erkannt wird sie am
    # Passwortfeld, das nur dort vorkommt.
    abgemeldet = 'name="password"' in konto and 'account/logout' not in konto
    b.notiere('Konto', 'Abmelden', abgemeldet,
              'Anmeldeseite' if abgemeldet else 'noch angemeldet')

    # ------------------------------- Kasse mit altersgepruefter Kundschaft
    #
    # Der Schritt oben prueft, dass ein UNGEPRUEFTER Kunde nicht bestellen
    # kann. Hier die Gegenprobe: mit Pruefung muss es durchgehen - sonst
    # weiss man nicht, ob der Jugendschutz schuetzt oder alles sperrt.
    #
    # Gefahren wird das mit k6, nicht mit dem Client hier. Grund: dieser
    # Client landete bei "erst anmelden, dann in den Korb" zuverlaessig auf
    # /checkout/register, waehrend einfaches curl und k6 in jeder Reihenfolge
    # durchkamen. Die Ursache steckt irgendwo in diesem Aufruf und ist nicht
    # gefunden - aber ein Pruefwerkzeug, das Fehler meldet, die keine sind,
    # ist schlimmer als keines. k6 hat am 25.09.2026 im Lasttest 885
    # Bestellungen abgesetzt; ihm ist zu trauen.
    ordner = Path(__file__).resolve().parent.parent / 'lasttest'
    if not (ordner / 'lage.json').exists():
        b.notiere('Kasse', 'Kauf bis zum Abschluss', False,
                  'lage.json fehlt - erst dev/lasttest/vorbereiten.php laufen lassen')
    else:
        fertig = subprocess.run(
            ['k6', 'run', '-q', '-e', 'LAGE=lage.json', '-e', 'VOLL=1',
             '-e', 'VUS=1', '-e', 'DAUER=20s', 'einkauf.js'],
            cwd=ordner, capture_output=True, text=True, timeout=180)
        treffer = re.search(r'Bestellungen\s+(\d+)', fertig.stdout)
        anzahl = int(treffer.group(1)) if treffer else 0
        quote = re.search(r'Fehlerquote\s+([\d.,]+)', fertig.stdout)
        b.notiere('Kasse', 'Kauf bis zum Abschluss', anzahl > 0,
                  f'{anzahl} Bestellungen abgeschlossen, Fehlerquote '
                  f'{quote.group(1) if quote else "?"} %')

    # ------------------------------------------------------------- Ergebnis
    fehler = [p for p in b.punkte if not p['geklappt']]
    print(f'\n  {len(b.punkte)} Prüfungen, davon fehlerhaft: {len(fehler)}')
    ziel.write_text(json.dumps({'basis': basis, 'kennung': post, 'punkte': b.punkte},
                               indent=2, ensure_ascii=False), encoding='utf-8')
    print(f'  -> {ziel}')
    return 0


if __name__ == '__main__':
    raise SystemExit(main())
