#!/usr/bin/env python3
"""Misst die Qualitaet der Produktsuche gegen die Wahrheit aus der Datenbank.

    messen.py <basis-url> <installationsverzeichnis> [ausgabe.json]

Die Pruefbegriffe werden NICHT von Hand gewaehlt, sondern aus den Daten
gezogen: Geschmacksrichtungen, Hersteller und Produkttypen, von denen die
Datenbank genau weiss, wie viele Artikel sie haben. Die Suche muss also
gegen eine bekannte Wahrheit antreten.

Gemessen wird beides:
  Trefferquote (recall)   - wie viele der richtigen Artikel kommen vor?
  Genauigkeit (precision) - wie viel von dem, was kommt, ist richtig?

Ein Ergebnis ist nur mit einem zweiten vergleichbar, das auf derselben
Begriffsliste beruht - darum wird die Liste in die Ausgabedatei geschrieben.
"""
import json
import re
import subprocess
import sys
import urllib.parse
import urllib.request
from pathlib import Path

GESCHMACK_GRUPPE = 'Geschmacksrichtung'


def db(ziel: str, sql: str) -> list:
    # Ueber DBSQL laesst sich ein anderer Weg zur Datenbank einsetzen - etwa
    # ein Wrapper, der die Abfrage per ssh auf einen Produktionsknoten
    # weiterreicht. Sonst der Helfer aus dem Nachbarverzeichnis.
    import os
    hier = os.environ.get('DBSQL') or str(Path(__file__).resolve().parent.parent / 'deploy' / 'db-sql.sh')
    roh = subprocess.run([hier, ziel, sql], capture_output=True, text=True).stdout
    zeilen = [z.split('\t') for z in roh.strip().splitlines()]
    return zeilen[1:] if len(zeilen) > 1 else []


def zugangsschluessel(ziel: str) -> str:
    z = db(ziel, "SELECT access_key FROM sales_channel "
                 "WHERE type_id=UNHEX('8A243080F92E4C719546314B577CF82B') AND active=1 LIMIT 1")
    return z[0][0] if z else ''


def begriffe_sammeln(ziel: str) -> list:
    """Begriffe mit bekannter Soll-Trefferzahl."""
    liste = []

    # Geschmacksrichtungen: das Feld, das heute gar nicht durchsucht wird
    for name, n in db(ziel, f"""
        SELECT ot.name, COUNT(DISTINCT pp.product_id) AS n
          FROM property_group_option_translation ot
          JOIN property_group_option o ON o.id = ot.property_group_option_id
          JOIN property_group g ON g.id = o.property_group_id
          JOIN property_group_translation gt ON gt.property_group_id = g.id
                                            AND gt.name = '{GESCHMACK_GRUPPE}'
          JOIN product_property pp ON pp.property_group_option_id = o.id
          JOIN product p ON p.id = pp.product_id AND p.active = 1
         GROUP BY ot.name HAVING n >= 5 ORDER BY n DESC LIMIT 25"""):
        liste.append({'begriff': name, 'art': 'Geschmack', 'soll': int(n)})

    # Hersteller: die sind durchsuchbar, dienen als Gegenprobe
    for name, n in db(ziel, """
        SELECT m.name, COUNT(DISTINCT p.id) AS n
          FROM product_manufacturer_translation m
          JOIN product p ON p.product_manufacturer_id = m.product_manufacturer_id AND p.active = 1
         GROUP BY m.name HAVING n >= 10 ORDER BY n DESC LIMIT 10"""):
        liste.append({'begriff': name, 'art': 'Hersteller', 'soll': int(n)})

    return liste


def soll_artikel(ziel: str, eintrag: dict) -> set:
    if eintrag['art'] == 'Geschmack':
        sql = f"""SELECT DISTINCT LOWER(HEX(COALESCE(p.parent_id, p.id)))
                    FROM property_group_option_translation ot
                    JOIN property_group_option o ON o.id = ot.property_group_option_id
                    JOIN property_group g ON g.id = o.property_group_id
                    JOIN property_group_translation gt ON gt.property_group_id = g.id
                                                      AND gt.name = '{GESCHMACK_GRUPPE}'
                    JOIN product_property pp ON pp.property_group_option_id = o.id
                    JOIN product p ON p.id = pp.product_id AND p.active = 1
                   WHERE ot.name = {sql_text(eintrag['begriff'])}"""
    else:
        sql = f"""SELECT DISTINCT LOWER(HEX(COALESCE(p.parent_id, p.id))) FROM product p
                    JOIN product_manufacturer_translation m
                      ON m.product_manufacturer_id = p.product_manufacturer_id
                   WHERE p.active = 1 AND m.name = {sql_text(eintrag['begriff'])}"""
    return {z[0] for z in db(ziel, sql) if z and z[0]}


def sql_text(s: str) -> str:
    return "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'"


def suchen(basis: str, schluessel: str, begriff: str, hoechstens: int = 400) -> list:
    """Holt die Treffer seitenweise.

    Die Store-API begrenzt limit auf 100 und antwortet auf mehr mit einem
    Fehler - der erste Versuch mit 400 lieferte darum ueberall null Treffer
    und sah aus, als waere die Suche kaputt. Sie war es nicht, die Messung
    war es.
    """
    treffer = []
    seite = 1
    while len(treffer) < hoechstens:
        adresse = f"{basis}/store-api/search?search={urllib.parse.quote(begriff)}"
        daten = json.dumps({'limit': 100, 'p': seite,
                            'includes': {'product': ['id', 'parentId']}}).encode()
        anfrage = urllib.request.Request(adresse, data=daten, headers={
            'sw-access-key': schluessel, 'Content-Type': 'application/json'})
        try:
            with urllib.request.urlopen(anfrage, timeout=30) as antwort:
                d = json.loads(antwort.read())
        except Exception:
            break
        teil = d.get('elements', [])
        if not teil:
            break
        treffer += [(p.get('parentId') or p.get('id')) for p in teil]
        if len(teil) < 100:
            break
        seite += 1
    return treffer


def main() -> int:
    basis = sys.argv[1].rstrip('/')
    ziel = sys.argv[2]
    ausgabe = Path(sys.argv[3]) if len(sys.argv) > 3 else Path(__file__).parent / 'messung.json'

    schluessel = zugangsschluessel(ziel)
    if not schluessel:
        print('Kein Verkaufskanal-Schluessel gefunden.', file=sys.stderr)
        return 1

    begriffe = begriffe_sammeln(ziel)
    print(f'{len(begriffe)} Pruefbegriffe aus den Daten gezogen\n')

    ergebnisse = []
    for e in begriffe:
        soll = soll_artikel(ziel, e)
        # Auch Elternartikel der Treffer zaehlen: die Suche liefert Varianten,
        # die Wahrheit aus der Datenbank steht am Elternartikel.
        gefunden = set(suchen(basis, schluessel, e['begriff']))
        eltern = soll
        treffer = gefunden & eltern
        recall = len(treffer) / len(eltern) if eltern else None
        precision = len(treffer) / len(gefunden) if gefunden else None
        ergebnisse.append({**e, 'sollIds': len(eltern), 'gefunden': len(gefunden),
                           'richtig': len(treffer), 'recall': recall, 'precision': precision})

    def mittel(art, feld):
        werte = [r[feld] for r in ergebnisse if r['art'] == art and r[feld] is not None]
        return sum(werte) / len(werte) if werte else 0

    print('  Art          Trefferquote   Genauigkeit')
    print('  ' + '-' * 44)
    for art in ('Geschmack', 'Hersteller'):
        print('  %-12s %11.0f %%  %10.0f %%' % (art, 100 * mittel(art, 'recall'), 100 * mittel(art, 'precision')))
    print()
    print('  Die zehn schlechtesten:')
    schlecht = sorted((r for r in ergebnisse if r['recall'] is not None), key=lambda r: r['recall'])[:10]
    for r in schlecht:
        print('    %-22s %-11s soll %3d, gefunden %3d, davon richtig %3d  (%3.0f %%)' % (
            r['begriff'][:22], r['art'], r['sollIds'], r['gefunden'], r['richtig'], 100 * r['recall']))

    ausgabe.write_text(json.dumps({'basis': basis, 'ergebnisse': ergebnisse}, indent=2, ensure_ascii=False),
                       encoding='utf-8')
    print(f'\n  -> {ausgabe}')
    return 0


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