#!/usr/bin/env python3.11
# -*- coding: utf-8 -*-
"""
Индексатор коллекции "Востлит" для Manticore Search.

Обходит рекурсивно директорию с файлами (.html, .htm, .phtml), извлекает
заголовок и текст каждого документа (только содержимое <div id="main"> —
реальный текст труда, без меню/рекламы/футера сайта), приводит к UTF-8 и
загружает в Manticore пачками через HTTP JSON API (порт 9308).

Идемпотентность: id каждого документа в Manticore — стабильный хэш от его
относительного пути (doc_id_of). Загрузка идёт операцией "replace", а не
"insert", поэтому повторный прогон (полный или инкрементальный) обновляет
существующую запись вместо того, чтобы создавать дубль. Можно безопасно
запускать --full сколько угодно раз.

Дедупликация показа: отдельно считается content_hash — хэш текста труда.
Если один и тот же текст лежит под разными путями (разные rel_path → разные
id → разные строки в индексе), search.php группирует по content_hash, так
что в выдаче поиска дубли не размножаются.

Работает и для первого полного прогона, и для последующих докачек:
хранит контрольную точку (mtime последнего проиндексированного файла) в
CHECKPOINT_FILE и по умолчанию обрабатывает только файлы, изменённые
после неё. Для полного переиндексирования используйте --full.

Запуск (по SSH, в screen/tmux — прогон 100k+ файлов может занять время):
    screen -S vostlit_index
    python3.11 02_vostlit_index.py --full        # полный прогон
    python3.11 02_vostlit_index.py                # инкрементальная докачка (для cron)
"""

import argparse
import hashlib
import json
import logging
import os
import re
import sys
import time
from pathlib import Path

import chardet
import requests
from bs4 import BeautifulSoup

# ---------------------------------------------------------------------------
# НАСТРОЙКИ
# ---------------------------------------------------------------------------

BASE_DIR = Path(
    "/home/dayhanbiz/public_html/3-damja.science/"
    "Хранилище библиотеки истории стран бывшего Туркестана и сопредельных стран/"
    "Проект Востлит/Тексты проекта Восточная Литература/"
)

BASE_URL = (
    "https://3-damja.science/"
    "Хранилище библиотеки истории стран бывшего Туркестана и сопредельных стран/"
    "Проект Востлит/Тексты проекта Восточная Литература/"
)

WORK_DIR = Path(
    "/home/dayhanbiz/public_html/3-damja.science/"
    "Хранилище библиотеки истории стран бывшего Туркестана и сопредельных стран/"
    "Проект Востлит/vostlit_search/"
)

MANTICORE_HTTP = "http://127.0.0.1:9308"
INDEX_NAME = "vostlit"

CHECKPOINT_FILE = WORK_DIR / "db" / "vostlit_index_checkpoint.json"
LOG_FILE = WORK_DIR / "logs" / "vostlit_index.log"

BATCH_SIZE = 300

# Реальные расширения текстовых файлов коллекции (подтверждено подсчётом:
# 59353 htm + 38075 phtml + 275 html = 97703 файла с текстом)
TEXT_EXTENSIONS = {".html", ".htm", ".phtml"}

MAIN_CONTAINER_ID = "main"

NOISE_SELECTORS = [
    "script", "style", "nav", "header", "footer",
    "#headmenu", ".menu", "#menu", ".navigation", ".breadcrumbs",
    ".site-footer", ".site-header",
]

# ---------------------------------------------------------------------------

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
    handlers=[
        logging.FileHandler(LOG_FILE, encoding="utf-8"),
        logging.StreamHandler(sys.stdout),
    ],
)
log = logging.getLogger("vostlit_index")


def load_checkpoint() -> float:
    if CHECKPOINT_FILE.exists():
        try:
            return json.loads(CHECKPOINT_FILE.read_text(encoding="utf-8"))["last_mtime"]
        except Exception:
            return 0.0
    return 0.0


def save_checkpoint(mtime: float):
    CHECKPOINT_FILE.parent.mkdir(parents=True, exist_ok=True)
    CHECKPOINT_FILE.write_text(
        json.dumps({"last_mtime": mtime, "saved_at": time.time()}, ensure_ascii=False),
        encoding="utf-8",
    )


def detect_and_decode(raw: bytes) -> str:
    head = raw[:2048].decode("ascii", errors="ignore").lower()
    m = re.search(r'charset=["\']?([\w-]+)', head)
    declared = m.group(1) if m else None

    candidates = [c for c in [declared, "utf-8", "windows-1251", "koi8-r"] if c]
    for enc in candidates:
        try:
            return raw.decode(enc)
        except (UnicodeDecodeError, LookupError):
            continue

    guess = chardet.detect(raw)
    enc = guess.get("encoding") or "utf-8"
    return raw.decode(enc, errors="replace")


def is_trivial_title(t: str) -> bool:
    """Отсеивает технические заголовки-заглушки старых страниц:
    "-", "Neue Seite 11", "New Page 3", "Untitled", "Без имени" и т.п."""
    t = t.strip()
    if len(t) < 3:
        return True
    if re.fullmatch(r"[\W_]+", t):  # только знаки препинания, ни одной буквы
        return True
    if re.match(r"^(neue seite|new page|untitled|без\s*имени|document)\s*\d*$", t, re.IGNORECASE):
        return True
    return False


def extract_title_and_body(html: str, fallback_title: str) -> tuple[str, str]:
    soup = BeautifulSoup(html, "lxml")

    container = soup.find(id=MAIN_CONTAINER_ID)
    if container is None:
        container = soup.body or soup

    title = ""
    raw_title = soup.title.string.strip() if (soup.title and soup.title.string) else ""
    if raw_title and not is_trivial_title(raw_title):
        title = raw_title

    if not title:
        # <title> — техническая заглушка (частый случай на старых страницах
        # коллекции, сделанных в FrontPage: "Neue Seite 11", "-" и т.п.).
        # Настоящее заглавие обычно — несколько идущих подряд центрированных
        # абзацев/подзаголовков в начале документа (автор, произведение,
        # раздел), до первого обычного абзаца текста (align="justify").
        parts = []
        total_len = 0
        for tag in container.find_all(["p", "h1", "h2", "h3", "h4"], recursive=True):
            text = tag.get_text(" ", strip=True)
            if not text:
                continue
            align = (tag.get("align") or "").strip().lower()
            is_heading = tag.name in ("h1", "h2", "h3", "h4")
            if is_heading or align == "center":
                parts.append(text)
                total_len += len(text)
                if len(parts) >= 6 or total_len > 300:
                    break
            else:
                break  # первый "обычный" абзац — начало текста труда, стоп
        if parts:
            title = " — ".join(parts)

    if not title:
        h_tag = soup.find(["h1", "h2"])
        if h_tag:
            title = h_tag.get_text(strip=True)
    if not title:
        title = fallback_title
    title = re.sub(r"\s*->\s*", " — ", title)
    title = re.sub(r"\s+", " ", title).strip()

    for sel in NOISE_SELECTORS:
        for tag in container.select(sel):
            tag.decompose()

    raw_lines = container.get_text(separator="\n").split("\n")
    lines = []
    for line in raw_lines:
        line = line.strip()
        if not line:
            continue
        if line.startswith("©"):
            continue
        lines.append(line)
    body = re.sub(r"\s+", " ", " ".join(lines)).strip()

    return title, body


def stable_hash_int(text: str) -> int:
    """15 hex-цифр sha1 -> положительное 60-битное число (влезает в bigint)."""
    digest = hashlib.sha1(text.encode("utf-8")).hexdigest()
    return int(digest[:15], 16)


def doc_id_of(rel_path: str) -> int:
    """Стабильный id документа в Manticore, вычисленный из пути к файлу —
    благодаря этому повторный прогон обновляет ту же запись (replace),
    а не создаёт дубль."""
    return stable_hash_int(rel_path)


def content_hash_of(body: str) -> int:
    """Хэш текста для дедупликации показа (group by в Manticore)."""
    return stable_hash_int(body)


def iter_text_files(base: Path, since_mtime: float):
    for root, _dirs, files in os.walk(base):
        for name in files:
            ext = os.path.splitext(name)[1].lower()
            if ext in TEXT_EXTENSIONS:
                full = Path(root) / name
                try:
                    mtime = full.stat().st_mtime
                except OSError:
                    continue
                if mtime > since_mtime:
                    yield full, mtime


def make_doc(path: Path, mtime: float) -> dict | None:
    try:
        raw = path.read_bytes()
    except OSError as e:
        log.warning("Не удалось прочитать %s: %s", path, e)
        return None

    html = detect_and_decode(raw)
    fallback_title = path.stem
    title, body = extract_title_and_body(html, fallback_title)

    if not body:
        log.warning("Пустой текст после очистки: %s", path)
        return None

    rel = path.relative_to(BASE_DIR).as_posix()
    url = BASE_URL + rel

    return {
        "id": doc_id_of(rel),
        "title": title[:3000],
        "body": body,
        "rel_path": rel,
        "url": url,
        "lang": "ru",
        "content_hash": content_hash_of(body),
        "_mtime": mtime,
    }


def flush_batch(batch: list[dict], session: requests.Session):
    if not batch:
        return
    lines = []
    for doc in batch:
        doc_id = doc["id"]
        d = {k: v for k, v in doc.items() if k not in ("_mtime", "id")}
        lines.append(json.dumps({
            "replace": {"index": INDEX_NAME, "id": doc_id, "doc": d}
        }, ensure_ascii=False))
    payload = "\n".join(lines)
    resp = session.post(f"{MANTICORE_HTTP}/bulk",
                         data=payload.encode("utf-8"),
                         headers={"Content-Type": "application/x-ndjson"})
    if resp.status_code != 200:
        log.error("Ошибка bulk-загрузки: %s / %s", resp.status_code, resp.text[:500])
    else:
        result = resp.json()
        if result.get("errors"):
            log.error("Manticore вернул ошибки: %s", json.dumps(result)[:1000])


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--full", action="store_true",
                         help="Полное переиндексирование (игнорировать checkpoint)")
    args = parser.parse_args()

    since_mtime = 0.0 if args.full else load_checkpoint()
    log.info("Старт индексации. full=%s since_mtime=%s", args.full, since_mtime)

    session = requests.Session()
    batch = []
    max_mtime = since_mtime
    total = 0
    skipped = 0
    t0 = time.time()

    for path, mtime in iter_text_files(BASE_DIR, since_mtime):
        doc = make_doc(path, mtime)
        if doc is None:
            skipped += 1
            continue
        batch.append(doc)
        max_mtime = max(max_mtime, mtime)
        total += 1

        if len(batch) >= BATCH_SIZE:
            flush_batch(batch, session)
            batch = []
            if total % 3000 == 0:
                elapsed = time.time() - t0
                log.info("Проиндексировано %d файлов (%.1f/сек)", total, total / max(elapsed, 1))

    flush_batch(batch, session)
    save_checkpoint(max_mtime)

    log.info("Готово. Всего проиндексировано: %d, пропущено: %d, время: %.1f сек",
              total, skipped, time.time() - t0)


if __name__ == "__main__":
    main()
