"""Чтение Slack/Viktor токенов напрямую из локальных браузеров.

Поддерживает Chrome, Edge, Brave, Chromium, Firefox.
Не открывает окно браузера — читает sqlite/leveldb даже если браузер запущен
(копируем файлы во временную папку).

CLI-режим (используется из TUI):
    python -m viktor_valency.browser_extract --cli --name <name> --proxy-port 8799

Эмитит JSON-events на stdout (по строке):
    {"event":"start","name":"work"}
    {"event":"info","msg":"сканирую браузеры..."}
    {"event":"found","browser":"Firefox","profile":"...","fields":{"slack_token":true,"viktor_jwt":true}}
    {"event":"saved","detect":{...}}
    {"event":"done"}
    {"event":"error","msg":"...","partial":{...}}
"""
from __future__ import annotations

import argparse
import base64
import json
import os
import re
import shutil
import sqlite3
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any

# ----------------------------- Optional deps -----------------------------

try:
    import snappy
    HAS_SNAPPY = True
except ImportError:
    HAS_SNAPPY = False


# ----------------------------- DPAPI расшифровка -----------------------------

def _dpapi_unprotect(blob: bytes) -> bytes:
    """Windows-only: расшифровать DPAPI-зашифрованный блоб."""
    if sys.platform != "win32":
        raise RuntimeError("DPAPI доступен только на Windows")
    import ctypes
    from ctypes import wintypes

    class DATA_BLOB(ctypes.Structure):
        _fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_byte))]

    in_blob = DATA_BLOB(len(blob), ctypes.cast(ctypes.c_char_p(blob), ctypes.POINTER(ctypes.c_byte)))
    out_blob = DATA_BLOB()
    crypt32 = ctypes.windll.crypt32
    if not crypt32.CryptUnprotectData(
        ctypes.byref(in_blob), None, None, None, None, 0, ctypes.byref(out_blob)
    ):
        raise RuntimeError(f"CryptUnprotectData failed (err={ctypes.windll.kernel32.GetLastError()})")
    out = ctypes.string_at(out_blob.pbData, out_blob.cbData)
    ctypes.windll.kernel32.LocalFree(out_blob.pbData)
    return out


def _read_chrome_master_key(user_data_root: Path) -> bytes | None:
    local_state = user_data_root / "Local State"
    if not local_state.exists():
        return None
    try:
        data = json.loads(local_state.read_text(encoding="utf-8"))
        enc_key_b64 = data.get("os_crypt", {}).get("encrypted_key", "")
        if not enc_key_b64:
            return None
        enc_key = base64.b64decode(enc_key_b64)
        if not enc_key.startswith(b"DPAPI"):
            return None
        return _dpapi_unprotect(enc_key[5:])
    except Exception:
        return None


def _aes_gcm_decrypt(master_key: bytes, blob: bytes) -> str:
    try:
        from cryptography.hazmat.primitives.ciphers.aead import AESGCM
    except ImportError as e:
        raise RuntimeError("требуется пакет 'cryptography' для расшифровки cookies") from e
    if blob[:3] not in (b"v10", b"v11"):
        return _dpapi_unprotect(blob).decode("utf-8", errors="ignore")
    nonce = blob[3:15]
    ciphertext = blob[15:]
    plain = AESGCM(master_key).decrypt(nonce, ciphertext, None)
    return plain.decode("utf-8", errors="ignore")


# ----------------------------- Безопасное копирование БД -----------------------------

def _copy_to_temp(src: Path, name: str | None = None) -> tuple[Path, Path]:
    """Скопировать файл во временную папку. Возвращает (file_path, parent_dir для cleanup)."""
    tmp_dir = Path(tempfile.mkdtemp(prefix="vv_extract_"))
    dst = tmp_dir / (name or src.name)
    shutil.copy2(src, dst)
    return dst, tmp_dir


def _cleanup(parent: Path) -> None:
    try:
        shutil.rmtree(parent, ignore_errors=True)
    except Exception:
        pass


# ----------------------------- Chrome family extractor -----------------------------

def _chrome_user_data_dirs() -> list[tuple[str, Path]]:
    """[(label, path), ...] для Chrome / Edge / Brave / Chromium."""
    home = Path.home()
    candidates = [
        ("Chrome",   home / "AppData/Local/Google/Chrome/User Data"),
        ("Edge",     home / "AppData/Local/Microsoft/Edge/User Data"),
        ("Brave",    home / "AppData/Local/BraveSoftware/Brave-Browser/User Data"),
        ("Chromium", home / "AppData/Local/Chromium/User Data"),
        ("Yandex",   home / "AppData/Local/Yandex/YandexBrowser/User Data"),
    ]
    if sys.platform == "darwin":
        candidates += [
            ("Chrome",   home / "Library/Application Support/Google/Chrome"),
            ("Edge",     home / "Library/Application Support/Microsoft Edge"),
            ("Brave",    home / "Library/Application Support/BraveSoftware/Brave-Browser"),
        ]
    elif sys.platform.startswith("linux"):
        candidates += [
            ("Chrome",   home / ".config/google-chrome"),
            ("Chromium", home / ".config/chromium"),
            ("Brave",    home / ".config/BraveSoftware/Brave-Browser"),
        ]
    return [(label, p) for label, p in candidates if p.exists()]


def _chrome_profiles(user_data: Path) -> list[Path]:
    out = []
    for sub in user_data.iterdir():
        if not sub.is_dir():
            continue
        if sub.name == "Default" or sub.name.startswith("Profile "):
            if (sub / "Network/Cookies").exists() or (sub / "Cookies").exists() or (sub / "Local Storage/leveldb").exists():
                out.append(sub)
    return out


def _chrome_extract_slack_cookie(profile: Path, master_key: bytes | None) -> str:
    cookies_db = profile / "Network" / "Cookies"
    if not cookies_db.exists():
        cookies_db = profile / "Cookies"
    if not cookies_db.exists():
        return ""

    db, parent = _copy_to_temp(cookies_db, "Cookies")
    try:
        conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
        cur = conn.cursor()
        cur.execute(
            "SELECT value, encrypted_value FROM cookies "
            "WHERE host_key LIKE '%slack.com' AND name = 'd' "
            "ORDER BY length(encrypted_value) DESC, length(value) DESC"
        )
        for value, enc_value in cur.fetchall():
            if value and value.startswith("xoxd-"):
                conn.close()
                return value
            if enc_value and master_key and sys.platform == "win32":
                try:
                    plain = _aes_gcm_decrypt(master_key, enc_value)
                    m = re.search(r"xoxd-[A-Za-z0-9/+=._%-]+", plain)
                    if m:
                        conn.close()
                        return m.group(0)
                except Exception:
                    continue
        conn.close()
    finally:
        _cleanup(parent)
    return ""


def _chrome_extract_slack_localstorage(profile: Path) -> tuple[str, str, str]:
    """Возвращает (token, team_id, user_id) из Local Storage leveldb."""
    ldb = profile / "Local Storage" / "leveldb"
    if not ldb.exists():
        return "", "", ""

    # Копируем целиком (leveldb может быть открыт)
    tmp_parent = Path(tempfile.mkdtemp(prefix="vv_ldb_"))
    try:
        dst = tmp_parent / "leveldb"
        try:
            shutil.copytree(ldb, dst, dirs_exist_ok=True)
        except Exception:
            return "", "", ""

        raw = b""
        for f in dst.rglob("*"):
            if f.suffix.lower() in (".ldb", ".log") and f.is_file():
                try:
                    raw += f.read_bytes()
                except Exception:
                    pass

        token = ""
        team = ""
        user = ""

        m = re.search(rb"xoxc-[A-Za-z0-9-]+", raw)
        if m:
            token = m.group(0).decode("ascii", errors="ignore")

        # Chrome localStorage хранится в UTF-16 LE
        for txt in (raw.decode("utf-8", errors="ignore"),
                    raw.decode("utf-16-le", errors="ignore")):
            if not team:
                m2 = re.search(r'"team_id"\s*:\s*"(T[A-Z0-9]+)"', txt)
                if m2:
                    team = m2.group(1)
            if not user:
                m3 = re.search(r'"user_id"\s*:\s*"(U[A-Z0-9]+)"', txt)
                if m3:
                    user = m3.group(1)
            if team and user:
                break

        return token, team, user
    finally:
        _cleanup(tmp_parent)


def _chrome_extract_viktor_jwt(profile: Path) -> str:
    """Viktor JWT — из cookies (token_*) ИЛИ из leveldb."""
    # Сначала cookies
    cookies_db = profile / "Network" / "Cookies"
    if not cookies_db.exists():
        cookies_db = profile / "Cookies"
    if cookies_db.exists():
        db, parent = _copy_to_temp(cookies_db, "Cookies")
        try:
            conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
            cur = conn.cursor()
            cur.execute(
                "SELECT value FROM cookies "
                "WHERE host_key LIKE '%viktor.com' AND name LIKE 'token_%' "
                "ORDER BY length(value) DESC LIMIT 1"
            )
            row = cur.fetchone()
            conn.close()
            if row and row[0] and row[0].startswith("eyJ"):
                return row[0]
        finally:
            _cleanup(parent)

    # Leveldb (на случай если jwt в localStorage)
    ldb = profile / "Local Storage" / "leveldb"
    if ldb.exists():
        tmp_parent = Path(tempfile.mkdtemp(prefix="vv_jwt_"))
        try:
            dst = tmp_parent / "leveldb"
            shutil.copytree(ldb, dst, dirs_exist_ok=True)
            raw = b""
            for f in dst.rglob("*.ldb"):
                if f.is_file():
                    try:
                        raw += f.read_bytes()
                    except Exception:
                        pass
            for txt in (raw.decode("utf-8", errors="ignore"),
                        raw.decode("utf-16-le", errors="ignore")):
                m = re.search(r"eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}", txt)
                if m:
                    return m.group(0)
        finally:
            _cleanup(tmp_parent)
    return ""


# ----------------------------- Firefox extractor -----------------------------

def _firefox_profiles() -> list[Path]:
    home = Path.home()
    if sys.platform == "win32":
        roots = [
            home / "AppData/Roaming/Mozilla/Firefox/Profiles",
            home / "AppData/Roaming/Waterfox/Profiles",
        ]
    elif sys.platform == "darwin":
        roots = [home / "Library/Application Support/Firefox/Profiles"]
    else:
        roots = [home / ".mozilla/firefox"]

    out = []
    for root in roots:
        if not root.exists():
            continue
        for sub in root.iterdir():
            if not sub.is_dir():
                continue
            # Профиль валиден если есть cookies.sqlite ИЛИ storage/default
            if (sub / "cookies.sqlite").exists() or (sub / "storage" / "default").exists():
                out.append(sub)
    return out


def _firefox_extract_slack(profile: Path) -> tuple[str, str, str, str]:
    """Возвращает (token, team_id, user_id, cookie_d)."""
    token = ""
    team_id = ""
    user_id = ""
    cookie_d = ""

    # 1) cookie d
    ck = profile / "cookies.sqlite"
    if ck.exists():
        db, parent = _copy_to_temp(ck, "cookies.sqlite")
        try:
            conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
            cur = conn.cursor()
            cur.execute(
                "SELECT value FROM moz_cookies WHERE host LIKE '%slack.com' AND name = 'd' "
                "ORDER BY length(value) DESC LIMIT 1"
            )
            row = cur.fetchone()
            conn.close()
            if row and row[0]:
                cookie_d = row[0]
        finally:
            _cleanup(parent)

    # 2) localStorage app.slack.com
    ls = profile / "storage" / "default" / "https+++app.slack.com" / "ls" / "data.sqlite"
    if ls.exists():
        db, parent = _copy_to_temp(ls, "data.sqlite")
        try:
            conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
            cur = conn.cursor()
            cur.execute("SELECT value FROM data WHERE key = 'localConfig_v2'")
            row = cur.fetchone()
            conn.close()
            if row and row[0]:
                raw = row[0]
                if HAS_SNAPPY:
                    try:
                        decompressed = snappy.decompress(raw).decode("utf-8")
                        cfg = json.loads(decompressed)
                        teams = cfg.get("teams", {})
                        if teams:
                            tid, info = next(iter(teams.items()))
                            team_id = tid
                            token = info.get("token", "")
                            user_id = info.get("user_id", "")
                    except Exception:
                        pass
                # Fallback: грепаем регексы прямо по сжатому
                if not token:
                    m = re.search(rb"xoxc-[A-Za-z0-9-]+", raw)
                    if m:
                        token = m.group(0).decode("ascii", errors="ignore")
                if not team_id:
                    m = re.search(rb"T[A-Z0-9]{8,12}", raw)
                    if m:
                        team_id = m.group(0).decode("ascii", errors="ignore")
        finally:
            _cleanup(parent)

    return token, team_id, user_id, cookie_d


def _firefox_extract_viktor_jwt(profile: Path) -> str:
    """JWT из cookie token_* для viktor.com."""
    ck = profile / "cookies.sqlite"
    if not ck.exists():
        return ""
    db, parent = _copy_to_temp(ck, "cookies.sqlite")
    try:
        conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
        cur = conn.cursor()
        cur.execute(
            "SELECT value FROM moz_cookies WHERE host LIKE '%viktor.com' AND name LIKE 'token_%' "
            "ORDER BY length(value) DESC LIMIT 1"
        )
        row = cur.fetchone()
        conn.close()
        if row and row[0] and row[0].startswith("eyJ"):
            return row[0]
    finally:
        _cleanup(parent)
    return ""


# ----------------------------- Top-level orchestration -----------------------------

def extract_all() -> list[dict[str, Any]]:
    """Перебрать все браузеры × профили, вернуть список найденных учёток."""
    results = []

    # Chrome family
    for label, user_data in _chrome_user_data_dirs():
        master_key = _read_chrome_master_key(user_data) if sys.platform == "win32" else None
        for profile in _chrome_profiles(user_data):
            entry = {
                "browser": label,
                "profile": profile.name,
                "slack_token": "",
                "slack_cookie_d": "",
                "slack_team_id": "",
                "slack_user_id": "",
                "viktor_jwt": "",
                "errors": [],
            }
            try:
                entry["slack_cookie_d"] = _chrome_extract_slack_cookie(profile, master_key)
            except Exception as e:
                entry["errors"].append(f"slack_cookie: {e}")
            try:
                t, tid, uid = _chrome_extract_slack_localstorage(profile)
                entry["slack_token"] = t
                entry["slack_team_id"] = tid
                entry["slack_user_id"] = uid
            except Exception as e:
                entry["errors"].append(f"slack_ls: {e}")
            try:
                entry["viktor_jwt"] = _chrome_extract_viktor_jwt(profile)
            except Exception as e:
                entry["errors"].append(f"viktor: {e}")
            results.append(entry)

    # Firefox
    for profile in _firefox_profiles():
        entry = {
            "browser": "Firefox",
            "profile": profile.name,
            "slack_token": "",
            "slack_cookie_d": "",
            "slack_team_id": "",
            "slack_user_id": "",
            "viktor_jwt": "",
            "errors": [],
        }
        try:
            t, tid, uid, ck_d = _firefox_extract_slack(profile)
            entry["slack_token"] = t
            entry["slack_team_id"] = tid
            entry["slack_user_id"] = uid
            entry["slack_cookie_d"] = ck_d
        except Exception as e:
            entry["errors"].append(f"slack: {e}")
        try:
            entry["viktor_jwt"] = _firefox_extract_viktor_jwt(profile)
        except Exception as e:
            entry["errors"].append(f"viktor: {e}")
        results.append(entry)

    return results


def find_best() -> dict[str, Any] | None:
    """Лучший профиль = у которого больше всего ключевых полей заполнено.

    Приоритет: viktor_jwt + slack_token + slack_cookie_d > только viktor_jwt > slack only.
    """
    all_results = extract_all()
    if not all_results:
        return None

    def score(r: dict) -> int:
        s = 0
        if r["viktor_jwt"]:
            s += 4
        if r["slack_token"]:
            s += 2
        if r["slack_cookie_d"]:
            s += 2
        if r["slack_team_id"]:
            s += 1
        return s

    best = max(all_results, key=score)
    return best if score(best) > 0 else None


# ----------------------------- CLI / TUI bridge -----------------------------

def _emit(event: str, **kwargs: Any) -> None:
    """Выпустить JSON-event на stdout (по строке)."""
    line = json.dumps({"event": event, **kwargs}, ensure_ascii=False)
    print(line, flush=True)


def cli_run(name: str, proxy_port: int) -> int:
    """Запуск из TUI: ищем токены, шлём в прокси, эмитим JSON-events."""
    _emit("start", name=name)
    _emit("info", msg="Сканирую установленные браузеры...")

    if not HAS_SNAPPY:
        _emit("info", msg="⚠ Нет python-snappy — Firefox localStorage может читаться частично")

    try:
        all_results = extract_all()
    except Exception as e:
        _emit("error", msg=f"Не удалось просканировать: {e}", partial={})
        return 1

    if not all_results:
        _emit("error", msg="Не нашёл ни одного установленного браузера", partial={})
        return 2

    # Эмитим что нашли в каждом профиле
    for r in all_results:
        fields = {
            "viktor_jwt":    bool(r["viktor_jwt"]),
            "slack_token":   bool(r["slack_token"]),
            "slack_cookie":  bool(r["slack_cookie_d"]),
            "slack_team_id": bool(r["slack_team_id"]),
        }
        _emit("found", browser=r["browser"], profile=r["profile"], fields=fields)

    # Выбрать лучший
    def score(r: dict) -> int:
        s = 0
        if r["viktor_jwt"]: s += 4
        if r["slack_token"]: s += 2
        if r["slack_cookie_d"]: s += 2
        if r["slack_team_id"]: s += 1
        return s

    best = max(all_results, key=score)
    best_score = score(best)

    if best_score == 0:
        _emit(
            "error",
            msg="Ни в одном браузере не нашёл Slack/Viktor — залогинься в браузере и попробуй снова",
            partial={},
        )
        return 3

    _emit("info", msg=f"Лучший профиль: {best['browser']} / {best['profile']}")

    # Минимум: должен быть viktor_jwt ИЛИ (slack_token + slack_cookie_d)
    has_viktor = bool(best["viktor_jwt"])
    has_slack = bool(best["slack_token"]) and bool(best["slack_cookie_d"])
    if not (has_viktor or has_slack):
        _emit(
            "error",
            msg="Найден частичный набор — залогинься полностью в Slack ИЛИ Viktor",
            partial={
                "viktor_jwt": has_viktor,
                "slack_token": bool(best["slack_token"]),
                "slack_cookie_d": bool(best["slack_cookie_d"]),
            },
        )
        return 4

    # POST в прокси
    _emit("info", msg="Сохраняю аккаунт через прокси...")
    payload = {
        "name": name,
        "slack_token": best["slack_token"],
        "slack_cookie_d": best["slack_cookie_d"],
        "team_id": best["slack_team_id"],
        "jwt": best["viktor_jwt"],
    }
    try:
        req = urllib.request.Request(
            f"http://127.0.0.1:{proxy_port}/api/accounts/wizard-save",
            data=json.dumps(payload).encode(),
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        with urllib.request.urlopen(req, timeout=30) as resp:
            result = json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", errors="replace")
        _emit("error", msg=f"Прокси вернул HTTP {e.code}: {body}", partial={})
        return 5
    except Exception as e:
        _emit("error", msg=f"Не достучался до прокси: {e}", partial={})
        return 6

    if not result.get("ok"):
        _emit("error", msg=f"Прокси не сохранил: {result}", partial={})
        return 7

    _emit("saved", detect=result.get("detect", {}))
    _emit("done")
    return 0


def main() -> int:
    parser = argparse.ArgumentParser(description="Извлечь Slack/Viktor токены из локальных браузеров")
    parser.add_argument("--cli", action="store_true", help="Эмитить JSON-events для TUI")
    parser.add_argument("--name", default="local", help="Имя нового аккаунта")
    parser.add_argument("--proxy-port", type=int, default=8799, help="Порт прокси")
    args = parser.parse_args()

    if args.cli:
        return cli_run(args.name, args.proxy_port)

    # Standalone debug
    results = extract_all()
    if not results:
        print("Не нашёл ни одного браузера/профиля.")
        return 1

    for r in results:
        print(f"\n=== {r['browser']} / {r['profile']} ===")
        for k in ("slack_token", "slack_cookie_d", "slack_team_id", "slack_user_id", "viktor_jwt"):
            v = r[k]
            if v:
                print(f"  {k:20s}: {v[:30]}... (len={len(v)})")
            else:
                print(f"  {k:20s}: -")
        for err in r["errors"]:
            print(f"  ! {err}")

    best = find_best()
    if best:
        print(f"\n>>> ЛУЧШИЙ: {best['browser']} / {best['profile']} <<<")
    return 0


if __name__ == "__main__":
    sys.exit(main())
