"""OK ITHAKI R10F42 privacy-first site analytics.

No cookies, no fingerprinting, no raw IP storage. A daily HMAC pseudonym is used
only for approximate unique-visitor counts. Optional GeoLite2 lookup stays local.
"""
from __future__ import annotations

import hashlib
import hmac
import html
import json
import os
import re
import secrets
import sqlite3
import time
from collections import Counter, defaultdict
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any

from fastapi import Header, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse

VERSION = "249526r10f42"
RETENTION_DAYS = 395
MAX_PATH = 500
MAX_REF = 300
MAX_UA = 500
BOT_RE = re.compile(r"bot|spider|crawler|slurp|bingpreview|facebookexternalhit|whatsapp|telegrambot|curl|wget", re.I)
DOWNLOAD_RE = re.compile(r"\.(apk|aab|zip|exe|msi|dmg|pkg|pdf|txt|sha256)(?:$|\?)", re.I)


def _now() -> float:
    return time.time()


def _db(root: Path) -> sqlite3.Connection:
    data = root / "data"
    data.mkdir(parents=True, exist_ok=True)
    con = sqlite3.connect(str(data / "site_analytics_r10f42.sqlite"), timeout=20)
    con.row_factory = sqlite3.Row
    con.execute("PRAGMA journal_mode=WAL")
    con.execute("PRAGMA busy_timeout=10000")
    con.execute(
        """
        CREATE TABLE IF NOT EXISTS site_events_r10f42(
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          created_at REAL NOT NULL,
          day TEXT NOT NULL,
          hour TEXT NOT NULL,
          month TEXT NOT NULL,
          visitor_hash TEXT NOT NULL,
          event_type TEXT NOT NULL,
          path TEXT NOT NULL,
          download_name TEXT NOT NULL DEFAULT '',
          referrer_host TEXT NOT NULL DEFAULT '',
          browser TEXT NOT NULL DEFAULT 'Other',
          os TEXT NOT NULL DEFAULT 'Other',
          device TEXT NOT NULL DEFAULT 'Other',
          country TEXT NOT NULL DEFAULT 'Unknown',
          region TEXT NOT NULL DEFAULT 'Unknown',
          language TEXT NOT NULL DEFAULT '',
          timezone TEXT NOT NULL DEFAULT '',
          bot INTEGER NOT NULL DEFAULT 0,
          status INTEGER NOT NULL DEFAULT 0,
          bytes INTEGER NOT NULL DEFAULT 0
        )
        """
    )
    con.execute("CREATE INDEX IF NOT EXISTS idx_site_events42_created ON site_events_r10f42(created_at)")
    con.execute("CREATE INDEX IF NOT EXISTS idx_site_events42_day ON site_events_r10f42(day,event_type)")
    con.execute("CREATE INDEX IF NOT EXISTS idx_site_events42_path ON site_events_r10f42(path,event_type)")
    con.commit()
    return con


def _secret(root: Path, name: str, nbytes: int = 32) -> str:
    p = root / "data" / name
    p.parent.mkdir(parents=True, exist_ok=True)
    if p.exists():
        value = p.read_text(encoding="utf-8").strip()
        if value:
            return value
    value = secrets.token_hex(nbytes)
    p.write_text(value + "\n", encoding="utf-8")
    try:
        os.chmod(p, 0o600)
    except OSError:
        pass
    return value


def _client_ip(request: Request) -> str:
    for header in ("cf-connecting-ip", "x-real-ip", "x-forwarded-for"):
        raw = request.headers.get(header, "")
        if raw:
            return raw.split(",", 1)[0].strip()[:80]
    return (request.client.host if request.client else "")[:80]


def _visitor_hash(root: Path, ip: str, day: str) -> str:
    key = _secret(root, ".site_analytics_hmac_r10f42", 32).encode()
    return hmac.new(key, f"{day}|{ip}".encode(), hashlib.sha256).hexdigest()[:24]


def _ua_parts(ua: str) -> tuple[str, str, str, bool]:
    u = ua or ""
    bot = bool(BOT_RE.search(u))
    if "Edg/" in u:
        browser = "Edge"
    elif "OPR/" in u or "Opera" in u:
        browser = "Opera"
    elif "Firefox/" in u:
        browser = "Firefox"
    elif "SamsungBrowser/" in u:
        browser = "Samsung Internet"
    elif "Chrome/" in u or "CriOS/" in u:
        browser = "Chrome"
    elif "Safari/" in u:
        browser = "Safari"
    else:
        browser = "Other"
    if "Android" in u:
        os_name = "Android"
    elif "iPhone" in u or "iPad" in u:
        os_name = "iOS/iPadOS"
    elif "Windows" in u:
        os_name = "Windows"
    elif "Mac OS" in u or "Macintosh" in u:
        os_name = "macOS"
    elif "Linux" in u:
        os_name = "Linux"
    else:
        os_name = "Other"
    if "Mobile" in u or "Android" in u or "iPhone" in u:
        device = "Mobile"
    elif "iPad" in u or "Tablet" in u:
        device = "Tablet"
    else:
        device = "Desktop"
    return browser, os_name, device, bot


def _ref_host(ref: str) -> str:
    try:
        from urllib.parse import urlparse
        return (urlparse(ref).hostname or "")[:MAX_REF]
    except Exception:
        return ""


def _geo(ip: str) -> tuple[str, str]:
    candidates = [
        Path("/opt/ithaki/shared/geoip/GeoLite2-City.mmdb"),
        Path("/usr/share/GeoIP/GeoLite2-City.mmdb"),
        Path("/usr/local/share/GeoIP/GeoLite2-City.mmdb"),
    ]
    db_path = next((p for p in candidates if p.exists()), None)
    if not db_path:
        return "Unknown", "Unknown"
    try:
        import maxminddb  # optional; database is not redistributed
        with maxminddb.open_database(str(db_path)) as reader:
            d = reader.get(ip) or {}
        country = (((d.get("country") or {}).get("names") or {}).get("en") or (d.get("country") or {}).get("iso_code") or "Unknown")
        subs = d.get("subdivisions") or []
        region = (((subs[0].get("names") or {}).get("en") if subs else "") or (subs[0].get("iso_code") if subs else "") or "Unknown")
        return str(country)[:80], str(region)[:100]
    except Exception:
        return "Unknown", "Unknown"


def _clean_path(value: Any) -> str:
    s = str(value or "/").strip()
    if not s.startswith("/"):
        s = "/" + s
    return s[:MAX_PATH]


def _token_ok(root: Path, token: str) -> bool:
    expected = _secret(root, ".site_analytics_admin_token_r10f42", 24)
    return hmac.compare_digest(str(token or ""), expected)


def _admin_token(request: Request, query_token: str = "", header_token: str = "") -> str:
    return header_token or query_token or request.headers.get("x-ithaki-analytics-token", "")


def _record(root: Path, request: Request, payload: dict[str, Any], event_type: str) -> dict[str, Any]:
    now = datetime.now(timezone.utc)
    ip = _client_ip(request)
    ua = request.headers.get("user-agent", "")[:MAX_UA]
    browser, os_name, device, bot = _ua_parts(ua)
    country, region = _geo(ip)
    path = _clean_path(payload.get("path") or request.headers.get("x-ithaki-page") or "/")
    download_name = str(payload.get("download_name") or "")[:180]
    if event_type == "download" and not download_name:
        download_name = Path(path.split("?", 1)[0]).name[:180]
    referrer = str(payload.get("referrer") or request.headers.get("referer") or "")[:MAX_REF]
    row = (
        now.timestamp(), now.strftime("%Y-%m-%d"), now.strftime("%Y-%m-%dT%H"), now.strftime("%Y-%m"),
        _visitor_hash(root, ip, now.strftime("%Y-%m-%d")), event_type, path, download_name,
        _ref_host(referrer), browser, os_name, device, country, region,
        str(payload.get("language") or "")[:30], str(payload.get("timezone") or "")[:80],
        1 if bot else 0, int(payload.get("status") or 0), int(payload.get("bytes") or 0),
    )
    with _db(root) as con:
        con.execute(
            """INSERT INTO site_events_r10f42(created_at,day,hour,month,visitor_hash,event_type,path,download_name,referrer_host,browser,os,device,country,region,language,timezone,bot,status,bytes)
               VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
            row,
        )
        con.execute("DELETE FROM site_events_r10f42 WHERE created_at<?", (_now() - RETENTION_DAYS * 86400,))
        con.commit()
    return {"ok": True, "version": VERSION, "event_type": event_type}


def _series(con: sqlite3.Connection, field: str, since: float, where: str = "") -> list[dict[str, Any]]:
    allowed = {"hour", "day", "month"}
    if field not in allowed:
        raise ValueError(field)
    sql = f"SELECT {field} label, COUNT(*) events, COUNT(DISTINCT visitor_hash) visitors FROM site_events_r10f42 WHERE created_at>=? {where} GROUP BY {field} ORDER BY {field}"
    return [dict(r) for r in con.execute(sql, (since,)).fetchall()]


def _top(con: sqlite3.Connection, field: str, since: float, limit: int = 12, where: str = "") -> list[dict[str, Any]]:
    allowed = {"path", "download_name", "country", "region", "browser", "os", "device", "referrer_host"}
    if field not in allowed:
        raise ValueError(field)
    sql = f"SELECT {field} label, COUNT(*) events, COUNT(DISTINCT visitor_hash) visitors FROM site_events_r10f42 WHERE created_at>=? {where} AND {field}!='' GROUP BY {field} ORDER BY events DESC LIMIT ?"
    return [dict(r) for r in con.execute(sql, (since, limit)).fetchall()]


def _summary(root: Path, days: int) -> dict[str, Any]:
    days = max(1, min(int(days), RETENTION_DAYS))
    since = _now() - days * 86400
    with _db(root) as con:
        total = dict(con.execute("SELECT COUNT(*) events, COUNT(DISTINCT day||':'||visitor_hash) visitor_days, SUM(event_type='download') downloads, SUM(bot=1) bot_events FROM site_events_r10f42 WHERE created_at>=?", (since,)).fetchone())
        return {
            "ok": True,
            "version": VERSION,
            "period_days": days,
            "retention_days": RETENTION_DAYS,
            "privacy": {"cookies": False, "fingerprinting": False, "raw_ip_stored": False, "visitor_identity": "daily_hmac_pseudonym", "geoip_optional_local": True},
            "totals": total,
            "hourly": _series(con, "hour", max(since, _now() - 48 * 3600)),
            "daily": _series(con, "day", max(since, _now() - 62 * 86400)),
            "monthly": _series(con, "month", since),
            "pages": _top(con, "path", since, 20, "AND event_type='visit'"),
            "downloads": _top(con, "download_name", since, 20, "AND event_type='download'"),
            "countries": _top(con, "country", since, 20),
            "regions": _top(con, "region", since, 20),
            "browsers": _top(con, "browser", since, 12),
            "devices": _top(con, "device", since, 8),
            "referrers": _top(con, "referrer_host", since, 15),
        }


DASHBOARD = r'''<!doctype html><html lang="el"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>OK ITHAKI · Site Pulse</title><style>
:root{color-scheme:dark;font-family:Inter,system-ui,sans-serif}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 80% 0,#173d58,#07131f 42%,#03090e);color:#eef9ff}.wrap{max-width:1250px;margin:auto;padding:20px}.head{display:flex;gap:14px;align-items:center;flex-wrap:wrap}.head h1{margin:0;flex:1}.pill,.card{border:1px solid #ffffff1c;background:#ffffff0b;border-radius:18px;box-shadow:0 16px 40px #0005}.pill{padding:8px 12px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:12px;margin:16px 0}.card{padding:16px}.metric{font-size:32px;font-weight:900;color:#ffe5a5}.muted{color:#a9c2d0}.charts{display:grid;grid-template-columns:repeat(auto-fit,minmax(350px,1fr));gap:14px}.chart{min-height:300px}.bars{display:flex;align-items:end;gap:3px;height:210px;margin-top:18px;border-bottom:1px solid #ffffff20}.bar{flex:1;min-width:3px;background:linear-gradient(#58e7b0,#d8b45c);border-radius:6px 6px 0 0;position:relative}.bar:hover:after{content:attr(data-tip);position:absolute;bottom:100%;left:50%;transform:translateX(-50%);background:#02070b;padding:6px;border-radius:8px;white-space:nowrap;z-index:4}.table{width:100%;border-collapse:collapse}.table td,.table th{padding:8px;border-bottom:1px solid #ffffff12;text-align:left}.controls{display:flex;gap:8px;flex-wrap:wrap}.controls input,.controls button,.controls select{background:#071925;color:#fff;border:1px solid #ffffff24;border-radius:12px;padding:10px}.danger{color:#ffacb6}.ok{color:#80efbe}@media(max-width:600px){.wrap{padding:10px}.charts{grid-template-columns:1fr}.chart{min-height:250px}}</style></head><body><main class="wrap"><div class="head"><h1>⛵ OK ITHAKI · Site Pulse</h1><span class="pill">Χωρίς cookies · χωρίς raw IP</span></div><p class="muted">Ιδιωτικός πίνακας επισκέψεων, γεωγραφίας, προγραμμάτων περιήγησης και λήψεων.</p><div class="controls"><input id="token" type="password" placeholder="Admin token"><select id="days"><option value="1">24 ώρες</option><option value="7">7 ημέρες</option><option value="30" selected>30 ημέρες</option><option value="90">90 ημέρες</option><option value="395">13 μήνες</option></select><button onclick="loadData()">Ανανέωση</button><button onclick="exportCsv()">Εξαγωγή CSV</button></div><div id="status" class="muted"></div><section class="grid" id="metrics"></section><section class="charts"><article class="card chart"><h2>Ανά ώρα</h2><div class="bars" id="hourly"></div></article><article class="card chart"><h2>Ανά ημέρα</h2><div class="bars" id="daily"></div></article><article class="card chart"><h2>Ανά μήνα</h2><div class="bars" id="monthly"></div></article><article class="card"><h2>Λήψεις</h2><div id="downloads"></div></article><article class="card"><h2>Χώρες / περιοχές</h2><div id="geo"></div></article><article class="card"><h2>Browsers / συσκευές</h2><div id="tech"></div></article><article class="card"><h2>Σελίδες</h2><div id="pages"></div></article><article class="card"><h2>Πηγές</h2><div id="refs"></div></article></section><p class="muted">Οι μοναδικοί επισκέπτες είναι προσεγγιστικοί ανά ημέρα. Το GeoIP λειτουργεί μόνο όταν εγκατασταθεί τοπική βάση GeoLite2 City στον server.</p></main><script>
const $=id=>document.getElementById(id);function table(rows){return '<table class="table"><tr><th>Στοιχείο</th><th>Γεγονότα</th><th>Επισκέπτες</th></tr>'+((rows||[]).map(x=>`<tr><td>${String(x.label||'—').replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]))}</td><td>${x.events||0}</td><td>${x.visitors||0}</td></tr>`).join('')||'<tr><td colspan=3>Δεν υπάρχουν ακόμη δεδομένα</td></tr>')+'</table>'}function bars(id,rows){const e=$(id),m=Math.max(1,...(rows||[]).map(x=>x.events||0));e.innerHTML=(rows||[]).map(x=>`<i class="bar" style="height:${Math.max(2,Math.round((x.events||0)/m*100))}%" data-tip="${x.label}: ${x.events}"></i>`).join('')}async function loadData(){const token=$('token').value.trim();sessionStorage.setItem('ithakiPulseToken',token);$('status').textContent='Φόρτωση…';try{const r=await fetch('/api/site-analytics/r10f42/summary?days='+$('days').value,{cache:'no-store',headers:{'X-Ithaki-Analytics-Token':token}});const d=await r.json();if(!r.ok)throw Error(d.detail||r.status);const t=d.totals||{};$('metrics').innerHTML=`<article class=card><div class=metric>${t.events||0}</div><b>Συνολικά γεγονότα</b></article><article class=card><div class=metric>${t.visitor_days||0}</div><b>Επισκέπτες/ημέρα</b></article><article class=card><div class=metric>${t.downloads||0}</div><b>Λήψεις</b></article><article class=card><div class=metric>${t.bot_events||0}</div><b>Bot γεγονότα</b></article>`;bars('hourly',d.hourly);bars('daily',d.daily);bars('monthly',d.monthly);$('downloads').innerHTML=table(d.downloads);$('geo').innerHTML='<h3>Χώρες</h3>'+table(d.countries)+'<h3>Περιοχές</h3>'+table(d.regions);$('tech').innerHTML='<h3>Browsers</h3>'+table(d.browsers)+'<h3>Συσκευές</h3>'+table(d.devices);$('pages').innerHTML=table(d.pages);$('refs').innerHTML=table(d.referrers);$('status').innerHTML='<span class=ok>Ενημερώθηκε '+new Date().toLocaleString()+'</span>'}catch(e){$('status').innerHTML='<span class=danger>'+e.message+'</span>'}}async function exportCsv(){const token=$('token').value.trim();if(!token)return;$('status').textContent='Δημιουργία CSV…';try{const r=await fetch('/api/site-analytics/r10f42/export.csv?days='+$('days').value,{headers:{'X-Ithaki-Analytics-Token':token}});if(!r.ok)throw Error((await r.text())||r.status);const b=await r.blob(),u=URL.createObjectURL(b),a=document.createElement('a');a.href=u;a.download='ok-ithaki-site-pulse-'+new Date().toISOString().slice(0,10)+'.csv';a.click();setTimeout(()=>URL.revokeObjectURL(u),1000);$('status').textContent='Το CSV δημιουργήθηκε.'}catch(e){$('status').innerHTML='<span class=danger>'+e.message+'</span>'}}$('token').value=sessionStorage.getItem('ithakiPulseToken')||'';if($('token').value)loadData();
</script></body></html>'''


def install(app: Any, root: Path) -> None:
    root = Path(root)
    _db(root).close()
    _secret(root, ".site_analytics_admin_token_r10f42", 24)

    @app.post("/api/site-analytics/r10f42/visit")
    async def site_visit_r10f42(request: Request):
        try:
            payload = await request.json()
            if not isinstance(payload, dict):
                payload = {}
        except Exception:
            payload = {}
        return _record(root, request, payload, "visit")

    @app.post("/api/site-analytics/r10f42/download")
    async def site_download_r10f42(request: Request):
        try:
            payload = await request.json()
            if not isinstance(payload, dict):
                payload = {}
        except Exception:
            payload = {}
        return _record(root, request, payload, "download")

    @app.get("/api/site-analytics/r10f42/download-file")
    def site_download_file_r10f42(request: Request, name: str = Query(..., min_length=1, max_length=180)):
        # Fixed host + strict basename: this is a counter/redirect, never an open redirect.
        clean = Path(name).name
        if clean != name or not re.fullmatch(r"[A-Za-z0-9._-]+", clean) or not DOWNLOAD_RE.search(clean):
            raise HTTPException(400, "invalid download name")
        _record(root, request, {"path": "/downloads/" + clean, "download_name": clean}, "download")
        from urllib.parse import quote
        return RedirectResponse("https://www.ok-ithaki.gr/downloads/" + quote(clean), status_code=302, headers={"Cache-Control": "no-store", "X-Robots-Tag": "noindex"})

    @app.get("/api/site-analytics/r10f42/summary")
    def site_summary_r10f42(request: Request, days: int = Query(30, ge=1, le=395), token: str = Query(""), x_ithaki_analytics_token: str = Header("")):
        if not _token_ok(root, _admin_token(request, token, x_ithaki_analytics_token)):
            raise HTTPException(403, "invalid analytics token")
        return _summary(root, days)

    @app.get("/api/site-analytics/r10f42/export.csv")
    def site_export_r10f42(request: Request, days: int = Query(30, ge=1, le=395), token: str = Query(""), x_ithaki_analytics_token: str = Header("")):
        if not _token_ok(root, _admin_token(request, token, x_ithaki_analytics_token)):
            raise HTTPException(403, "invalid analytics token")
        import csv, io
        since = _now() - max(1, min(int(days), RETENTION_DAYS)) * 86400
        out = io.StringIO()
        writer = csv.writer(out)
        writer.writerow(["created_at_utc","event_type","path","download_name","referrer_host","browser","os","device","country","region","language","timezone","bot","status","bytes"])
        with _db(root) as con:
            for r in con.execute("SELECT created_at,event_type,path,download_name,referrer_host,browser,os,device,country,region,language,timezone,bot,status,bytes FROM site_events_r10f42 WHERE created_at>=? ORDER BY created_at DESC", (since,)):
                writer.writerow([datetime.fromtimestamp(float(r[0]), timezone.utc).isoformat(), *list(r)[1:]])
        data = out.getvalue().encode("utf-8-sig")
        return StreamingResponse(iter([data]), media_type="text/csv; charset=utf-8", headers={"Content-Disposition":"attachment; filename=ok-ithaki-site-pulse.csv","Cache-Control":"no-store","X-Robots-Tag":"noindex"})

    @app.get("/api/site-analytics/r10f42/status")
    def site_status_r10f42(request: Request, token: str = Query(""), x_ithaki_analytics_token: str = Header("")):
        if not _token_ok(root, _admin_token(request, token, x_ithaki_analytics_token)):
            raise HTTPException(403, "invalid analytics token")
        paths = ["/opt/ithaki/shared/geoip/GeoLite2-City.mmdb", "/usr/share/GeoIP/GeoLite2-City.mmdb", "/usr/local/share/GeoIP/GeoLite2-City.mmdb"]
        return {"ok": True, "version": VERSION, "geoip_database": next((p for p in paths if Path(p).exists()), ""), "retention_days": RETENTION_DAYS, "raw_ip_stored": False, "cookies": False}

    @app.get("/admin/site-pulse-r10f42/", response_class=HTMLResponse)
    def site_dashboard_r10f42():
        return HTMLResponse(DASHBOARD, headers={"Cache-Control": "no-store", "X-Robots-Tag": "noindex, nofollow"})
