"""
Password-protected catalogue viewer — same MySQL target as load_mysql.py.

  python view_server.py

Login via HTTP Basic Auth (BASKIT_VIEW_USER / BASKIT_VIEW_PASSWORD).
"""
from __future__ import annotations

import html
import math
from decimal import Decimal
from functools import wraps
from urllib.parse import urlencode

import pymysql
from flask import Flask, Response, jsonify, request

import config

app = Flask(__name__)
RETAILERS = [("pnp", "Pick n Pay"), ("checkers", "Checkers"), ("woolworths", "Woolworths")]
RETAILER_LABELS = dict(RETAILERS)
PER_PAGE = 60


def _auth_ok(user: str, password: str) -> bool:
    return user == config.VIEW_USER and password == config.VIEW_PASSWORD


def requires_auth(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        auth = request.authorization
        if not auth or not _auth_ok(auth.username, auth.password):
            return Response(
                "Login required",
                401,
                {"WWW-Authenticate": 'Basic realm="Baskit Pipeline Viewer"'},
            )
        return fn(*args, **kwargs)

    return wrapper


def _conn():
    return pymysql.connect(**config.MYSQL, cursorclass=pymysql.cursors.DictCursor)


def _h(s) -> str:
    return html.escape("" if s is None else str(s))


def _money(v) -> str:
    return "—" if v is None else f"R{float(v):,.2f}"


def _json_val(v):
    """Make MySQL row values JSON-serialisable."""
    if v is None:
        return None
    if isinstance(v, Decimal):
        return float(v)
    return v


@app.get("/api/product")
@requires_auth
def product_detail():
    """Lazy-load per-retailer catalogue rows for one barcode (admin drill-down)."""
    barcode = (request.args.get("barcode") or "").strip()
    if not barcode:
        return jsonify({"error": "barcode required"}), 400

    with _conn() as conn, conn.cursor() as cur:
        cur.execute("SHOW COLUMNS FROM catalogue LIKE 'scraped_at'")
        has_scraped = cur.fetchone() is not None
        cols = (
            "retailer, product_key, barcode_raw, barcode_norm, is_instore_bc, "
            "name, brand, price, was_price, image_url, url, bucket"
            + (", scraped_at" if has_scraped else "")
        )
        cur.execute(
            f"SELECT {cols} FROM catalogue WHERE barcode_norm = %s OR barcode_raw = %s "
            "ORDER BY retailer",
            (barcode, barcode),
        )
        rows = cur.fetchall()
        cur.execute("SELECT * FROM mvp_catalogue WHERE barcode = %s", (barcode,))
        mvp = cur.fetchone()

    by_retailer = {key: None for key, _ in RETAILERS}
    for row in rows:
        by_retailer[row["retailer"]] = {k: _json_val(v) for k, v in row.items()}

    return jsonify({
        "barcode": barcode,
        "mvp": {k: _json_val(v) for k, v in mvp.items()} if mvp else None,
        "retailers": by_retailer,
        "labels": RETAILER_LABELS,
    })


def _qs(overrides: dict | None = None) -> str:
    base = {
        "mode": request.args.get("mode", "mvp"),
        "q": request.args.get("q", ""),
        "bucket": request.args.get("bucket", ""),
        "page": request.args.get("page", "1"),
    }
    if overrides:
        base.update(overrides)
    return urlencode(base)


@app.get("/health")
def health():
    """Unauthenticated liveness check."""
    return {"ok": True}


@app.get("/")
@requires_auth
def index():
    mode = "all" if request.args.get("mode") == "all" else "mvp"
    q = (request.args.get("q") or "").strip()
    bucket = (request.args.get("bucket") or "").strip()
    page = max(1, int(request.args.get("page") or 1))
    offset = (page - 1) * PER_PAGE

    with _conn() as conn, conn.cursor() as cur:
        cur.execute(
            "SELECT (SELECT COUNT(*) FROM catalogue) AS catalogue_rows,"
            " (SELECT COUNT(*) FROM mvp_catalogue) AS mvp_rows,"
            " (SELECT COUNT(*) FROM mvp_catalogue WHERE n_retailers>=3) AS mvp_all3"
        )
        stats = cur.fetchone()
        cur.execute(
            "SELECT retailer, COUNT(*) c FROM catalogue GROUP BY retailer ORDER BY retailer"
        )
        by_retailer = {r["retailer"]: r["c"] for r in cur.fetchall()}
        cur.execute("SELECT bucket, COUNT(*) c FROM mvp_catalogue GROUP BY bucket ORDER BY bucket")
        buckets = cur.fetchall()

        rows: list[dict] = []
        total_rows = 0
        if mode == "mvp":
            where, args = [], {}
            if q:
                where.append(
                    "(CONVERT(m.name USING utf8mb4) LIKE %(q)s OR CONVERT(m.brand USING utf8mb4) LIKE %(q)s "
                    "OR CONVERT(m.barcode USING utf8mb4) LIKE %(q)s)"
                )
                args["q"] = f"%{q}%"
            if bucket:
                where.append("CONVERT(m.bucket USING utf8mb4) = %(bucket)s")
                args["bucket"] = bucket
            where_sql = ("WHERE " + " AND ".join(where)) if where else ""
            cur.execute(f"SELECT COUNT(*) c FROM mvp_catalogue m {where_sql}", args)
            total_rows = int(cur.fetchone()["c"])
            cur.execute(
                f"SELECT m.*, img.image_url FROM mvp_catalogue m LEFT JOIN ("
                f" SELECT barcode_norm, MIN(image_url) AS image_url FROM catalogue"
                f" WHERE image_url IS NOT NULL AND image_url <> '' GROUP BY barcode_norm"
                f") img ON CONVERT(img.barcode_norm USING utf8mb4) = CONVERT(m.barcode USING utf8mb4)"
                f" {where_sql} ORDER BY m.bucket, m.n_retailers DESC, m.name"
                f" LIMIT {PER_PAGE} OFFSET {offset}",
                args,
            )
            rows = cur.fetchall()
        else:
            where = ["barcode_norm IS NOT NULL", "is_instore_bc = 0", "price IS NOT NULL"]
            args: dict = {}
            if q:
                where.append(
                    "(CONVERT(name USING utf8mb4) LIKE %(q)s OR CONVERT(brand USING utf8mb4) LIKE %(q)s "
                    "OR CONVERT(barcode_norm USING utf8mb4) LIKE %(q)s)"
                )
                args["q"] = f"%{q}%"
            where_sql = "WHERE " + " AND ".join(where)
            having = "HAVING COUNT(DISTINCT retailer) >= 2"
            cur.execute(
                f"SELECT COUNT(*) c FROM (SELECT barcode_norm FROM catalogue {where_sql}"
                f" GROUP BY barcode_norm {having}) t",
                args,
            )
            total_rows = int(cur.fetchone()["c"])
            cur.execute(
                f"SELECT barcode_norm AS barcode, MAX(name) AS name, MAX(brand) AS brand,"
                f" COUNT(DISTINCT retailer) AS n_retailers,"
                f" MAX(CASE WHEN retailer='pnp' THEN price END) AS pnp_price,"
                f" MAX(CASE WHEN retailer='checkers' THEN price END) AS checkers_price,"
                f" MAX(CASE WHEN retailer='woolworths' THEN price END) AS woolworths_price,"
                f" MIN(price) AS min_price, MAX(price) AS max_price,"
                f" (SELECT image_url FROM catalogue c2 WHERE c2.barcode_norm = catalogue.barcode_norm"
                f" AND c2.image_url IS NOT NULL LIMIT 1) AS image_url"
                f" FROM catalogue {where_sql} GROUP BY barcode_norm {having}"
                f" ORDER BY (MAX(price)-MIN(price)) DESC LIMIT {PER_PAGE} OFFSET {offset}",
                args,
            )
            rows = cur.fetchall()

    total_pages = max(1, math.ceil(total_rows / PER_PAGE))
    mysql_label = f"{config.MYSQL['user']}@{config.MYSQL['host']}:{config.MYSQL['port']}/{config.MYSQL['database']}"
    return _render(mode, q, bucket, page, total_pages, total_rows, stats, buckets, by_retailer, rows, mysql_label)


def _render(mode, q, bucket, page, total_pages, total_rows, stats, buckets, by_retailer, rows, mysql_label):
    pills = ""
    if mode == "mvp":
        pills += f'<a class="pill {"on" if not bucket else ""}" href="?{_h(_qs({"bucket":"","page":1}))}">All buckets</a>'
        for b in buckets:
            on = "on" if bucket == b["bucket"] else ""
            label = _h(b["bucket"].replace("_", " ").title())
            pills += f'<a class="pill {on}" href="?{_h(_qs({"bucket":b["bucket"],"page":1}))}">{label} {b["c"]}</a>'

    body_rows = ""
    if not rows:
        body_rows = '<tr><td colspan="7" class="empty">No products found.</td></tr>'
    for r in rows:
        prices = {"pnp": r.get("pnp_price"), "checkers": r.get("checkers_price"), "woolworths": r.get("woolworths_price")}
        valid = {k: v for k, v in prices.items() if v is not None}
        mn = min(valid.values()) if valid else None
        mx = max(valid.values()) if valid else None
        save = (mx - mn) if mn is not None and mx is not None else 0
        save_pct = round(save / mx * 100) if mx and save > 0 else 0
        cheapest = next((k for k, v in prices.items() if v is not None and float(v) == float(mn)), None) if mn else None
        img = f'<img class="thumb" src="{_h(r.get("image_url"))}" alt="" loading="lazy">' if r.get("image_url") else ""
        bk = f'<span class="bk">{_h(str(r.get("bucket","")).replace("_"," "))}</span> ' if r.get("bucket") else ""
        brand = f'{_h(r.get("brand"))} · ' if r.get("brand") else ""
        price_cells = ""
        for key, label in RETAILERS:
            v = prices[key]
            cheap = v is not None and cheapest == key and len(valid) > 1
            cls = "price cheap" if cheap else ("price none" if v is None else "price")
            price_cells += f'<td class="num"><span class="{cls}">{_money(v)}</span></td>'
        save_cell = f'<span class="save">{_money(save)}<small>{save_pct}%</small></span>' if save > 0 else "—"
        cheap_label = dict(RETAILERS).get(cheapest, "—") if cheapest else "—"
        body_rows += (
            f"<tr class='prow' data-barcode='{_h(r.get('barcode'))}' tabindex='0'>"
            f"<td style='width:42px'>{img}</td><td><div class='pname'>{_h(r.get('name'))}</div>"
            f"<div class='pmeta'>{bk}{brand}barcode {_h(r.get('barcode'))}</div></td>{price_cells}"
            f"<td class='num'><b>{_h(cheap_label)}</b></td><td class='num'>{save_cell}</td></tr>"
        )

    retailer_stats = " · ".join(f"{_h(l)}: {by_retailer.get(k, 0):,}" for k, l in RETAILERS)
    return f"""<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Baskit Pipeline Viewer</title>
<style>{_CSS}</style></head><body>
<header><div class="brand"><h1>🧺 Baskit</h1><span class="sub">pipeline viewer · {_h(mysql_label)}</span></div>
<div class="stats">
<div class="stat"><b>{stats['catalogue_rows']:,}</b> catalogue rows</div>
<div class="stat"><b>{stats['mvp_rows']:,}</b> MVP SKUs</div>
<div class="stat"><b>{stats['mvp_all3']:,}</b> priced in all 3</div>
<div class="stat"><span class="sub">{retailer_stats}</span></div>
</div></header>
<div class="wrap"><div class="toolbar">
<div class="tabs"><a class="{'on' if mode=='mvp' else ''}" href="?{_h(_qs({'mode':'mvp','page':1}))}">MVP</a>
<a class="{'on' if mode=='all' else ''}" href="?{_h(_qs({'mode':'all','bucket':'','page':1}))}">All Matched</a></div>
<form class="search" method="get"><input type="hidden" name="mode" value="{_h(mode)}">
<input type="search" name="q" placeholder="Search…" value="{_h(q)}"><button type="submit">Search</button></form>
</div><div class="pills">{pills}</div>
<div class="count">{total_rows:,} products · page {page} of {total_pages}</div>
<table><thead><tr><th colspan="2">Product</th>
{"".join(f'<th class="num">{_h(l)}</th>' for _, l in RETAILERS)}
<th class="num">Cheapest</th><th class="num">You save</th></tr></thead><tbody>{body_rows}</tbody></table>
<div class="pager">
{"<a href='?"+_h(_qs({"page":page-1}))+"'>‹ Prev</a>" if page>1 else "<span class='disabled'>‹ Prev</span>"}
<span>Page {page} / {total_pages}</span>
{"<a href='?"+_h(_qs({"page":page+1}))+"'>Next ›</a>" if page<total_pages else "<span class='disabled'>Next ›</span>"}
</div></div>
<div id="modal" class="modal" hidden aria-hidden="true">
<div class="modal-backdrop" data-close></div>
<div class="modal-panel" role="dialog" aria-labelledby="modal-title">
<div class="modal-head"><h2 id="modal-title">Product detail</h2>
<button type="button" class="modal-close" data-close aria-label="Close">×</button></div>
<div id="modal-body" class="modal-body"><p class="modal-loading">Loading…</p></div>
</div></div>
<script>{_MODAL_JS}</script>
</body></html>"""


_CSS = """
:root{--bg:#f4f6f8;--card:#fff;--ink:#1c2733;--muted:#6b7886;--line:#e4e9ee;--accent:#1f9d55;--accent-soft:#e7f6ee}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:system-ui,sans-serif;font-size:14px}
header{background:var(--card);border-bottom:1px solid var(--line);padding:16px 24px;position:sticky;top:0;z-index:10}
.brand{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap}.brand h1{margin:0;font-size:20px}.brand .sub{color:var(--muted);font-size:12px}
.stats{display:flex;gap:20px;margin-top:10px;flex-wrap:wrap}.stat{font-size:12px;color:var(--muted)}.stat b{display:block;font-size:18px;color:var(--ink)}
.wrap{padding:20px 24px 60px;max-width:1280px;margin:0 auto}.toolbar{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:16px}
.tabs{display:flex;gap:4px;background:var(--card);padding:4px;border-radius:10px;border:1px solid var(--line)}
.tabs a{padding:7px 14px;border-radius:7px;text-decoration:none;color:var(--muted);font-weight:600}.tabs a.on{background:var(--accent);color:#fff}
form.search{display:flex;gap:8px;flex:1;min-width:240px}input[type=search]{flex:1;padding:9px 12px;border:1px solid var(--line);border-radius:8px}
button{padding:9px 16px;border:0;border-radius:8px;background:var(--accent);color:#fff;font-weight:600;cursor:pointer}
.pills{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px}.pill{padding:5px 11px;border-radius:999px;background:var(--card);border:1px solid var(--line);text-decoration:none;color:var(--muted);font-size:12px}.pill.on{background:var(--ink);color:#fff}
.count{color:var(--muted);margin-bottom:10px}table{width:100%;border-collapse:collapse;background:var(--card);border-radius:12px;overflow:hidden}
th,td{padding:10px 12px;text-align:left;border-bottom:1px solid var(--line);vertical-align:middle}th{font-size:11px;text-transform:uppercase;color:var(--muted);background:#fafbfc}
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}.thumb{width:42px;height:42px;object-fit:contain;border-radius:6px;border:1px solid var(--line)}
.pname{font-weight:600}.pmeta{color:var(--muted);font-size:12px}.bk{font-size:10px;text-transform:uppercase;color:var(--accent);background:var(--accent-soft);padding:2px 7px;border-radius:5px}
.price.cheap{color:var(--accent);background:var(--accent-soft);border-radius:6px;padding:4px 8px}.price.none{color:#c2cad2}
.pager{display:flex;gap:8px;justify-content:center;margin-top:18px}.pager a,.pager span{padding:7px 12px;border-radius:8px;border:1px solid var(--line);background:var(--card);text-decoration:none;color:var(--ink)}
.empty{padding:40px;text-align:center;color:var(--muted)}
.prow{cursor:pointer}.prow:hover{background:#f8fafb}
.modal{position:fixed;inset:0;z-index:100;display:flex;align-items:flex-start;justify-content:center;padding:24px}
.modal[hidden]{display:none}
.modal-backdrop{position:absolute;inset:0;background:rgba(28,39,51,.45)}
.modal-panel{position:relative;background:var(--card);border-radius:14px;max-width:920px;width:100%;max-height:calc(100vh - 48px);overflow:auto;box-shadow:0 20px 60px rgba(0,0,0,.18)}
.modal-head{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--card);z-index:1}
.modal-head h2{margin:0;font-size:17px}
.modal-close{border:0;background:transparent;font-size:24px;line-height:1;cursor:pointer;color:var(--muted);padding:4px 8px}
.modal-body{padding:16px 20px 24px}
.modal-loading{color:var(--muted)}
.ret-card{border:1px solid var(--line);border-radius:12px;padding:14px 16px;margin-bottom:12px}
.ret-card h3{margin:0 0 10px;font-size:14px;display:flex;align-items:center;gap:8px}
.ret-card.missing{background:#fafbfc;color:var(--muted)}
.ret-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px 16px;font-size:13px}
.ret-grid dt{color:var(--muted);font-size:11px;text-transform:uppercase;margin:0}
.ret-grid dd{margin:0 0 8px;font-weight:500;word-break:break-word}
.ret-img{max-width:80px;max-height:80px;object-fit:contain;border-radius:8px;border:1px solid var(--line)}
.mvp-box{background:var(--accent-soft);border-radius:10px;padding:12px 14px;margin-bottom:16px;font-size:13px}
.mvp-box b{color:var(--accent)}
"""

_MODAL_JS = """
(function(){
  const modal=document.getElementById('modal');
  const body=document.getElementById('modal-body');
  const title=document.getElementById('modal-title');
  let cache={};
  function esc(s){const d=document.createElement('div');d.textContent=s??'';return d.innerHTML;}
  function money(v){return v==null?'—':'R'+Number(v).toFixed(2);}
  function fmtDate(s){if(!s)return '—';try{return new Date(s).toLocaleString('en-ZA');}catch(e){return s;}}
  function field(label,val){if(val==null||val==='')return '';return '<dt>'+esc(label)+'</dt><dd>'+esc(String(val))+'</dd>';}
  function render(data){
    let h='';
    if(data.mvp){
      const m=data.mvp;
      h+='<div class="mvp-box"><b>MVP summary</b> · '+esc(m.bucket)+' · '
        +m.n_retailers+' retailers · PnP '+money(m.pnp_price)+' · Checkers '+money(m.checkers_price)
        +' · Woolworths '+money(m.woolworths_price)+'</div>';
    }
    const labels=data.labels||{};
    for(const key of ['pnp','checkers','woolworths']){
      const r=data.retailers[key];
      const name=labels[key]||key;
      if(!r){
        h+='<div class="ret-card missing"><h3>'+esc(name)+'</h3><p>Not in catalogue</p></div>';
        continue;
      }
      const img=r.image_url?'<img class="ret-img" src="'+esc(r.image_url)+'" alt="">':'';
      h+='<div class="ret-card"><h3>'+esc(name)+'</h3><div style="display:flex;gap:14px;flex-wrap:wrap">'
        +img+'<dl class="ret-grid">'
        +field('Store product ID',r.product_key)
        +field('Barcode (raw)',r.barcode_raw)
        +field('Barcode (norm)',r.barcode_norm)
        +field('Name',r.name)
        +field('Brand',r.brand)
        +field('Price',money(r.price))
        +field('Was price',money(r.was_price))
        +field('Bucket',r.bucket)
        +field('In-store barcode',r.is_instore_bc?'yes':'no')
        +field('Scraped at',fmtDate(r.scraped_at))
        +'</dl></div>';
      if(r.url) h+='<p style="margin:8px 0 0;font-size:12px"><a href="'+esc(r.url)+'" target="_blank" rel="noopener">View on store ↗</a></p>';
      h+='</div>';
    }
    return h;
  }
  function open(barcode){
    if(!barcode)return;
    title.textContent='Barcode '+barcode;
    body.innerHTML='<p class="modal-loading">Loading…</p>';
    modal.hidden=false; modal.setAttribute('aria-hidden','false');
    document.body.style.overflow='hidden';
    if(cache[barcode]){body.innerHTML=render(cache[barcode]);return;}
    fetch('/api/product?barcode='+encodeURIComponent(barcode),{credentials:'same-origin'})
      .then(r=>{if(!r.ok)throw new Error('HTTP '+r.status);return r.json();})
      .then(d=>{cache[barcode]=d;body.innerHTML=render(d);})
      .catch(e=>{body.innerHTML='<p class="modal-loading">Failed: '+esc(e.message)+'</p>';});
  }
  function close(){modal.hidden=true;modal.setAttribute('aria-hidden','true');document.body.style.overflow='';}
  document.querySelector('tbody').addEventListener('click',e=>{
    const row=e.target.closest('tr.prow'); if(row) open(row.dataset.barcode);
  });
  document.querySelector('tbody').addEventListener('keydown',e=>{
    if(e.key!=='Enter'&&e.key!==' ')return;
    const row=e.target.closest('tr.prow'); if(row){e.preventDefault();open(row.dataset.barcode);}
  });
  modal.addEventListener('click',e=>{if(e.target.dataset.close!==undefined)close();});
  document.addEventListener('keydown',e=>{if(e.key==='Escape'&&!modal.hidden)close();});
})();
"""


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=config.VIEW_PORT, debug=False)
