#!/usr/bin/env python3
"""
AI-powered scoring of DBA listings using Claude.

Usage:
  python3 score.py results_car_89a242.json
  python3 score.py results_rtx_3090_623595.json
  python3 score.py results_car_89a242.json --top 10     # show only top N
  python3 score.py results_car_89a242.json --save        # write ranked output to ranked_*.json
  python3 score.py results_car_89a242.json --force       # ignore cache, re-score everything
  python3 score.py results_car_89a242.json --prefs "Ikke franske biler"

Scores are cached in results_*.json — only new/unscored listings call Claude.
Change --prefs to invalidate cache and re-score with new preferences.

Requires:
  ANTHROPIC_API_KEY env var
  pip install anthropic
"""

import hashlib, json, os, re, sys, uuid as _uuid
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from pathlib import Path
import anthropic

MODEL        = "claude-haiku-4-5-20251001"   # fast + cheap; swap to sonnet for better ranking
API_KEY      = "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"
MAX_TOKENS   = 2048
BASE_DIR     = Path(__file__).parent
DATA_DIR     = BASE_DIR / "data"
SCORE_CACHE  = BASE_DIR / "data" / "score_cache"   # persistent cross-search score cache
UUID_RE      = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
METRICS_FILE = DATA_DIR / "metrics.json"

# Pricing: Claude Haiku 4.5 — https://www.anthropic.com/pricing
_PRICE_INPUT_PER_TOKEN  = 0.80 / 1_000_000   # $0.80 per MTok
_PRICE_OUTPUT_PER_TOKEN = 4.00 / 1_000_000   # $4.00 per MTok


def calc_cost(input_tokens: int, output_tokens: int) -> float:
    return round(input_tokens * _PRICE_INPUT_PER_TOKEN + output_tokens * _PRICE_OUTPUT_PER_TOKEN, 6)


def update_metrics(search_id: str, input_tokens: int, output_tokens: int, listings_scored: int) -> None:
    """Save per-search metrics and update global metrics.json."""
    cost = calc_cost(input_tokens, output_tokens)
    now  = datetime.now().isoformat(timespec="seconds")

    # Per-search metrics
    search_dir = DATA_DIR / search_id
    if search_dir.exists():
        search_metrics = {
            "search_id":       search_id,
            "scored_at":       now,
            "model":           MODEL,
            "listings_scored": listings_scored,
            "input_tokens":    input_tokens,
            "output_tokens":   output_tokens,
            "cost_usd":        cost,
        }
        (search_dir / "metrics.json").write_text(json.dumps(search_metrics, indent=2))

    # Global metrics
    global_metrics = {}
    if METRICS_FILE.exists():
        try:
            global_metrics = json.loads(METRICS_FILE.read_text())
        except Exception:
            pass

    global_metrics["total_searches"]       = global_metrics.get("total_searches", 0) + 1
    global_metrics["total_listings_scored"] = global_metrics.get("total_listings_scored", 0) + listings_scored
    global_metrics["total_input_tokens"]   = global_metrics.get("total_input_tokens", 0) + input_tokens
    global_metrics["total_output_tokens"]  = global_metrics.get("total_output_tokens", 0) + output_tokens
    global_metrics["total_cost_usd"]       = round(global_metrics.get("total_cost_usd", 0.0) + cost, 6)
    global_metrics["last_updated"]         = now

    METRICS_FILE.write_text(json.dumps(global_metrics, indent=2))


def prefs_hash(prefs: str) -> str:
    """Short stable hash of the user's preference string (empty → 'none')."""
    return hashlib.md5(prefs.strip().encode()).hexdigest()[:8] if prefs.strip() else "none"


def _score_cache_key(item_id: str, prefs: str, category: str) -> Path:
    """Return path to the persistent score cache file for this item+context."""
    ph = prefs_hash(prefs)
    ch = hashlib.md5(category.encode()).hexdigest()[:6]
    return SCORE_CACHE / f"{item_id}_{ph}_{ch}.json"


def load_score_cache(item_id: str, prefs: str, category: str) -> dict | None:
    """Return cached score dict or None if not cached."""
    p = _score_cache_key(item_id, prefs, category)
    if p.exists():
        try:
            return json.loads(p.read_text())
        except Exception:
            pass
    return None


def save_score_cache(item_id: str, prefs: str, category: str, score_data: dict) -> None:
    """Persist a score result so future searches with same item/prefs/category hit cache."""
    SCORE_CACHE.mkdir(parents=True, exist_ok=True)
    p = _score_cache_key(item_id, prefs, category)
    p.write_text(json.dumps(score_data, ensure_ascii=False))


# ── Helpers ───────────────────────────────────────────────────────────────────

def trim_text(raw: str, max_chars: int = 800) -> str:
    """Cut DBA boilerplate header/footer, keep the meat."""
    # Skip past the standard navigation header
    for marker in ["Varebeskrivelse", "Beskrivelse", "Specifikationer"]:
        idx = raw.find(marker)
        if idx != -1:
            raw = raw[idx:]
            break
    # Trim to max length
    if len(raw) > max_chars:
        raw = raw[:max_chars] + "…"
    return raw.strip()


def extract_structured_fields(raw: str) -> dict:
    """Pull key structured fields out of DBA raw_text before trimming."""
    fields = {}
    patterns = {
        "year":      r"(?:Modelår|Årstal|Årgang)[^\d]*(\d{4})",
        "km":        r"Kilometertal\s+([\d\.,]+ km)",
        "condition": r"Stand\s*:\s*([^\n|]{3,60})",
        "gear":      r"Geartype\s+(\S+)",
        "fuel":      r"Drivmiddel\s+(\S+)",
        "owners":    r"Antal ejere\s+(\d+)",
    }
    for key, pattern in patterns.items():
        m = re.search(pattern, raw, re.IGNORECASE)
        if m:
            fields[key] = m.group(1).strip()
    return fields


def listing_summary(item: dict, idx: int) -> str:
    """Compact text representation of a listing for the AI prompt."""
    raw    = item.get("details", {}).get("raw_text", item.get("description", ""))
    fields = extract_structured_fields(raw)
    text   = trim_text(raw)

    meta_parts = []
    if fields.get("year"):
        meta_parts.append(f"Årgang: {fields['year']}")
    if fields.get("km"):
        meta_parts.append(f"Km: {fields['km']}")
    if fields.get("fuel"):
        meta_parts.append(f"Brændstof: {fields['fuel']}")
    if fields.get("gear"):
        meta_parts.append(f"Gear: {fields['gear']}")
    if fields.get("owners"):
        meta_parts.append(f"Ejere: {fields['owners']}")
    if fields.get("condition"):
        meta_parts.append(f"Stand: {fields['condition']}")

    meta_line = " | ".join(meta_parts)

    return (
        f"--- Annonce #{idx + 1} (ID: {item['id']}) ---\n"
        f"Navn: {item['name']}\n"
        f"Pris: {item['price_dkk']} DKK\n"
        + (f"{meta_line}\n" if meta_line else "")
        + f"{text}\n"
    )


def detect_category(items: list[dict]) -> str:
    """Detect category from item URLs and breadcrumb in raw_text."""
    if not items:
        return "brugte varer"
    url = items[0].get("url", "")
    if "/mobility/" in url:
        return "brugte biler"

    # Extract breadcrumb from raw_text to detect subcategory
    raw = items[0].get("details", {}).get("raw_text", "")
    m = re.search(r"Du er her\s+(.+?)(?:\n|Billedgalleri)", raw)
    breadcrumb = m.group(1).lower() if m else ""

    for keywords, context_key in _CATEGORY_MAP:
        if any(kw in breadcrumb for kw in keywords):
            return context_key

    return "brugte varer"


KNOWLEDGE_CONTEXT = {
    "brugte biler": (
        "- Kendte reliabilitetsproblemer (fx Peugeot 1.2 PureTech timing-kæde, VW DSG-gearkasse, BMW N47 dieselmotor)\n"
        "- Km-stand og alder sat i forhold til markedsværdi for den specifikke model og variant\n"
        "- Kendte stærke og svage modeller (fx Toyota/Mazda høj reliabilitet, Renault/Citroën/Fiat lavere)\n"
        "- Typiske brugtpriser for modellen baseret på år og km"
    ),
    "elektronik": (
        "- Produktgenerationens relative ydelse og markedsværdi (fx RTX 4070 > RTX 3080, iPhone 15 > 13)\n"
        "- Kendte problemer: mining-slid på GPU'er, batterinedgang på telefoner/laptops, kondensatorfejl\n"
        "- Hvad er en rimelig brugtpris for dette produkt i denne stand?\n"
        "- Stand er afgørende — 'Som ny' vs 'Brugt - med synlige brugsspor' bør veje tungt"
    ),
    "sport": (
        "- Kendte mærker og deres relative kvalitet (fx Titleist/Callaway/TaylorMade til golf, Shimano-grupper til cykler)\n"
        "- Produktets alder og teknologisk forældelse (fx ældre golfkøller med stålskaft vs moderne grafit)\n"
        "- Stand er meget afgørende for sportsudstyr — slid påvirker ydeevne direkte\n"
        "- Hvad er en rimelig brugtpris for dette udstyr i denne stand og fra dette mærke?"
    ),
    "møbler": (
        "- Kendte mærker og materialer (fx massivt træ > spånplade, dansk design har høj gensalgsværdi)\n"
        "- Stand og alder — patina kan være positivt for vintage, negativt for moderne møbler\n"
        "- Originale vs efterligninger (fx IKEA POÄNG vs original Fritz Hansen)\n"
        "- Hvad er en rimelig brugtpris baseret på stand, alder og mærke?"
    ),
    "brugte varer": (
        "- Produktets markedsværdi brugt i denne stand\n"
        "- Kendte problemer eller svagheder ved denne model/variant\n"
        "- Stand er afgørende — 'Som ny' vs 'Brugt - med synlige brugsspor' bør veje tungt\n"
        "- Er varen komplet? Mangler tilbehør eller dokumentation?"
    ),
}

# Breadcrumb keywords → knowledge context key
_CATEGORY_MAP = [
    (["elektronik", "computer", "grafikkort", "telefon", "mobil", "tv", "hifi", "kamera"], "elektronik"),
    (["golf", "sport", "cykel", "fitness", "jagt", "fiskeri", "friluftsliv"], "sport"),
    (["møbel", "stol", "bord", "sofa", "seng", "reol", "lampe", "bolig", "indretning"], "møbler"),
]


def build_prompt(items: list[dict], category: str, criteria: str, prefs: str = "") -> str:
    summaries = "\n".join(listing_summary(i, n) for n, i in enumerate(items))

    prefs_block = ""
    if prefs.strip():
        prefs_block = f"""
KØBERENS EGNE PRÆFERENCER (vigtig — vej disse tungt i din scoring):
{prefs.strip()}
Annoncer der strider mod disse præferencer skal have markant lavere score.
"""

    knowledge = KNOWLEDGE_CONTEXT.get(category, KNOWLEDGE_CONTEXT["brugte varer"])

    return f"""Du er en ekspert køberrådgiver for {category} på DBA.

Brug BÅDE annonceteksten OG din egen viden om produkterne:
{knowledge}
{prefs_block}
Scorer HVER annonce UAFHÆNGIGT på en absolut skala 1-10 baseret på disse kriterier:
{criteria}

ABSOLUT SCORESKALA (brug din viden om markedet — scoren må IKKE afhænge af de andre annoncer i denne batch):
- 9-10: Fremragende køb — markant under markedspris, pålidelig model, god stand/historik
- 7-8:  Godt køb — fair pris, solid model, få eller ingen bekymringer
- 5-6:  Middel — markedspris, eller visse risici/ukendte faktorer
- 3-4:  Under middel — overpriset eller kendte modelproblem
- 1-2:  Undgå — alvorlige røde flag, stor risiko eller klart overpriset

WARNINGS — list KUN konkrete, faktuelle røde flag der er direkte støttet af annonceteksten eller veldokumenterede modelproblemer:
- Nævn KUN ting der er bekræftet i annonceteksten (fx "sælger nævner støj", "ingen billeder", "kun afhentning")
- Eller veldokumenterede modelspecifikke problemer (fx "Turbo-variant har historisk køleproblemer")
- Skriv IKKE generiske advarsler om mining, stand etc. medmindre det eksplicit nævnes i annoncen
- Hvis ingen konkrete røde flag: tom streng ""

Returner KUN et JSON-array — ingen forklaringer udenfor JSON:
[
  {{
    "id": "annonce-ID",
    "score": 8.5,
    "reason": "Begrundelse på dansk (maks 2 sætninger). Nævn gerne konkret viden om modellen.",
    "warnings": "Kun konkrete røde flag fra annonceteksten eller kendte modelproblemer. Tom streng hvis ingen."
  }},
  ...
]

Alle {len(items)} annoncer skal med. Score er 1-10 (10 = suverænt køb).

ANNONCER:
{summaries}"""


# ── Scoring ───────────────────────────────────────────────────────────────────

def score_listings(
    items: list[dict],
    criteria: str,
    prefs: str = "",
    batch_size: int = 10,
    force: bool = False,
    source_file: Path | None = None,
) -> list[dict]:
    """Score listings with AI — skips items that are already cached. Runs batches in parallel."""
    client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY", API_KEY))
    category = detect_category(items)
    phash = prefs_hash(prefs)

    # ── Split: persistent score cache → in-file cache → needs AI scoring ────
    to_score, cached = [], []
    now = datetime.now().isoformat(timespec="seconds")
    for item in items:
        if not force:
            # 1. Check persistent cross-search score cache
            sc = load_score_cache(str(item["id"]), prefs, category)
            if sc:
                item["ai_score"]    = sc["score"]
                item["ai_rank"]     = sc.get("rank")
                item["ai_reason"]   = sc.get("reason", "")
                item["ai_warnings"] = sc.get("warnings", "")
                item["ai_prefs_hash"] = phash
                item["ai_scored_at"]  = sc.get("scored_at", now)
                cached.append(item)
                continue
            # 2. In-file cache (same search UUID, already scored)
            if item.get("ai_score") is not None and item.get("ai_prefs_hash") == phash:
                cached.append(item)
                continue
        to_score.append(item)

    if cached:
        print(f"  ♻️  {len(cached)} annoncer genbruger cache", file=sys.stderr)
    if to_score:
        print(f"  🤖 {len(to_score)} annoncer sendes til AI…", file=sys.stderr)
    elif not cached:
        print("  Ingen annoncer at score.", file=sys.stderr)
        return []

    # ── Score only uncached items — parallel batches ──────────────────────────
    all_scores: dict[str, dict] = {}
    if to_score:
        batches = [to_score[i:i + batch_size] for i in range(0, len(to_score), batch_size)]
        print(f"  ({len(batches)} parallelle batches à max {batch_size})", file=sys.stderr)

        def score_batch(b_idx: int, batch: list[dict]) -> tuple[dict[str, dict], int, int]:
            prompt = build_prompt(batch, category, criteria, prefs)
            response = client.messages.create(
                model=MODEL,
                max_tokens=MAX_TOKENS,
                temperature=0,
                messages=[{"role": "user", "content": prompt}],
            )
            inp = response.usage.input_tokens
            out = response.usage.output_tokens
            text = response.content[0].text.strip()
            json_m = re.search(r"\[.*\]", text, re.DOTALL)
            if not json_m:
                print(f"  ⚠ Kunne ikke parse svar fra batch {b_idx + 1}:\n{text[:300]}", file=sys.stderr)
                return {}, inp, out
            result = {}
            for s in json.loads(json_m.group(0)):
                result[str(s["id"])] = s
            print(f"  ✓ Batch {b_idx + 1}/{len(batches)} færdig ({len(result)} scores, {inp}+{out} tok)", file=sys.stderr)
            return result, inp, out

        total_input = total_output = 0
        with ThreadPoolExecutor(max_workers=min(len(batches), 8)) as pool:
            futures = {pool.submit(score_batch, i, b): i for i, b in enumerate(batches)}
            for future in as_completed(futures):
                scores, inp, out = future.result()
                all_scores.update(scores)
                total_input  += inp
                total_output += out

        # Write scores + cache metadata back onto items
        now = datetime.now().isoformat(timespec="seconds")
        for item in to_score:
            s = all_scores.get(str(item["id"]), {})
            if s:
                item["ai_score"]    = s.get("score")
                item["ai_rank"]     = s.get("rank")
                item["ai_reason"]   = s.get("reason", "")
                item["ai_warnings"] = s.get("warnings", "")
                # Persist to cross-search score cache so same item never re-scored
                save_score_cache(str(item["id"]), prefs, category, {
                    "score":    s.get("score"),
                    "rank":     s.get("rank"),
                    "reason":   s.get("reason", ""),
                    "warnings": s.get("warnings", ""),
                    "scored_at": now,
                })
            item["ai_prefs_hash"] = phash
            item["ai_scored_at"]  = now

        # Auto-save scores back into source file so cache persists next run
        if source_file:
            all_items_map = {str(i["id"]): i for i in cached + to_score}
            source_file.write_text(json.dumps(list(all_items_map.values()), ensure_ascii=False, indent=2))
            scored_count = sum(1 for i in to_score if i.get("ai_score") is not None)
            print(f"  💾 {scored_count} nye scores gemt → {source_file}", file=sys.stderr)
            cost = calc_cost(total_input, total_output)
            print(f"  💰 {total_input}+{total_output} tokens → ${cost:.4f}", file=sys.stderr)
            update_metrics(source_file.parent.name, total_input, total_output, scored_count)

    # ── Combine, re-sort, re-rank ─────────────────────────────────────────────
    combined = [i for i in (cached + to_score) if i.get("ai_score") is not None]
    combined.sort(key=lambda x: x["ai_score"], reverse=True)
    for rank, item in enumerate(combined, 1):
        item["ai_rank"] = rank

    return combined


# ── Output ────────────────────────────────────────────────────────────────────

def print_results(ranked: list[dict], top: int | None = None) -> None:
    show = ranked[:top] if top else ranked
    print(f"\n{'═' * 60}")
    print(f"  TOP {len(show)} ANNONCER (af {len(ranked)} scoret)")
    print(f"{'═' * 60}\n")
    for item in show:
        score = item.get("ai_score", "?")
        bar   = "█" * int(score) + "░" * (10 - int(score)) if isinstance(score, (int, float)) else ""
        print(
            f"#{item['ai_rank']:>2}  [{score:4.1f}] {bar}  {item['name']}\n"
            f"      Pris: {item['price_dkk']} DKK  |  {item['url']}\n"
            f"      ✅ {item.get('ai_reason','')}\n"
        )
        if item.get("ai_warnings"):
            print(f"      ⚠️  {item['ai_warnings']}\n")


# ── Main ──────────────────────────────────────────────────────────────────────

CRITERIA = {
    "mobility": (
        "- Pris ift. markedsværdi for den specifikke model/år/km (brug din viden)\n"
        "- Modelreliabilitet og kendte svagheder (timing-kæde, gearkasse, rust etc.)\n"
        "- Km-stand og alder (Årgang og Kilometertal er angivet hvis tilgængeligt)\n"
        "- Privat sælger foretrukket (forhandler = højere pris, ingen reklamationsret ved brugt)\n"
        "- Servicehistorik, nysynet, tandrem nævnt?\n"
        "- Udstyrsniveau og antal ejere"
    ),
    "recommerce": (
        "- Pris ift. aktuel markedsværdi for produktet (brug din viden om typiske priser)\n"
        "- Produktgenerationens relative ydelse og værdi (fx GPU-generationer, produktionsår)\n"
        "- Stand (DBA's standbeskrivelse er angivet: 'Som ny', 'Brugt - men i god stand', 'Brugt - med synlige brugsspor')\n"
        "- Kendte problemer med denne model/variant\n"
        "- Er varen komplet? Mangler tilbehør?\n"
        "- Privat sælger foretrukket"
    ),
}


def main() -> None:
    if "ANTHROPIC_API_KEY" not in os.environ and not API_KEY:
        print("Fejl: ANTHROPIC_API_KEY er ikke sat.", file=sys.stderr)
        sys.exit(1)

    # Parse args properly — handles both --top 3 and --top=3
    top_n = None
    prefs = ""
    force = False
    save  = False
    positional = []

    argv = sys.argv[1:]
    i = 0
    while i < len(argv):
        a = argv[i]
        if a in ("--top", "--prefs") and i + 1 < len(argv):
            if a == "--top":
                top_n = int(argv[i + 1])
            else:
                prefs = argv[i + 1]
            i += 2
        elif a.startswith("--top="):
            top_n = int(a[6:])
            i += 1
        elif a.startswith("--prefs="):
            prefs = a[8:]
            i += 1
        elif a == "--force":
            force = True
            i += 1
        elif a == "--save":
            save = True
            i += 1
        elif not a.startswith("--"):
            positional.append(a)
            i += 1
        else:
            i += 1

    if not positional:
        # Auto-detect: most recent data/<uuid>/listings.json
        searches = sorted(
            (d for d in DATA_DIR.iterdir() if (d / "listings.json").exists()),
            key=lambda d: d.stat().st_mtime, reverse=True
        ) if DATA_DIR.exists() else []
        if not searches:
            print("Ingen søgninger fundet. Kør fetch_dba.py <url> først.", file=sys.stderr)
            sys.exit(1)
        search_dir  = searches[0]
        results_file = search_dir / "listings.json"
        print(f"Bruger nyeste søgning: {search_dir.name}", file=sys.stderr)
    else:
        ref = positional[0]
        if UUID_RE.match(ref):
            results_file = DATA_DIR / ref / "listings.json"
        else:
            results_file = Path(ref)
        if not results_file.exists():
            print(f"Fejl: {results_file} ikke fundet.", file=sys.stderr)
            sys.exit(1)

    items = json.loads(results_file.read_text())
    print(f"Loaded {len(items)} annoncer fra {results_file}", file=sys.stderr)

    domain   = "mobility" if items and "/mobility/" in items[0].get("url", "") else "recommerce"
    criteria = CRITERIA[domain]

    # ── Interaktiv refinement-løkke (op til 3 forsøg) ────────────────────────
    MAX_ROUNDS  = 3
    interactive = sys.stdin.isatty() and not prefs

    for attempt in range(MAX_ROUNDS):
        if prefs:
            print(f"\n🎯 Præferencer: {prefs}", file=sys.stderr)

        ranked = score_listings(items, criteria, prefs, force=force, source_file=results_file)
        # After first run, don't force re-score on subsequent interactive rounds
        force = False
        print_results(ranked, top_n)

        if save:
            out = results_file.parent / "ranked.json"
            out.write_text(json.dumps(ranked, ensure_ascii=False, indent=2))
            print(f"\n💾 Ranked output gemt → {out}", file=sys.stderr)

        if not interactive or attempt >= MAX_ROUNDS - 1:
            break

        remaining = MAX_ROUNDS - attempt - 1
        print(f"\n{'─' * 60}")
        print(f"  Tilføj præferencer for at re-score ({remaining} forsøg tilbage)")
        print(f"  Eks: 'Ikke franske biler' / 'Helst manuel gear' / 'Max 50 km fra Aarhus'")
        print(f"  (Tryk Enter for at afslutte)")
        print(f"{'─' * 60}")

        try:
            new_prefs = input("  > ").strip()
        except (EOFError, KeyboardInterrupt):
            break

        if not new_prefs:
            break

        prefs = f"{prefs}\n{new_prefs}".strip() if prefs else new_prefs
        # Force re-score when prefs change (cache hash will differ anyway, but be explicit)
        force = True
        print(f"\n🔄 Re-scorer med dine præferencer…\n", file=sys.stderr)


if __name__ == "__main__":
    main()
