#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r"""
sphinx_search_cli_index.py

Sphinx の searchindex.js を直接読んで検索する CLI。
search.html の結果は JavaScript で後から描画されることが多いため、
requests + BeautifulSoup で search.html を読むだけでは結果が取れない場合の対策版。

更新点:
- Windows 形式の \ 区切りを / に正規化
- .md / .rst を .html に変換
- --open で上位結果の URL を出力可能
"""

from __future__ import annotations

import argparse
import json
import re
import sys
from dataclasses import asdict, dataclass
from html import unescape
from typing import Dict, List, Sequence, Tuple
from urllib.parse import quote_plus, urljoin

import requests


DEFAULT_BASE_URL = "http://d2mate.mdxes.iir.isct.ac.jp/sphinx/tkProg"
DEFAULT_TIMEOUT = 20


@dataclass
class SearchResult:
    rank: int
    title: str
    url: str
    score: int
    matched_terms: List[str]


def normalize_base_url(base_url: str) -> str:
    base_url = base_url.strip()
    if not base_url:
        raise ValueError("base_url is empty")
    return base_url.rstrip("/") + "/"


def fetch_text(url: str, timeout: int = DEFAULT_TIMEOUT) -> str:
    headers = {
        "User-Agent": "Mozilla/5.0 (compatible; sphinx_search_cli_index/1.1)",
    }
    resp = requests.get(url, headers=headers, timeout=timeout)
    resp.raise_for_status()
    resp.encoding = resp.encoding or "utf-8"
    return resp.text


def build_index_url(base_url: str) -> str:
    return urljoin(normalize_base_url(base_url), "searchindex.js")


def clean_text(text: str) -> str:
    return " ".join(unescape(text).split()).strip()


def split_query(query: str) -> List[str]:
    parts = [p.strip() for p in re.split(r"\s+", query) if p.strip()]
    lowered = []
    seen = set()
    for p in [query.strip(), *parts]:
        k = p.lower()
        if k and k not in seen:
            seen.add(k)
            lowered.append(k)
    return lowered


def extract_searchindex_json(js_text: str) -> dict:
    m = re.search(r"Search\.setIndex\((.*)\)\s*;?\s*$", js_text, flags=re.S)
    if not m:
        raise ValueError("searchindex.js から Search.setIndex(...) を見つけられませんでした。")

    payload = m.group(1).strip()

    try:
        return json.loads(payload)
    except json.JSONDecodeError as e:
        raise ValueError(f"searchindex.js の JSON 解釈に失敗しました: {e}") from e


def normalize_doc_list(value) -> List[int]:
    if isinstance(value, int):
        return [value]
    if isinstance(value, list):
        out = []
        for x in value:
            if isinstance(x, int):
                out.append(x)
        return out
    return []


def normalize_relpath(rel: str) -> str:
    rel = str(rel).strip().replace("\\", "/")
    if rel.endswith(".md"):
        rel = rel[:-3] + ".html"
    elif rel.endswith(".rst"):
        rel = rel[:-4] + ".html"
    return rel


def score_documents(index_data: dict, query: str) -> List[Tuple[int, int, List[str]]]:
    titles = index_data.get("titles", [])
    terms: Dict[str, object] = index_data.get("terms", {}) or {}
    titleterms: Dict[str, object] = index_data.get("titleterms", {}) or {}

    query_terms = split_query(query)
    scores: Dict[int, int] = {}
    matched: Dict[int, List[str]] = {}

    for q in query_terms:
        for bucket, weight in (
            (titleterms, 20),
            (terms, 10),
        ):
            for term, docs in bucket.items():
                term_l = str(term).lower()
                hit = False
                if q == term_l:
                    hit = True
                    w = weight + 20
                elif q in term_l:
                    hit = True
                    w = weight
                elif term_l in q and len(term_l) >= 2:
                    hit = True
                    w = max(3, weight - 5)
                else:
                    w = 0

                if not hit:
                    continue

                for doc_id in normalize_doc_list(docs):
                    scores[doc_id] = scores.get(doc_id, 0) + w
                    matched.setdefault(doc_id, [])
                    if term not in matched[doc_id]:
                        matched[doc_id].append(str(term))

    q_full = query.strip().lower()
    for doc_id, title in enumerate(titles):
        title_l = clean_text(str(title)).lower()
        if not title_l:
            continue
        if q_full and q_full == title_l:
            scores[doc_id] = scores.get(doc_id, 0) + 60
        elif q_full and q_full in title_l:
            scores[doc_id] = scores.get(doc_id, 0) + 30

        for q in query_terms:
            if q and q in title_l:
                scores[doc_id] = scores.get(doc_id, 0) + 8

    ranked = sorted(
        ((doc_id, score, matched.get(doc_id, [])) for doc_id, score in scores.items() if score > 0),
        key=lambda x: (-x[1], x[0]),
    )
    return ranked


def build_results(index_data: dict, base_url: str, query: str, limit: int) -> List[SearchResult]:
    titles = index_data.get("titles", []) or []
    filenames = index_data.get("filenames", []) or []
    docnames = index_data.get("docnames", []) or []

    ranked = score_documents(index_data, query)
    results: List[SearchResult] = []

    for rank, (doc_id, score, matched_terms) in enumerate(ranked[:limit], start=1):
        title = clean_text(str(titles[doc_id])) if doc_id < len(titles) else f"doc:{doc_id}"

        if doc_id < len(filenames):
            rel = normalize_relpath(filenames[doc_id])
        elif doc_id < len(docnames):
            rel = normalize_relpath(f"{docnames[doc_id]}.html")
        else:
            rel = ""

        url = urljoin(normalize_base_url(base_url), rel)
        results.append(
            SearchResult(
                rank=rank,
                title=title,
                url=url,
                score=score,
                matched_terms=matched_terms[:10],
            )
        )

    return results


def print_results(results: Sequence[SearchResult]) -> None:
    if not results:
        print("検索結果は見つかりませんでした。")
        return

    for r in results:
        print(f"[{r.rank}] {r.title}")
        print(f"    URL: {r.url}")
        print(f"    score: {r.score}")
        if r.matched_terms:
            print(f"    matched: {', '.join(r.matched_terms)}")
        print()


def main() -> int:
    parser = argparse.ArgumentParser(description="Sphinx の searchindex.js を使って検索する CLI")
    parser.add_argument("keyword", help="検索キーワード")
    parser.add_argument(
        "--base-url",
        default=DEFAULT_BASE_URL,
        help=f"Sphinx ドキュメントのベースURL (default: {DEFAULT_BASE_URL})",
    )
    parser.add_argument("--limit", type=int, default=20, help="表示件数上限")
    parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, help="HTTP timeout 秒")
    parser.add_argument("--json", action="store_true", help="JSON 形式で出力")
    parser.add_argument(
        "--open",
        type=int,
        default=0,
        metavar="N",
        help="N番目の結果のURLだけを出力する (例: --open 1)",
    )
    args = parser.parse_args()

    try:
        index_url = build_index_url(args.base_url)
        js_text = fetch_text(index_url, timeout=args.timeout)
        index_data = extract_searchindex_json(js_text)
        results = build_results(index_data, args.base_url, args.keyword, args.limit)
    except requests.HTTPError as e:
        print(f"HTTP エラー: {e}", file=sys.stderr)
        return 2
    except requests.RequestException as e:
        print(f"通信エラー: {e}", file=sys.stderr)
        return 2
    except Exception as e:
        print(f"エラー: {e}", file=sys.stderr)
        return 1

    search_url = urljoin(normalize_base_url(args.base_url), f"search.html?q={quote_plus(args.keyword)}")

    if args.open > 0:
        idx = args.open - 1
        if idx < 0 or idx >= len(results):
            print(f"エラー: --open {args.open} は範囲外です。", file=sys.stderr)
            return 1
        print(results[idx].url)
        return 0

    if args.json:
        print(json.dumps({
            "search_url": search_url,
            "results": [asdict(x) for x in results],
        }, ensure_ascii=False, indent=2))
    else:
        print(f"Search URL: {search_url}")
        print(f"Index URL : {index_url}")
        print()
        print_results(results)

    return 0


if __name__ == "__main__":
    raise SystemExit(main())
