"""Оркестратор: тянет TGStat, обновляет SQLite, генерирует выходные файлы."""
from __future__ import annotations

import argparse
import fcntl
import json
import os
import sys
import time
import traceback
from datetime import datetime, timezone, timedelta
from pathlib import Path

from dotenv import load_dotenv

import db as db_module
import render as render_module
import snapshots as snapshots_module
from tgstat import (
    LimitReachedError,
    NotFoundError,
    RateLimitError,
    TGStatClient,
    TGStatError,
    mask_token,
)

SCRIPT_DIR = Path(__file__).resolve().parent
VAULT_ROOT = SCRIPT_DIR.parent.parent.parent
# На Linux-сервере путь к БД задаётся через env TGSTAT_DB_PATH; по умолчанию — macOS-путь.
# Это только стартовое значение: TGSTAT_DB_PATH из .env (загружается позже,
# внутри run()) переопределяет его через resolve_db_path() — см. там подробный
# комментарий про то, почему одной этой module-level константы недостаточно.
_ENV_DB = os.environ.get("TGSTAT_DB_PATH")
if _ENV_DB:
    DB_PATH = Path(_ENV_DB)
    DATA_DIR = DB_PATH.parent
else:
    DATA_DIR = Path.home() / "Library" / "Application Support" / "tgstat-puller"
    DB_PATH = DATA_DIR / "channels.db"
PID_FILE = DATA_DIR / "pull.pid"
ENV_FILE = SCRIPT_DIR / ".env"

CHANNELS_CFG = {
    "natashhhh": {
        "label": "Natasha (личный)",
        "output_dir_rel": Path("projects") / "channel",
        "json_name": "{channel} {source} TGStat архив постов канала.json",
        "md_name": "{channel} {source} TGStat архив постов канала.md",
        "is_personal": True,
    },
    "marsingru": {
        "label": "Mars",
        "output_dir_rel": Path("projects") / "mars" / "tgstat-snapshots",
        "json_name_template": "{{mars}} {{source}} посты канала marsingru – {date}.json",
        "is_personal": False,
    },
    "choooooooir": {
        "label": "Хор",
        "output_dir_rel": Path("projects") / "mars" / "tgstat-snapshots",
        "json_name_template": "{{mars}} {{source}} посты канала choooooooir – {date}.json",
        "is_personal": False,
    },
    "tvorcheskiye_lyudi": {
        "label": "Творческие люди",
        "output_dir_rel": Path("projects") / "mars" / "tgstat-snapshots",
        "json_name_template": "{{mars}} {{source}} посты канала tvorcheskiye_lyudi – {date}.json",
        "is_personal": False,
    },
}

# Refresh-цикл (posts_in_window → один запрос posts/stat НА КАЖДЫЙ пост за окно) —
# основной расход API-квоты. --no-refresh его полностью отключает.
REFRESH_WINDOW_DAYS = 14
POSTS_PER_PAGE = 50
MAX_POSTS_INITIAL = 1000


def acquire_lock() -> int | None:
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    fd = os.open(PID_FILE, os.O_CREAT | os.O_WRONLY, 0o600)
    try:
        fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except BlockingIOError:
        os.close(fd)
        return None
    os.ftruncate(fd, 0)
    os.write(fd, str(os.getpid()).encode())
    return fd


def release_lock(fd: int | None) -> None:
    if fd is None:
        return
    fcntl.flock(fd, fcntl.LOCK_UN)
    os.close(fd)


def load_token() -> str:
    # .env берём от текущего SCRIPT_DIR, а не от константы, посчитанной при
    # импорте: иначе в тестах (где SCRIPT_DIR подменён на tmp) грузился боевой
    # .env, оттуда в окружение попадала TGSTAT_VAULT_ROOT — и рендер уходил в
    # настоящий волт, обнуляя архив канала.
    load_dotenv(SCRIPT_DIR / ".env")
    token = os.environ.get("TGSTAT_TOKEN")
    if not token:
        print("В .env нет TGSTAT_TOKEN.", file=sys.stderr)
        sys.exit(1)
    return token


def _msg_id_from_link(link: str | None) -> int | None:
    import re
    if not link:
        return None
    m = re.search(r"/(\d+)/?$", link)
    return int(m.group(1)) if m else None


def _process_channel(
    *,
    client: TGStatClient,
    channel: str,
    dry_run: bool,
    no_refresh: bool,
) -> None:
    info = client.channels_get(f"@{channel}")
    if not dry_run:
        db_module.upsert_channel(
            DB_PATH,
            username=channel,
            tg_id=info.get("tg_id") or info.get("id"),
            title=info.get("title"),
            subscribers=info.get("participants_count"),
        )
        db_module.insert_snapshot(
            DB_PATH,
            channel=channel,
            subscribers=info.get("participants_count"),
        )

    offset = 0
    fetched_new = 0
    while offset < MAX_POSTS_INITIAL:
        resp = client.channels_posts(f"@{channel}", limit=POSTS_PER_PAGE, offset=offset)
        items = resp.get("items", [])
        if not items:
            break
        added_in_batch = 0
        for it in items:
            post_id = _msg_id_from_link(it.get("link"))
            if post_id is None:
                continue
            ts = it.get("date")
            iso_date = (
                datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
                if isinstance(ts, int) else str(ts)
            )
            raw_link = it.get("link") or ""
            if raw_link.startswith("http"):
                link = raw_link
            elif raw_link:
                link = f"https://{raw_link}"
            else:
                link = None
            post = {
                "post_id": post_id,
                "date": iso_date,
                "text": it.get("text") or "",
                "link": link,
                "media_type": (it.get("media") or {}).get("media_type"),
                "views": it.get("views"),
                "forwards": it.get("forwards"),
            }
            if not dry_run:
                added_in_batch += db_module.insert_post(DB_PATH, channel, post)
        fetched_new += added_in_batch
        if added_in_batch == 0:
            break
        offset += POSTS_PER_PAGE
        time.sleep(0.1)

    if not no_refresh and not dry_run:
        rows = db_module.posts_in_window(DB_PATH, channel, days=REFRESH_WINDOW_DAYS)
        for r in rows:
            try:
                stat = client.posts_stat(r["link"])
            except NotFoundError:
                db_module.mark_deleted(DB_PATH, channel, r["post_id"])
                continue
            normalized = {
                "views": stat.get("viewsCount"),
                "reactions": stat.get("reactionsCount"),
                "comments": stat.get("commentsCount"),
                "forwards": stat.get("sharesCount"),
                "reposts": stat.get("forwardsCount"),
                "er": stat.get("er"),
                "err": stat.get("err"),
                "views_growth": stat.get("views"),
            }
            db_module.update_post_stats(DB_PATH, channel, r["post_id"], normalized)


def resolve_db_path() -> Path:
    """Путь к БД: свежий env важнее module-level DB_PATH — тот же приём, что
    `resolve_vault_root()` применяет для VAULT_ROOT.

    DB_PATH резолвится на уровне модуля при **импорте** — раньше, чем внутри
    run() успевает отработать load_dotenv(). Полагаться только на
    `Environment=` в systemd-юните опасно: прямой ручной запуск
    (`python pull.py`, в обход юнита) эту переменную не получит и по старой
    логике тихо создавал бы новую пустую БД по дефолтному macOS-пути —
    рендер архива в волт затирал бы накопленную историю почти пустой (ровно
    так один раз и получилось, 2026-07-29, поймано по диффу в волте).
    Вызывается в начале run(), после load_dotenv() — так же, как
    resolve_vault_root() вызывается после того, как токен уже подгружен.
    """
    env_db = os.environ.get("TGSTAT_DB_PATH")
    return Path(env_db) if env_db else DB_PATH


def resolve_vault_root() -> Path:
    """Корень волта: env важнее модульной константы.

    Код переехал из волта в ~/Projects (2026-07-02) — эвристика «три папки
    вверх» больше не находит волт, поэтому путь задаётся через
    TGSTAT_VAULT_ROOT (грузится из .env). Вызывается один раз в run(), а не
    внутри рендера: так подмена pull.VAULT_ROOT в тестах работает так, как от
    неё все ожидают.
    """
    return Path(os.environ.get("TGSTAT_VAULT_ROOT") or VAULT_ROOT)


def _render_outputs(channel: str, partial: bool, vault_root: Path) -> None:
    cfg = CHANNELS_CFG[channel]
    out_dir = vault_root / cfg["output_dir_rel"]
    out_dir.mkdir(parents=True, exist_ok=True)
    if cfg["is_personal"]:
        json_path = out_dir / cfg["json_name"]
        md_path = out_dir / cfg["md_name"]
        render_module.render_channel_json(DB_PATH, channel, json_path)
        render_module.render_personal_md(DB_PATH, channel, md_path, partial=partial)
    else:
        today = datetime.now().strftime("%Y-%m-%d")
        json_name = cfg["json_name_template"].format(date=today)
        json_path = out_dir / json_name
        render_module.render_channel_json(DB_PATH, channel, json_path)
        render_module.cleanup_dated_snapshots(out_dir, channel_username=channel, keep_file=json_path)


def append_snapshots(db_path: Path, vault_root: Path, today: str, window_days: int = 14) -> int:
    """Снять снапшот просмотров/пересылок личного канала для истории.

    Берёт цифры из локальной БД (уже свежие после refresh-цикла пула, второй
    запрос к TGStat не нужен) и дописывает их через snapshots.write_rows.
    Только для личного канала — у Mars-каналов своя история через
    channel_snapshots/подписчиков, эта задача про посты natashhhh.
    """
    rows = db_module.posts_in_window(db_path, "natashhhh", days=window_days)
    posts = [
        {
            "msg_id": r["post_id"],
            "posted_at": r["date"],
            "views": r["views"],
            "forwards": r["forwards"],
        }
        for r in rows
    ]
    out_path = Path(vault_root) / "projects" / "channel" / "{channel} {source} снапшоты просмотров.jsonl"
    return snapshots_module.write_rows(out_path, posts, today, window_days=window_days)


def run(channels: list[str], *, dry_run: bool, no_refresh: bool) -> int:
    global DB_PATH, DATA_DIR, PID_FILE

    # .env берём от текущего SCRIPT_DIR (см. комментарий в load_token()) —
    # и делаем это ДО резолюции DB_PATH, а не только внутри load_token():
    # TGSTAT_DB_PATH должен быть виден резолверу до того, как что-либо
    # запишется на диск, а не после первого обращения к токену.
    load_dotenv(SCRIPT_DIR / ".env")
    resolved_db = resolve_db_path()
    if resolved_db != DB_PATH:
        DB_PATH = resolved_db
        DATA_DIR = DB_PATH.parent
        PID_FILE = DATA_DIR / "pull.pid"

    if sys.platform != "darwin" and not os.environ.get("TGSTAT_DB_PATH"):
        # Условие — про ПРОИСХОЖДЕНИЕ пути, а не про то, есть ли там уже
        # файл. Первая версия гварда проверяла `not DB_PATH.exists()` и
        # пропускала ровно тот случай, который должна была ловить: если по
        # фолбэк-пути уже лежит файл — легаси-БД с прошлых времён, или
        # каталог, оставшийся от предыдущего инцидента, — гвард молча
        # пропускал бы пул на неё. Ни окружение процесса, ни .env не
        # задают TGSTAT_DB_PATH, а платформа не macOS: неважно, что лежит
        # по DB_PATH — путь не был задан осознанно, продолжать нельзя.
        raise RuntimeError(
            f"TGSTAT_DB_PATH не задан (ни в окружении процесса, ни в .env "
            f"в {SCRIPT_DIR}), платформа не macOS. Отказываюсь молча "
            f"работать с БД по фолбэк-пути {DB_PATH} — задайте "
            "TGSTAT_DB_PATH явно."
        )

    DATA_DIR.mkdir(parents=True, exist_ok=True)
    db_module.init_db(DB_PATH)

    fd = acquire_lock()
    if fd is None:
        print("Уже запущен другой инстанс. Выходим.", file=sys.stderr)
        return 2

    try:
        if not dry_run:
            db_module.backup_db(DB_PATH)

        token = load_token()
        client = TGStatClient(token=token)
        started_at = db_module.start_run(DB_PATH) if not dry_run else "dry-run"

        succeeded: list[str] = []
        failed: list[tuple[str, str]] = []

        for channel in channels:
            try:
                _process_channel(
                    client=client, channel=channel,
                    dry_run=dry_run, no_refresh=no_refresh,
                )
                succeeded.append(channel)
            except LimitReachedError as e:
                msg = f"limit reached: {e}"
                print(msg, file=sys.stderr)
                failed.append((channel, msg))
                break
            except Exception as e:
                err = mask_token(str(e))
                print(f"канал @{channel} упал: {err}", file=sys.stderr)
                traceback.print_exc(file=sys.stderr)
                failed.append((channel, err))

        partial = bool(failed)
        if not dry_run:
            render_error: str | None = None
            # На сервере (TGSTAT_NO_RENDER=1) рендер файлов в волт не нужен — только БД.
            if not os.environ.get("TGSTAT_NO_RENDER"):
                try:
                    vault_root = resolve_vault_root()
                    for channel in succeeded:
                        _render_outputs(channel, partial=partial, vault_root=vault_root)
                    if "natashhhh" in succeeded:
                        try:
                            today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
                            n = append_snapshots(DB_PATH, vault_root, today)
                            print(f"снапшот просмотров: {n} строк")
                        except Exception as e:
                            print(
                                f"не удалось записать снапшот просмотров: {mask_token(str(e))}",
                                file=sys.stderr,
                            )
                except Exception as e:
                    render_error = mask_token(str(e))
                    print(f"ошибка рендеринга: {render_error}", file=sys.stderr)
                    traceback.print_exc(file=sys.stderr)

            if render_error:
                status = "failed"
                error_summary = f"render: {render_error}"
                if failed:
                    error_summary = (
                        "; ".join(f"{c}: {e}" for c, e in failed)
                        + f"; {error_summary}"
                    )
            else:
                status = "ok" if not failed else ("partial" if succeeded else "failed")
                error_summary = "; ".join(f"{c}: {e}" for c, e in failed)

            db_module.finish_run(
                DB_PATH,
                started_at=started_at,
                status=status,
                succeeded=succeeded,
                failed=[c for c, _ in failed],
                error_summary=error_summary,
            )

            try:
                from alert_check import should_alert, send_alert
                if status == "failed" and should_alert(DB_PATH):
                    send_alert("⚠️ tgstat-puller: 3 запуска подряд завершились с ошибкой.")
            except Exception as e:
                print(f"не удалось отправить алерт: {e}", file=sys.stderr)

            if render_error:
                return 1

        return 0 if not failed else 1
    finally:
        release_lock(fd)


def main() -> None:
    parser = argparse.ArgumentParser(description="tgstat-puller: тянет посты 3 каналов через TGStat в SQLite + JSON")
    parser.add_argument("--dry-run", action="store_true", help="ходить в API, но ничего не писать")
    parser.add_argument("--channel", help="обработать только один канал")
    parser.add_argument("--no-refresh", action="store_true", help="пропустить обновление статистики свежих")
    args = parser.parse_args()

    channels = [args.channel] if args.channel else list(CHANNELS_CFG.keys())
    code = run(channels=channels, dry_run=args.dry_run, no_refresh=args.no_refresh)
    sys.exit(code)


if __name__ == "__main__":
    main()
