#!/usr/bin/env python3
"""
Aria Brain Think — KAR-622

Synthesis + Gap-Analysis Query-Layer on top of the existing BM25 search.
Modell: gbrain's `gbrain think` vs `gbrain search`.

  search → liefert Treffer-Liste
  think  → liefert synthetisierte Antwort + explizites GAP ANALYSIS Report

Gap-Kategorien:
  STALE        — Treffer die zu alt sind für zeitkritische Queries
  UNCITED      — Antwort-Claims ohne Backing-Page
  CONTRADICTION — Heuristisches Signal wenn zwei Treffer widersprechen
  MISSING      — Query-Aspekte die kein Treffer abdeckt

LLM-Modus:
  Versucht autoatisch Gemini-Flash (billigste Option), dann DeepSeek, dann OpenAI.
  Mit --no-llm wird eine deterministische Extraktions-Synthese verwendet.

CLI:
  aria-brain-think "<query>" [--top N] [--no-llm] [--json] [--stale-days N]

JSON-Output-Schema:
  {
    "query": str,
    "answer": str,
    "citations": [{"slug": str, "path": str, "score": float, "preview": str}],
    "gaps": {
      "stale": [{"path": str, "age_days": int, "reason": str}],
      "uncited": [str],
      "contradiction": [{"paths": [str, str], "signal": str}],
      "missing": [str]
    },
    "mode": "llm" | "extractive",
    "llm_provider": str | null,
    "llm_model": str | null
  }
"""

from __future__ import annotations

import argparse
import json
import math
import os
import re
import sys
from datetime import datetime
from pathlib import Path

# Add scripts dir to path so we can import aria-brain-search as module
SCRIPTS_DIR = Path(__file__).parent
sys.path.insert(0, str(SCRIPTS_DIR))

# Import search module (hyphens in filename — use importlib)
import importlib.util

_search_spec = importlib.util.spec_from_file_location(
    "aria_brain_search", SCRIPTS_DIR / "aria-brain-search.py"
)
_search_mod = importlib.util.module_from_spec(_search_spec)
_search_spec.loader.exec_module(_search_mod)

search = _search_mod.search
parse_frontmatter = _search_mod.parse_frontmatter
tokenize = _search_mod.tokenize

BRAIN_DIR = Path(os.path.expandvars("$HOME/aria/brain"))
SECRETS_DIR = Path("/root/.aria-secrets")

# ---------------------------------------------------------------------------
# Frontmatter date helpers
# ---------------------------------------------------------------------------

def _read_full_frontmatter(rel_path: str) -> dict:
    """Reads full frontmatter from a brain file by its relative path."""
    p = BRAIN_DIR / rel_path
    if not p.exists():
        return {}
    try:
        text = p.read_text(encoding="utf-8", errors="replace")
    except OSError:
        return {}
    fm, _ = parse_frontmatter(text)
    return fm


def _file_age_days(rel_path: str) -> float | None:
    """Returns age of a brain file in days (mtime-based). None if file missing."""
    p = BRAIN_DIR / rel_path
    if not p.exists():
        return None
    try:
        mtime = p.stat().st_mtime
        return (datetime.now().timestamp() - mtime) / 86400
    except OSError:
        return None


def _frontmatter_date_age_days(fm: dict) -> float | None:
    """Parses `date` from frontmatter, returns age in days. None if unparseable."""
    raw = fm.get("date", "")
    if not raw:
        return None
    for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%Y-%m"):
        try:
            d = datetime.strptime(raw.strip(), fmt)
            return (datetime.now() - d).days
        except ValueError:
            continue
    return None


# ---------------------------------------------------------------------------
# Secrets / Key Loading
# ---------------------------------------------------------------------------

def _load_secret(filename: str, key_name: str) -> str:
    """Loads a key from /root/.aria-secrets/<filename> by variable name."""
    path = SECRETS_DIR / filename
    if not path.exists():
        return ""
    for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
        line = line.strip()
        if line.startswith(key_name + "="):
            val = line.split("=", 1)[1].strip().strip('"').strip("'")
            return val
    return ""


def _get_llm_key() -> tuple[str, str, str]:
    """
    Returns (provider, model, api_key) for cheapest available LLM.
    Priority: gemini-flash > deepseek-chat > openai-gpt4o-mini.
    Returns ("", "", "") if no key available.
    """
    gemini_key = os.environ.get("GEMINI_API_KEY") or _load_secret("gemini.env", "GEMINI_API_KEY")
    if gemini_key:
        return ("google", "gemini-2.0-flash", gemini_key)

    deepseek_key = os.environ.get("DEEPSEEK_API_KEY") or _load_secret("deepseek.env", "DEEPSEEK_API_KEY")
    if deepseek_key:
        return ("deepseek", "deepseek-chat", deepseek_key)

    openai_key = os.environ.get("OPENAI_API_KEY") or _load_secret("openai.env", "OPENAI_API_KEY")
    if openai_key:
        return ("openai", "gpt-4o-mini", openai_key)

    return ("", "", "")


def _get_all_llm_candidates() -> list[tuple[str, str, str]]:
    """
    Returns all available (provider, model, api_key) tuples ordered cheapest-first.
    Used for fallback chain: if first provider fails (e.g. rate limit), try next.
    """
    candidates = []
    gemini_key = os.environ.get("GEMINI_API_KEY") or _load_secret("gemini.env", "GEMINI_API_KEY")
    if gemini_key:
        candidates.append(("google", "gemini-2.0-flash", gemini_key))
    deepseek_key = os.environ.get("DEEPSEEK_API_KEY") or _load_secret("deepseek.env", "DEEPSEEK_API_KEY")
    if deepseek_key:
        candidates.append(("deepseek", "deepseek-chat", deepseek_key))
    openai_key = os.environ.get("OPENAI_API_KEY") or _load_secret("openai.env", "OPENAI_API_KEY")
    if openai_key:
        candidates.append(("openai", "gpt-4o-mini", openai_key))
    return candidates


# ---------------------------------------------------------------------------
# LLM Synthesis
# ---------------------------------------------------------------------------

def _build_synthesis_prompt(query: str, hits: list[dict]) -> str:
    """Builds a compact synthesis prompt from search hits."""
    ctx_parts = []
    for i, h in enumerate(hits):
        slug = Path(h["path"]).stem
        preview = h.get("preview", "")[:400]
        ctx_parts.append(f"[{i+1}] {slug} ({h['path']})\n{preview}")

    context = "\n\n".join(ctx_parts)
    return f"""Du bist Aria, ein KI-Assistent. Synthesiere eine prägnante Antwort auf die Frage unten,
basierend NUR auf den gegebenen Quellen. Nenne Quellen inline als [N] (N = Quellennummer).
Wenn du eine Aussage nicht belegen kannst, sage das explizit.
Antworte auf Deutsch. Maximal 300 Wörter.

Frage: {query}

Quellen:
{context}

Antwort (mit Quellenangaben [N]):"""


def _call_llm(provider: str, model: str, api_key: str, prompt: str) -> str:
    """Calls an LLM API and returns text. Uses raw HTTP to avoid extra deps."""
    import urllib.request
    import urllib.error

    if provider == "google":
        url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
        body = {
            "contents": [{"parts": [{"text": prompt}]}],
            "generationConfig": {"maxOutputTokens": 512, "temperature": 0.2}
        }
        data = json.dumps(body).encode()
        req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
        try:
            with urllib.request.urlopen(req, timeout=30) as resp:
                result = json.loads(resp.read())
            candidate = (result.get("candidates") or [{}])[0]
            return "".join(p.get("text", "") for p in (candidate.get("content") or {}).get("parts", []))
        except Exception as exc:
            raise RuntimeError(f"Gemini call failed: {exc}") from exc

    elif provider == "deepseek":
        url = "https://api.deepseek.com/v1/chat/completions"
        body = {
            "model": model,
            "max_tokens": 512,
            "temperature": 0.2,
            "messages": [{"role": "user", "content": prompt}]
        }
        data = json.dumps(body).encode()
        req = urllib.request.Request(
            url, data=data,
            headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
            method="POST"
        )
        try:
            with urllib.request.urlopen(req, timeout=30) as resp:
                result = json.loads(resp.read())
            return result["choices"][0]["message"]["content"] or ""
        except Exception as exc:
            raise RuntimeError(f"DeepSeek call failed: {exc}") from exc

    elif provider == "openai":
        url = "https://api.openai.com/v1/chat/completions"
        body = {
            "model": model,
            "max_tokens": 512,
            "temperature": 0.2,
            "messages": [{"role": "user", "content": prompt}]
        }
        data = json.dumps(body).encode()
        req = urllib.request.Request(
            url, data=data,
            headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
            method="POST"
        )
        try:
            with urllib.request.urlopen(req, timeout=30) as resp:
                result = json.loads(resp.read())
            return result["choices"][0]["message"]["content"] or ""
        except Exception as exc:
            raise RuntimeError(f"OpenAI call failed: {exc}") from exc

    raise ValueError(f"Unknown provider: {provider}")


# ---------------------------------------------------------------------------
# Extractive (No-LLM) Synthesis
# ---------------------------------------------------------------------------

def _extractive_synthesis(query: str, hits: list[dict]) -> tuple[str, list[str]]:
    """
    Deterministic extractive synthesis without LLM.
    Returns (answer_text, list_of_uncited_query_aspects).

    Strategy:
    1. Score each sentence in each hit by query token overlap.
    2. Pick top sentences (deduplicated, up to 5) as the answer.
    3. Each selected sentence is cited with its source slug.
    """
    query_tokens = set(tokenize(query))
    if not query_tokens:
        return "Keine relevanten Informationen gefunden.", []

    candidates: list[tuple[float, str, str]] = []  # (score, sentence, slug)

    for hit in hits:
        slug = Path(hit["path"]).stem
        preview = hit.get("preview", "")
        # Try to get more content from the actual file
        full_path = BRAIN_DIR / hit["path"]
        if full_path.exists():
            try:
                text = full_path.read_text(encoding="utf-8", errors="replace")
                _, body = parse_frontmatter(text)
                # Use first 1500 chars of body for sentence extraction
                body_text = body.strip()[:1500]
            except OSError:
                body_text = preview
        else:
            body_text = preview

        # Split into sentences (rough: split on ". ", ".\n", "! ", "? ")
        sentences = re.split(r'(?<=[.!?])\s+', body_text)
        for sent in sentences:
            sent = sent.strip()
            if len(sent) < 20 or len(sent) > 500:
                continue
            sent_tokens = set(tokenize(sent))
            overlap = len(query_tokens & sent_tokens)
            if overlap > 0:
                # Score = overlap / sqrt(len(sent_tokens)) to penalize very long sentences
                score = overlap / math.sqrt(max(1, len(sent_tokens)))
                candidates.append((score, sent, slug))

    # Sort by score descending, deduplicate by content similarity
    candidates.sort(key=lambda x: x[0], reverse=True)

    seen_fingerprints: set[frozenset] = set()
    selected: list[tuple[str, str]] = []  # (sentence, slug)
    for score, sent, slug in candidates:
        fp = frozenset(tokenize(sent)[:10])  # fingerprint on first 10 tokens
        if fp not in seen_fingerprints:
            seen_fingerprints.add(fp)
            selected.append((sent, slug))
        if len(selected) >= 5:
            break

    if not selected:
        # Fallback: use previews directly
        lines = []
        for hit in hits[:3]:
            slug = Path(hit["path"]).stem
            preview = hit.get("preview", "")[:200]
            if preview:
                lines.append(f"[{slug}] {preview}")
        answer = " ".join(lines) if lines else "Keine relevanten Informationen gefunden."
        return answer, list(query_tokens)

    # Build answer text with inline citations
    parts = []
    cited_slugs: set[str] = set()
    for sent, slug in selected:
        parts.append(f"{sent} [{slug}]")
        cited_slugs.add(slug)

    answer = " ".join(parts)

    # Identify uncited query aspects: query tokens not present in cited sentences
    cited_text = " ".join(s for s, _ in selected).lower()
    uncited_aspects = []
    for tok in query_tokens:
        if tok not in cited_text and len(tok) > 3:
            uncited_aspects.append(tok)

    return answer, uncited_aspects


# ---------------------------------------------------------------------------
# Gap Analysis
# ---------------------------------------------------------------------------

def _gap_stale(hits: list[dict], stale_days: int) -> list[dict]:
    """
    Flags hits whose content is older than stale_days.
    Uses frontmatter date first, then mtime as fallback.
    """
    stale = []
    for hit in hits:
        fm = _read_full_frontmatter(hit["path"])
        fm_age = _frontmatter_date_age_days(fm)
        mtime_age = _file_age_days(hit["path"])

        age = fm_age if fm_age is not None else mtime_age
        if age is not None and age > stale_days:
            reason = f"frontmatter date {fm.get('date', 'N/A')}" if fm_age is not None else f"file mtime {age:.0f}d ago"
            stale.append({
                "path": hit["path"],
                "age_days": int(age),
                "reason": reason,
            })
    return stale


def _gap_contradiction(hits: list[dict]) -> list[dict]:
    """
    Lightweight heuristic: flag pairs of hits that share sentiment-polar tokens.
    Checks for negation patterns near shared key terms.
    V1 heuristic: if two docs both mention a term but one has a negation near it
    (kein/nicht/never/no) and the other doesn't, flag as potential contradiction.
    """
    contradictions = []
    # Build token sets per hit (from preview)
    NEG_TOKENS = {"kein", "keine", "nicht", "never", "no", "not", "falsch", "wrong",
                  "deprecated", "veraltet", "obsolet", "broken", "fehler", "error"}

    hit_data = []
    for hit in hits:
        tokens = set(tokenize(hit.get("preview", "")))
        neg_context = bool(tokens & NEG_TOKENS)
        hit_data.append((hit["path"], tokens, neg_context))

    # Compare pairs: if two hits share >3 key tokens but one has negation context
    for i in range(len(hit_data)):
        for j in range(i + 1, len(hit_data)):
            path_a, toks_a, neg_a = hit_data[i]
            path_b, toks_b, neg_b = hit_data[j]
            shared = (toks_a & toks_b) - _search_mod.STOPWORDS
            # Remove very short tokens
            shared = {t for t in shared if len(t) > 3}
            if len(shared) >= 3 and (neg_a != neg_b):
                contradictions.append({
                    "paths": [path_a, path_b],
                    "signal": f"Shared terms: {', '.join(sorted(shared)[:5])}. "
                              f"{'First' if neg_a else 'Second'} doc has negation context.",
                })
    return contradictions


def _gap_missing(query: str, hits: list[dict], uncited: list[str]) -> list[str]:
    """
    Identifies query aspects not covered by any hit.
    Uses named entity / key phrase extraction on the query.
    """
    # Extract meaningful query terms (length > 3, not stopwords)
    query_terms = [t for t in tokenize(query) if len(t) > 3]
    if not query_terms:
        return []

    # Build combined text from all hits
    combined = " ".join(
        h.get("preview", "") + " " + h["path"].replace("/", " ").replace("-", " ")
        for h in hits
    ).lower()

    # A term is "missing" if it appears in uncited aspects AND not in any hit content
    missing = []
    for term in query_terms:
        if term not in combined:
            missing.append(term)

    return missing


# ---------------------------------------------------------------------------
# Main Think Function
# ---------------------------------------------------------------------------

def think(
    query: str,
    top: int = 7,
    use_llm: bool = True,
    stale_days: int = 90,
) -> dict:
    """
    Main think function. Returns dict with answer, citations, gaps.
    """
    # 1. Search for top-N hits
    hits = search(query, top=top)

    if not hits:
        return {
            "query": query,
            "answer": "Keine relevanten Brain-Einträge gefunden.",
            "citations": [],
            "gaps": {
                "stale": [],
                "uncited": [query],
                "contradiction": [],
                "missing": [query],
            },
            "mode": "no-hits",
            "llm_provider": None,
            "llm_model": None,
        }

    # Build citations list
    citations = [
        {
            "slug": Path(h["path"]).stem,
            "path": h["path"],
            "score": h.get("score", 0),
            "preview": h.get("preview", "")[:200],
        }
        for h in hits
    ]

    # 2. Synthesize answer
    mode = "extractive"
    llm_provider = None
    llm_model = None
    answer = ""
    uncited_aspects: list[str] = []

    if use_llm:
        llm_candidates = _get_all_llm_candidates()
        llm_ok = False
        for provider, model, api_key in llm_candidates:
            try:
                prompt = _build_synthesis_prompt(query, hits)
                answer = _call_llm(provider, model, api_key, prompt)
                mode = "llm"
                llm_provider = provider
                llm_model = model
                llm_ok = True

                # Extract uncited aspects: query tokens not mentioned in answer
                answer_tokens = set(tokenize(answer))
                query_tokens_set = set(tokenize(query))
                uncited_aspects = [t for t in query_tokens_set if t not in answer_tokens and len(t) > 3]
                break
            except Exception as exc:
                sys.stderr.write(f"[think] LLM call failed ({provider}/{model}): {exc}\n")
        if not llm_ok:
            sys.stderr.write("[think] All LLM providers failed. Falling back to extractive synthesis.\n")
            answer, uncited_aspects = _extractive_synthesis(query, hits)
            mode = "extractive"
    else:
        answer, uncited_aspects = _extractive_synthesis(query, hits)

    # 3. Gap Analysis
    stale = _gap_stale(hits, stale_days)
    contradiction = _gap_contradiction(hits)
    missing = _gap_missing(query, hits, uncited_aspects)

    # Deduplicate uncited_aspects vs missing (both derived differently)
    uncited_final = list(set(uncited_aspects))
    missing_final = list(set(missing) - set(uncited_aspects))  # missing = harder to find

    return {
        "query": query,
        "answer": answer,
        "citations": citations,
        "gaps": {
            "stale": stale,
            "uncited": uncited_final,
            "contradiction": contradiction,
            "missing": missing_final,
        },
        "mode": mode,
        "llm_provider": llm_provider,
        "llm_model": llm_model,
    }


# ---------------------------------------------------------------------------
# Pretty Printer
# ---------------------------------------------------------------------------

def _print_result(result: dict) -> None:
    """Human-readable output for the think result."""
    print(f"\n{'='*70}")
    print(f"QUERY: {result['query']}")
    print(f"Mode: {result['mode']}", end="")
    if result.get("llm_provider"):
        print(f" ({result['llm_provider']}/{result['llm_model']})", end="")
    print()
    print(f"{'='*70}\n")

    print("ANSWER")
    print("-" * 40)
    print(result["answer"])
    print()

    citations = result.get("citations", [])
    if citations:
        print(f"CITATIONS ({len(citations)} Treffer)")
        print("-" * 40)
        for i, c in enumerate(citations, 1):
            print(f"  [{i}] {c['slug']} (score={c['score']:.3f})")
            print(f"      brain/{c['path']}")
            if c.get("preview"):
                print(f"      {c['preview'][:120]}...")
        print()

    gaps = result.get("gaps", {})

    stale = gaps.get("stale", [])
    if stale:
        print(f"GAP — STALE ({len(stale)} Notes)")
        print("-" * 40)
        for s in stale:
            print(f"  {s['path']} — {s['age_days']}d alt — {s['reason']}")
        print()

    uncited = gaps.get("uncited", [])
    if uncited:
        print(f"GAP — UNCITED ({len(uncited)} Aspekte)")
        print("-" * 40)
        print(f"  {', '.join(uncited)}")
        print()

    contradiction = gaps.get("contradiction", [])
    if contradiction:
        print(f"GAP — CONTRADICTION ({len(contradiction)} Signale)")
        print("-" * 40)
        for c in contradiction:
            print(f"  {' vs '.join(c['paths'])}")
            print(f"  Signal: {c['signal']}")
        print()

    missing = gaps.get("missing", [])
    if missing:
        print(f"GAP — MISSING ({len(missing)} Aspekte)")
        print("-" * 40)
        print(f"  Das Brain kennt diese Aspekte nicht: {', '.join(missing)}")
        print()


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

def main() -> None:
    parser = argparse.ArgumentParser(
        description="Aria Brain Think — Synthesis + Gap Analysis über das Brain.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Beispiele:
  aria-brain-think "Aria Architektur Sprint 6"
  aria-brain-think "BMW Design System" --top 10 --no-llm
  aria-brain-think "Kadi-v2 Supabase Schema" --json
  aria-brain-think "Was ist der aktuelle Status der AI-Radar Entwicklung?" --stale-days 30
        """.strip(),
    )
    parser.add_argument("query", help="Die Suchanfrage / Frage")
    parser.add_argument("--top", type=int, default=7, help="Anzahl der Treffer für die Synthese (Default: 7)")
    parser.add_argument("--no-llm", action="store_true", help="Deterministischer Extraktions-Modus (kein LLM-Aufruf)")
    parser.add_argument("--json", action="store_true", help="JSON-Output")
    parser.add_argument("--stale-days", type=int, default=90,
                        help="Alter in Tagen ab dem eine Note als STALE gilt (Default: 90)")

    args = parser.parse_args()

    result = think(
        query=args.query,
        top=args.top,
        use_llm=not args.no_llm,
        stale_days=args.stale_days,
    )

    if args.json:
        print(json.dumps(result, indent=2, ensure_ascii=False))
    else:
        _print_result(result)


if __name__ == "__main__":
    main()
