import os
import re
import sys
import json
import urllib.request
import urllib.error
import urllib.parse
from pathlib import Path
from datetime import datetime, timezone
from typing import Optional

EN_DASH = "–"

SCRIPT_DIR = Path(__file__).resolve().parent
VAULT_ROOT = Path(os.environ.get("OBSIDIAN_VAULT", "/Users/nataliadudina/Desktop/ObsidianVault"))
INBOX_DIR = VAULT_ROOT / "_inbox"
# Ночной разбор крутится на Pi, а руками скилл запускают с мака. Состояние синка
# держим в волте, чтобы обе машины видели одно и то же: свой файл на каждой машине
# означал бы, что мак заново вытащит митинги, которые Pi уже разложил по проектам.
STATE_DIR = Path(os.environ.get("GRANOLA_STATE_DIR", VAULT_ROOT / ".granola-sync"))
LAST_SYNC_FILE = STATE_DIR / "last_sync.txt"
PROCESSED_IDS_FILE = STATE_DIR / "processed_ids.json"
ENV_FILE = SCRIPT_DIR / ".env"

# Официальный публичный API Granola (Business/Enterprise). Пришёл на смену
# скрейпингу локального токена — Granola 7.x зашифровала всё хранилище.
API_BASE = "https://public-api.granola.ai/v1"


def load_api_key() -> str:
    key = os.environ.get("GRANOLA_API_KEY")
    if not key and ENV_FILE.exists():
        for line in ENV_FILE.read_text().splitlines():
            line = line.strip()
            if line.startswith("GRANOLA_API_KEY="):
                key = line.split("=", 1)[1].strip().strip('"').strip("'")
                break
    if not key:
        print("Не найден GRANOLA_API_KEY. Создай ключ grn_… в Granola: "
              "Settings → Connectors → API keys, и положи в .env.")
        sys.exit(1)
    return key


class GranolaApiError(RuntimeError):
    """Невосстановимая ошибка API (не 404) — прерывает синк, но не должна
    роняться до сохранения уже обработанных id."""


def api_get(path: str, key: str, params: Optional[dict] = None,
            allow_404: bool = False) -> object:
    url = f"{API_BASE}{path}"
    if params:
        url += "?" + urllib.parse.urlencode(params)
    req = urllib.request.Request(
        url,
        headers={
            "Authorization": f"Bearer {key}",
            "Accept": "application/json",
        },
        method="GET",
    )
    try:
        with urllib.request.urlopen(req) as resp:
            return json.loads(resp.read())
    except urllib.error.HTTPError as e:
        if e.code == 404 and allow_404:
            return None
        body = e.read().decode("utf-8", errors="replace")
        msg = f"Ошибка API {e.code} на {path}: {body[:300]}"
        if e.code in (401, 403):
            msg += ("\nКлюч невалиден/нет доступа — пересоздай в Granola: "
                    "Settings → Connectors → API keys (нужен план Business/Enterprise).")
        raise GranolaApiError(msg) from e


def fetch_documents(key: str) -> list:
    """Все заметки с курсорной пагинацией. API отдаёт только митинги
    с готовым AI-саммари и транскриптом."""
    notes = []
    cursor = None
    while True:
        params = {"cursor": cursor} if cursor else {}
        data = api_get("/notes", key, params)
        batch = data.get("notes", []) if isinstance(data, dict) else []
        notes.extend(batch)
        cursor = data.get("cursor") if isinstance(data, dict) else None
        if not (isinstance(data, dict) and data.get("hasMore") and cursor):
            break
    return notes


def fetch_transcript(note_id: str, key: str) -> Optional[list]:
    # Непроцессенные заметки отдают 404 — тогда подтянем в следующий раз.
    data = api_get(f"/notes/{note_id}", key, {"include": "transcript"}, allow_404=True)
    if isinstance(data, dict):
        t = data.get("transcript")
        return t if isinstance(t, list) else None
    return None


def format_filename(title: str, date: str) -> str:
    if not title:
        title = ""
    slug = str(title).lower()
    slug = re.sub(r"[^\w\sа-яёА-ЯЁ\-]", "", slug)
    slug = re.sub(r"[\s_]+", "-", slug)
    slug = re.sub(r"-+", "-", slug)
    slug = slug.strip("-")
    if not slug:
        slug = "без-названия"

    prefix = "{meeting} {transcript} "
    suffix = f" {EN_DASH} {date}.md"
    max_slug_len = max(80 - len(prefix) - len(suffix), 10)
    slug = slug[:max_slug_len].rstrip("-")
    return f"{prefix}{slug}{suffix}"


def read_last_sync(path: Path) -> Optional[str]:
    try:
        return path.read_text().strip()
    except FileNotFoundError:
        return None


def write_last_sync(path: Path, dt: datetime) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(dt.strftime("%Y-%m-%dT%H:%M:%SZ"))


def load_processed_ids(path: Path) -> Optional[set]:
    """Множество id уже обработанных митингов.

    None — файла нет (первый запуск после миграции): дедуп ещё не инициализирован.
    """
    try:
        return set(json.loads(path.read_text()))
    except FileNotFoundError:
        return None


def save_processed_ids(path: Path, ids: set) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(sorted(ids), ensure_ascii=False))


def format_transcript(items: list) -> Optional[str]:
    """Сегменты официального API: {"speaker": {"source": ...}, "text": ...},
    уже в хронологическом порядке."""
    lines = []
    for seg in items:
        if not isinstance(seg, dict):
            continue
        source = (seg.get("speaker") or {}).get("source", "")
        label = "Я" if source == "microphone" else "Собеседник"
        text = (seg.get("text") or "").strip()
        if text:
            lines.append(f"**{label}:** {text}")
    return "\n\n".join(lines) if lines else None


# Пороги сторожа качества. Откалиброваны 26.07.2026 на 187 транскриптах волта:
# у здоровых встреч разрыв по доле реплик без кириллицы между дорожками ±2 п.п.,
# у развалившегося распознавания (интро 04.06) — +21 п.п. при длине реплик вдвое
# короче. См. docs/ и разбор в projects/ai-native/.
MIN_SEGMENTS_TO_JUDGE = 20
LATIN_GAP_THRESHOLD = 0.15
LENGTH_RATIO_FLOOR = 0.55
SHORT_SEGMENT_CHARS = 60

CYRILLIC_RE = re.compile(r"[а-яёА-ЯЁ]")


def _track_stats(items: list, source_is_mic: bool) -> Optional[dict]:
    """Число реплик, средняя длина и доля реплик без единой кириллической
    буквы по одной дорожке. None — дорожки нет."""
    texts = []
    for seg in items:
        if not isinstance(seg, dict):
            continue
        is_mic = (seg.get("speaker") or {}).get("source", "") == "microphone"
        if is_mic != source_is_mic:
            continue
        text = (seg.get("text") or "").strip()
        if text:
            texts.append(text)
    if not texts:
        return None
    latin = sum(1 for t in texts if not CYRILLIC_RE.search(t))
    return {
        "count": len(texts),
        "avg_len": sum(len(t) for t in texts) / len(texts),
        "latin_share": latin / len(texts),
    }


def check_transcript_quality(items: list) -> Optional[str]:
    """Ловит развалившееся распознавание одной из дорожек.

    Возвращает текст предупреждения или None. Смотрит на разрыв МЕЖДУ
    дорожками, а не на абсолютные значения: встреча целиком на английском
    даёт латиницу с обеих сторон и предупреждения не вызывает, а вот когда
    твоя дорожка по-русски, а дорожка собеседника рассыпалась в «Okay» и
    «que» — это плохой входящий звук, и знать об этом надо сразу.
    """
    if not isinstance(items, list):
        return None
    mine = _track_stats(items, source_is_mic=True)
    theirs = _track_stats(items, source_is_mic=False)
    if not mine or not theirs:
        return None
    # Слишком мало реплик, чтобы отличить поломку от молчаливого собеседника.
    if mine["count"] < MIN_SEGMENTS_TO_JUDGE or theirs["count"] < MIN_SEGMENTS_TO_JUDGE:
        return None

    # Единственный триггер — разрыв по латинице. Короткие реплики сами по себе
    # триггером быть не могут: проверка на 187 транскриптах волта показала, что
    # в живом диалоге собеседник естественно отвечает вдвое короче («О, привет»,
    # «Да, и быстренько»), и восемь таких файлов оказались ложными срабатываниями.
    latin_gap = theirs["latin_share"] - mine["latin_share"]
    if latin_gap <= LATIN_GAP_THRESHOLD:
        return None

    problems = [
        f"{theirs['latin_share']:.0%} реплик собеседника без кириллицы "
        f"против {mine['latin_share']:.0%} у тебя"
    ]
    ratio = theirs["avg_len"] / mine["avg_len"] if mine["avg_len"] else 1.0
    if ratio < LENGTH_RATIO_FLOOR and theirs["avg_len"] < SHORT_SEGMENT_CHARS:
        problems.append(
            f"и они вдвое короче твоих "
            f"({theirs['avg_len']:.0f} против {mine['avg_len']:.0f} знаков)"
        )
    return "плохой звук у собеседника — " + ", ".join(problems)


# Пороги сторожа обрыва. Собес GRI 27.07.2026: запись 10:30:32 → 11:01:20,
# дорожка собеседника оборвалась на 10:32:53 и не возобновилась — 28 минут
# ответов не записалось, и синк об этом промолчал. Второй случай той же недели:
# «Саша / Ксюша / Наташа» 24.07, потеряно 39 минут. Причина на стороне Granola
# (перехват системного звука отваливается после перезапуска аудио-IO в macOS),
# починить её мы не можем — значит надо хотя бы узнавать в тот же день.
MIN_RECORDING_SEC_TO_JUDGE = 600   # на встрече короче 10 мин пропажу от паузы не отличить
DROPOUT_MIN_GAP_SEC = 300          # тишина меньше 5 мин — нормальный монолог
DROPOUT_MIN_SHARE = 0.2            # и она же должна быть заметной долей записи


def _parse_ts(value) -> Optional[datetime]:
    if not isinstance(value, str):
        return None
    try:
        return datetime.fromisoformat(value.replace("Z", "+00:00"))
    except ValueError:
        return None


def _track_times(items: list, source_is_mic: bool) -> list:
    """Все распознанные отметки времени одной дорожки."""
    times = []
    for seg in items:
        if not isinstance(seg, dict):
            continue
        is_mic = (seg.get("speaker") or {}).get("source", "") == "microphone"
        if is_mic != source_is_mic:
            continue
        if not (seg.get("text") or "").strip():
            continue
        for key in ("start_time", "end_time"):
            ts = _parse_ts(seg.get(key))
            if ts:
                times.append(ts)
    return times


def check_track_dropout(items: list) -> Optional[str]:
    """Ловит дорожку, которая замолчала задолго до конца записи.

    Это не «плохо распозналось» (для этого есть check_transcript_quality),
    а «звук не писался вовсе»: перерасшифровка тут не поможет, и знать об
    этом надо сразу, пока разговор ещё помнишь. Молчит, когда дорожки нет
    совсем (сольная запись) и когда времени в сегментах нет (старые данные).
    """
    if not isinstance(items, list):
        return None
    mine = _track_times(items, source_is_mic=True)
    theirs = _track_times(items, source_is_mic=False)
    if not mine or not theirs:
        return None

    start = min(min(mine), min(theirs))
    end = max(max(mine), max(theirs))
    duration = (end - start).total_seconds()
    if duration < MIN_RECORDING_SEC_TO_JUDGE:
        return None

    for times, label in ((theirs, "дорожка собеседника"), (mine, "твоя дорожка")):
        gap = (end - max(times)).total_seconds()
        if gap >= DROPOUT_MIN_GAP_SEC and gap >= duration * DROPOUT_MIN_SHARE:
            heard = (max(times) - start).total_seconds()
            return (f"{label} оборвалась за {round(gap / 60)} мин до конца записи "
                    f"(слышно {round(heard / 60)} мин из {round(duration / 60)}) — "
                    f"речь за это время не записалась вообще")
    return None


def build_file_content(title: str, date: str, granola_id: str, transcript: str,
                       warning: Optional[str] = None) -> str:
    warning_line = f"\ntranscript_warning: {warning}" if warning else ""
    return f"""---
tags:
  - type/transcript
  - topic/meeting
date: {date}
granola_id: {granola_id}{warning_line}
---

# {title}

## Транскрипт

{transcript}
"""


def unique_meeting_path(meetings_dir: Path, filename: str) -> Path:
    """Незанятый путь для файла митинга. При коллизии (второй одноимённый
    митинг в тот же день) добавляет суффикс -2, -3… перед расширением —
    вместо молчаливого skip, который терял транскрипт."""
    path = meetings_dir / filename
    if not path.exists():
        return path
    stem = filename[:-3] if filename.endswith(".md") else filename
    ext = ".md" if filename.endswith(".md") else ""
    n = 2
    while True:
        candidate = meetings_dir / f"{stem}-{n}{ext}"
        if not candidate.exists():
            return candidate
        n += 1


def write_meeting_file(path: Path, content: str) -> None:
    path.write_text(content, encoding="utf-8")


def main():
    key = load_api_key()

    INBOX_DIR.mkdir(exist_ok=True)

    try:
        all_docs = fetch_documents(key)
    except GranolaApiError as e:
        print(str(e), file=sys.stderr)
        sys.exit(1)
    docs = [d for d in all_docs if isinstance(d, dict) and d.get("created_at")]

    processed = load_processed_ids(PROCESSED_IDS_FILE)
    if processed is None:
        # Миграция: дедуп раньше шёл по времени (last_sync). Считаем уже
        # обработанными все митинги, созданные не позже last_sync, — чтобы не
        # перекачивать всю историю. Дальше дедуп идёт строго по id.
        last_sync = read_last_sync(LAST_SYNC_FILE)
        processed = set()
        if last_sync:
            processed = {d["id"] for d in docs if d["created_at"] <= last_sync}
            print(f"Первый запуск нового дедупа — посев по last_sync: {last_sync}")
        else:
            print("Первый запуск — синхронизирую все митинги")
    else:
        print(f"Уже обработано митингов: {len(processed)}")

    candidates = [d for d in docs if d["id"] not in processed]
    candidates.sort(key=lambda d: d["created_at"])
    print(f"Новых митингов: {len(candidates)}")

    written = 0
    skipped_no_transcript = 0
    renamed_collisions = 0
    degraded = 0
    dropouts = 0
    api_error = None

    try:
        for doc in candidates:
            doc_id = doc["id"]
            title = doc.get("title") or "без-названия"
            date = doc["created_at"][:10]

            try:
                items = fetch_transcript(doc_id, key)
            except GranolaApiError as e:
                api_error = e
                break
            transcript = format_transcript(items) if isinstance(items, list) else None
            if transcript is None:
                # Транскрипт ещё не готов — не помечаем обработанным, повторим позже.
                print(f"  ⚠ Нет транскрипта: {title} — пропущен")
                skipped_no_transcript += 1
                continue

            dropout = check_track_dropout(items)
            warning = " · ".join(
                w for w in (dropout, check_transcript_quality(items)) if w
            ) or None
            filename = format_filename(title, date)
            content = build_file_content(title, date, doc_id, transcript, warning)
            path = unique_meeting_path(INBOX_DIR, filename)
            if path.name != filename:
                print(f"  ⚠ Коллизия имени — пишу как {path.name}")
                renamed_collisions += 1
            write_meeting_file(path, content)
            print(f"  ✓ {path.name}")
            if warning:
                print(f"    ⚠ {warning}")
                degraded += 1
            if dropout:
                dropouts += 1
            written += 1

            processed.add(doc_id)
    finally:
        # Сохраняем прогресс независимо от того, как закончился цикл —
        # иначе ошибка API посреди синка теряет уже обработанные id.
        save_processed_ids(PROCESSED_IDS_FILE, processed)

    if api_error:
        print(f"\n✗ Остановлено из-за ошибки API: {api_error}", file=sys.stderr)
        print(f"  Прогресс сохранён ({len(processed)} обработано), повтори запуск позже.",
              file=sys.stderr)
        sys.exit(1)

    write_last_sync(LAST_SYNC_FILE, datetime.now(timezone.utc))

    print()
    print(f"✓ Синхронизировано: {written}")
    print(f"⚠ Пропущено (нет транскрипта): {skipped_no_transcript}")
    if renamed_collisions:
        print(f"⚠ Коллизий имени (записаны с суффиксом): {renamed_collisions}")
    if dropouts:
        print(f"⚠ С оборванной дорожкой: {dropouts} — часть разговора не записалась "
              f"вовсе, перерасшифровка не поможет. Запиши по памяти сегодня, "
              f"пока помнишь.")
    if degraded:
        print(f"⚠ С плохим звуком у собеседника: {degraded} — "
              f"перед разбором проверь, есть ли запись Zoom (лучше раздельными "
              f"дорожками), и перерасшифруй через groq-transcribe")


if __name__ == "__main__":
    main()
