#!/usr/bin/env python3
"""
arXiv adapter for AI Research Oracle.
Fetches recent AI/ML papers via arXiv API (Atom XML).
No scraping — uses the official API endpoint.

Rate limits: 1 req/3s (be polite). arXiv enforces this aggressively.

============================================================================
SCORING DESIGN PRINCIPLE (do not violate when copying this to other adapters)
============================================================================
Structural metadata (recency, author count, abstract length, category
diversity) is a WEAK signal present in EVERY paper regardless of topic. It
must NEVER dominate the final score. Content-relevance signals (AI keyword
density in the abstract, methodology-contribution detection) MUST carry the
weight. A paper that merely USES an existing AI tool as incidental methodology
scores lower than one that CONTRIBUTES a new method — detection is by verb
(creative "we propose/introduce" vs evaluative "we evaluate/using"), not by
domain. Applied-domain papers are TAGGED for filtering, NEVER penalized: for a
startup-idea oracle, novel AI applied to a vertical is desired signal.
(See _score() and _methodology_claim_score() for the implementation.)
============================================================================
"""

import json
import os
import re
import time
import urllib.request
import urllib.error
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone
from html import unescape

from adapters import SourceAdapter
from adapters._store import true_first_seen, upsert_entries

# arXiv API
ARXIV_API = "http://export.arxiv.org/api/query"


class ArxivAdapter(SourceAdapter):
    """arXiv API adapter."""

    # Default categories to scan
    DEFAULT_CATEGORIES = ["cs.AI", "cs.LG", "cs.CL"]

    def __init__(self, categories=None, rate_limit=3):
        """
        Args:
            categories: List of arXiv categories. Default: cs.AI, cs.LG, cs.CL
            rate_limit: Seconds between API calls (default 3).
        """
        self.categories = categories or self.DEFAULT_CATEGORIES
        self.rate_limit = rate_limit

    def name(self) -> str:
        return "arxiv"

    def _parse_atom(self, xml_data: str) -> list[dict]:
        """Parse arXiv Atom XML into raw paper dicts."""
        root = ET.fromstring(xml_data)

        # Handle namespaces — arXiv uses multiple
        # Walk all elements and extract what we need
        papers = []

        for entry in root.iter():
            tag = entry.tag.split("}")[-1] if "}" in entry.tag else entry.tag
            if tag == "entry":
                paper = {
                    "id": "",
                    "title": "",
                    "summary": "",
                    "published": "",
                    "updated": "",
                    "authors": [],
                    "categories": [],
                    "comment": "",
                    "journal_ref": "",
                    "doi": "",
                    "link": "",
                }

                for child in entry:
                    ctag = child.tag.split("}")[-1]

                    if ctag == "id":
                        # arXiv ID like http://arxiv.org/abs/cs.AI/2607.00123
                        paper["id"] = child.text or ""
                        # Also extract clean ID
                        if "abs/" in paper["id"]:
                            paper["arxiv_id"] = paper["id"].split("abs/")[-1]

                    elif ctag == "title":
                        paper["title"] = unescape((child.text or "").strip())

                    elif ctag == "summary":
                        paper["summary"] = unescape((child.text or "").strip())

                    elif ctag == "published":
                        paper["published"] = child.text or ""

                    elif ctag == "updated":
                        paper["updated"] = child.text or ""

                    elif ctag == "author":
                        for ac in child:
                            a_tag = ac.tag.split("}")[-1]
                            if a_tag == "name":
                                paper["authors"].append(unescape((ac.text or "").strip()))

                    elif ctag == "category":
                        term = child.get("term", "")
                        if term:
                            paper["categories"].append(term)

                    elif ctag == "arxiv":
                        # arXiv-specific: comment, journal_ref, doi
                        sub_tag = child.tag.split("}")[-1]
                        if sub_tag == "comment":
                            paper["comment"] = unescape((child.text or "").strip())
                        elif sub_tag == "journal_ref":
                            paper["journal_ref"] = unescape((child.text or "").strip())
                        elif sub_tag == "doi":
                            paper["doi"] = unescape((child.text or "").strip())

                    elif ctag == "link":
                        href = child.get("href", "")
                        # Prefer the abstract page link
                        if "abs/" in href and not paper["link"]:
                            paper["link"] = href
                        elif "pdf/" in href:
                            paper["pdf_link"] = href

                # Clean up arxiv_id if not extracted from ID field
                if "arxiv_id" not in paper and "abs/" in paper.get("id", ""):
                    paper["arxiv_id"] = paper["id"].split("abs/")[-1]

                if paper["title"] and paper["summary"]:
                    papers.append(paper)

        return papers

    def _request(self, query: str, max_results: int = 20, sort_by="submittedDate") -> list[dict]:
        """Make an arXiv API request."""
        url = (
            f"{ARXIV_API}"
            f"?search_query={urllib.parse.quote(query)}"
            f"&sortBy={sort_by}"
            f"&sortOrder=descending"
            f"&max_results={max_results}"
        )

        req = urllib.request.Request(url, headers={"User-Agent": "ai-oracle/0.1"})

        try:
            with urllib.request.urlopen(req, timeout=30) as resp:
                xml_data = resp.read().decode("utf-8")
            return self._parse_atom(xml_data)
        except urllib.error.HTTPError as e:
            print(f"    HTTP {e.code} for arXiv query")
            return []
        except Exception as e:
            print(f"    arXiv request error: {e}")
            return []

    def _score(self, paper: dict, age_days: float) -> float:
        """Score based on AI-methodology relevance, not structural metadata.

        arXiv has no upvotes/stars. The key signal is whether the paper is a
        genuine AI/ML *contribution* (new architecture, framework, method) vs.
        merely *using* an existing AI tool as incidental methodology.

        Structural signals (recency, author count, category diversity) are
        weak and capped low so they cannot dominate the score — any paper has
        them regardless of topic. The relevance signals (methodology claim +
        AI keyword density) carry the weight.

        Applied-domain papers (healthcare, finance, biology) are TAGGED for
        filtering but NOT penalized — for a startup-idea oracle, "novel AI
        technique applied to a vertical" is exactly the signal we want.
        """
        # --- Structural signals (weak, capped low) ---
        # Recency: newer = slightly higher, but max 2.0 (was 5.0)
        try:
            pub_str = paper.get("published", "")
            pub_dt = datetime.fromisoformat(pub_str.replace("Z", "+00:00"))
            now = datetime.now(timezone.utc)
            age_hours = (now - pub_dt).total_seconds() / 3600
            recency = max(0, 2.0 - age_hours / 48.0)
        except (ValueError, TypeError):
            recency = max(0, 2.0 - age_days * 0.1)

        # Author count: collaborative work is a weak signal, max 0.5 (was 2.0)
        author_count = len(paper.get("authors", []))
        author_bonus = min(author_count * 0.05, 0.5)

        # Category diversity: cross-domain is mildly interesting, max 0.3
        cats = paper.get("categories", [])
        diversity_bonus = min(len(cats) * 0.1, 0.3)

        # Journal/DOI: published = validated, small bonus
        journal_bonus = 0.5 if paper.get("journal_ref") else 0.0
        doi_bonus = 0.2 if paper.get("doi") else 0.0

        # Comment field (page count etc.): tiny bonus
        comment = paper.get("comment", "")
        comment_bonus = min(len(comment) / 300.0, 0.5) if comment else 0.0

        # --- Relevance signals (carry the weight) ---
        title_lower = paper.get("title", "").lower()
        abstract = paper.get("summary", "")
        abstract_lower = abstract.lower()

        # AI keyword density in abstract (not just title)
        ai_keywords = [
            "agent", "llm", "large language", "gpt", "transformer",
            "neural", "deep learning", "machine learning", "reinforcement",
            "diffusion", "multimodal", "embedding", "attention", "rag",
            "retrieval", "fine-tun", "pretrain", "self-supervised",
            "foundation model", "reasoning", "alignment", "policy gradient",
            "graph neural", "vision-language", "vla", "vlm",
        ]
        ai_kw_hits = sum(1 for kw in ai_keywords if kw in abstract_lower)
        # Density matters more than single mention; saturate at ~6 hits
        ai_keyword_bonus = min(ai_kw_hits * 0.35, 2.0)

        # Title keyword bonus (small)
        title_kw_list = ["agent", "llm", "reasoning", "verif", "multimodal",
                         "embodied", "robot", "alignment", "autonomous"]
        title_kw_hits = sum(1 for kw in title_kw_list if kw in title_lower)
        title_kw_bonus = min(title_kw_hits * 0.2, 0.5)

        # AI methodology claim: does the abstract propose/invent something?
        methodology_bonus = self._methodology_claim_score(abstract_lower)

        # --- Domain tag (no penalty) ---
        combined_text = f"{title_lower} {abstract_lower}"
        applied_domains = {
            "applied:healthcare": ["gastric", "biopsy", "pathology", "clinical",
                "patient", "diagnosis", "medical imaging", "h. pylori",
                "cancer screening", "oncology", "neurology", "cardiology"],
            "applied:finance": ["stock market", "trading", "portfolio", "forex",
                "cryptocurrency", "fintech", "credit scoring", "fraud detection"],
            "applied:biology": ["protein folding", "gene expression", "genome",
                "molecular", "bioinformatics", "cell type", "organism"],
        }
        primary_domain = None
        for domain, domain_keywords in applied_domains.items():
            hits = sum(1 for kw in domain_keywords if kw in combined_text)
            if hits >= 2:  # need at least 2 domain keywords to trigger
                primary_domain = domain
                break
        if primary_domain:
            paper["_applied_domain"] = primary_domain

        # Total: structural (capped ~3.5) + relevance (capped ~5.0)
        structural = recency + author_bonus + diversity_bonus + journal_bonus + doi_bonus + comment_bonus
        relevance = ai_keyword_bonus + title_kw_bonus + methodology_bonus
        score = structural + relevance

        return min(round(score, 2), 10.0)

    def _methodology_claim_score(self, abstract_lower: str) -> float:
        """Detect whether the abstract proposes a NEW AI method vs. using an existing one.

        The core distinction is the VERB: creative verbs (propose/introduce/
        develop/design) signal a contribution; evaluative verbs (evaluate/
        conduct/retrospective/using/based on) signal applying an existing tool.

        Check weak (evaluative) patterns FIRST — if the paper is an evaluation
        or application of an existing system, it gets 0.5 even if it also says
        'we present'. Only if no weak pattern matches do we look for a genuine
        contribution claim.

        Returns 0.0 (no claim / unclear), 0.5 (uses existing tool),
        2.0 (novel method), or 2.5 (explicit proposal of new AI system).
        """
        # Weak FIRST: evaluative / application language
        weak_markers = ["we evaluate", "we conducted", "we conduct", "retrospective",
                        "case study", "pilot study", "using", "we leverage",
                        "we applied", "based on", "we report a", "we present a retrospective",
                        "an evaluation of", "we benchmark", "empirical study"]
        for wm in weak_markers:
            if wm in abstract_lower:
                return 0.5

        # Strong: genuinely creative verbs + AI noun within 60 chars
        proposal_verbs = ["we propose", "we introduce", "we develop", "we design",
                          "we formulate", "we construct", "we build", "we present a novel",
                          "we present a new"]
        ai_nouns = ["model", "architecture", "framework", "method", "approach",
                    "agent", "system", "network", "algorithm", "pipeline",
                    "llm", "transformer", "policy", "graph", "orchestration",
                    "harness", "memory", "solver", "planner"]
        for verb in proposal_verbs:
            idx = abstract_lower.find(verb)
            if idx != -1:
                window = abstract_lower[idx:idx + 60]
                if any(noun in window for noun in ai_nouns):
                    return 2.5

        # Strong-ish: 'novel'/'new' + AI architecture/framework/model within 40 chars
        for marker in ["novel", "new"]:
            idx = abstract_lower.find(marker)
            scan = 0
            while idx != -1 and scan < 5:
                window = abstract_lower[idx:idx + 40]
                if any(noun in window for noun in
                       ["architecture", "framework", "model", "method",
                        "approach", "agent", "system", "harness"]):
                    return 2.0
                idx = abstract_lower.find(marker, idx + 1)
                scan += 1

        return 0.0

    def _tags(self, paper: dict) -> list:
        """Generate category tags from arXiv metadata."""
        tags = ["arxiv"]

        # Add arXiv categories
        for cat in paper.get("categories", [])[:5]:
            tags.append(f"cat:{cat}")

        # Primary signal categories
        cats_lower = [c.lower() for c in paper.get("categories", [])]
        if "cs.ai" in cats_lower:
            tags.append("ai-general")
        if "cs.lg" in cats_lower:
            tags.append("machine-learning")
        if "cs.cl" in cats_lower:
            tags.append("nlp")
        if "cs.cv" in cats_lower:
            tags.append("computer-vision")
        if "cs.ro" in cats_lower:
            tags.append("robotics")
        if "cs.se" in cats_lower:
            tags.append("software-engineering")

        # Check if it's a survey/tutorial
        title_lower = paper.get("title", "").lower()
        summary_lower = paper.get("summary", "").lower()
        if any(kw in summary_lower for kw in ["survey", "tutorial", "overview of", "review of"]):
            tags.append("survey")

        # Check for preprint vs published
        if paper.get("journal_ref"):
            tags.append("published")
        else:
            tags.append("preprint")

        # Applied-domain tag
        if paper.get("_applied_domain"):
            tags.append(paper["_applied_domain"])

        # Local-serving / efficient-inference signal (suggested source: arXiv cs.LG
        # MoE/quantization papers → "local-serving" tag filter)
        serving_kw = [
            "quantiz", "quantization", "moe", "mixture of experts",
            "serving", "efficient inference", "pruning", "distill",
            "knowledge distillation", "low-rank", "lora", "parameter-efficient",
            "vram", "memory efficient", "edge inference", "on-device",
        ]
        combined = f"{title_lower} {summary_lower}"
        if any(kw in combined for kw in serving_kw):
            tags.append("local-serving")

        return tags

    def fetch(self, query: str = "", limit: int = 20) -> list[dict]:
        """
        Fetch papers from arXiv.

        If query is empty, fetch recent papers from configured categories.
        If query is provided, search for it.
        """
        now = datetime.now(timezone.utc)
        all_papers = []

        if query:
            print(f"  Searching arXiv: '{query}'")
            papers = self._request(query, max_results=limit, sort_by="submittedDate")
            all_papers.extend(papers)
        else:
            # Fetch from each configured category
            for cat in self.categories:
                q = f"cat:{cat}"
                cat_papers = self._request(q, max_results=limit, sort_by="submittedDate")
                all_papers.extend(cat_papers)
                time.sleep(self.rate_limit)

            # Deduplicate by arxiv_id
            seen = set()
            unique = []
            for p in all_papers:
                pid = p.get("arxiv_id", p.get("id", ""))
                if pid and pid not in seen:
                    seen.add(pid)
                    unique.append(p)
            all_papers = unique

        # Sort by published date (newest first), take top limit
        all_papers.sort(
            key=lambda p: p.get("published", ""),
            reverse=True,
        )
        all_papers = all_papers[:limit]

        entries = []
        for paper in all_papers:
            # Calculate age
            published = paper.get("published", "")
            try:
                pub_dt = datetime.fromisoformat(published.replace("Z", "+00:00"))
                age_days = (now - pub_dt).days
            except (ValueError, TypeError):
                age_days = 0

            score = self._score(paper, age_days)
            tags = self._tags(paper)

            # Source ID: arxiv_id
            source_id = paper.get("arxiv_id", paper.get("id", "")).split("/")[-1]

            # URL: abstract page
            url = paper.get("link", "")
            if not url and paper.get("arxiv_id"):
                url = f"https://arxiv.org/abs/{paper['arxiv_id']}"

            # Title (clean — remove trailing category markers)
            title = re.sub(r"\s*\([A-Za-z0-9., ]*\)\s*$", "", paper.get("title", "")).strip()

            # Extracted text: summary (abstract) — already clean
            extracted_text = paper.get("summary", "")

            # Structured metadata
            raw_meta = {
                "arxiv_id": paper.get("arxiv_id", ""),
                "authors": paper.get("authors", []),
                "author_count": len(paper.get("authors", [])),
                "categories": paper.get("categories", []),
                "published": paper.get("published", ""),
                "updated": paper.get("updated", ""),
                "comment": paper.get("comment", ""),
                "journal_ref": paper.get("journal_ref", ""),
                "doi": paper.get("doi", ""),
                "pdf_link": paper.get("pdf_link", ""),
                "age_days": age_days,
                "abstract_length": len(extracted_text),
                "score_type": "estimated",  # arXiv has no upvotes
            }
            # Store applied-domain info if detected
            if paper.get("_applied_domain"):
                raw_meta["applied_domain"] = paper["_applied_domain"]

            now_str = now.strftime("%Y-%m-%dT%H:%M:%SZ")
            first_seen = true_first_seen(raw_meta, "arxiv", now_str)
            entries.append({
                "source": "arxiv",
                "source_id": source_id,
                "url": url,
                "title": title,
                "extracted_text": extracted_text,
                "summary": None,
                "category_tags": json.dumps(tags),
                "signal_score": score,
                "raw_metadata": json.dumps(raw_meta),
                "first_seen": first_seen,
                "last_updated": now_str,
            })

        return entries


if __name__ == "__main__":
    import argparse
    import sqlite3
    import urllib.parse

    parser = argparse.ArgumentParser(description="arXiv adapter for AI Research Oracle")
    parser.add_argument("--query", default="", help="Search query (empty = recent categories)")
    parser.add_argument("--limit", type=int, default=20, help="Max entries")
    parser.add_argument("--db", default=os.path.join(os.path.dirname(__file__), "..", "oracle.db"), help="SQLite DB")
    parser.add_argument("--schema", default=os.path.join(os.path.dirname(__file__), "..", "schema.sql"), help="Schema file")
    parser.add_argument("--dry-run", action="store_true", help="Don't store in DB")
    args = parser.parse_args()

    print(f"=== arXiv Adapter ===")
    print(f"  Query: {args.query or '(recent AI categories)'}")
    print(f"  Limit: {args.limit}")
    print()

    adapter = ArxivAdapter()
    entries = adapter.fetch(query=args.query, limit=args.limit)

    print(f"  Fetched {len(entries)} entries")

    if not args.dry_run:
        conn = sqlite3.connect(args.db)
        if os.path.exists(args.schema):
            with open(args.schema) as f:
                conn.executescript(f.read())
            conn.commit()

        cur = conn.cursor()
        stored = upsert_entries(conn, entries)
        conn.commit()
        conn.close()
        print(f"  Stored {stored} entries")

    # Print top 5
    print(f"\n  Top entries:")
    for i, e in enumerate(entries[:5]):
        meta = json.loads(e["raw_metadata"]) if isinstance(e["raw_metadata"], str) else e["raw_metadata"]
        authors = meta.get("authors", [])
        author_str = f"{authors[0]} et al." if len(authors) > 2 else ", ".join(authors[:2])
        print(f"    [{i+1}] score={e['signal_score']:.2f} authors={author_str}")
        print(f"        {e['title'][:90]}")
        print(f"        {e['url']}")
        print(f"        abstract={meta.get('abstract_length', 0)}ch")

    print(f"\n  Done.")
