"""Коннектор к Slack DM с Viktor через user-token + d-cookie.

Делает ровно две вещи: постит сообщения в DM и забирает новые ответы Viktor'а.
Никакого Slack-приложения — это хайджек браузерной сессии (как querio-proxy).
"""
from __future__ import annotations

import asyncio
import os
import re
import sys
from typing import Any

import httpx

from .config import Settings
from .creds import load_creds

BASE = "https://slack.com/api"

# ошибки, означающие протухшие token/cookie -> пробуем обновить и повторить
_AUTH_ERRORS = {"invalid_auth", "not_authed", "token_expired", "token_revoked"}

# <@U123> упоминания, <http://url|текст> ссылки, &amp; и пр. — приводим к читаемому виду
_RE_LINK = re.compile(r"<(https?://[^|>]+)(?:\|([^>]+))?>")
_RE_USERMENTION = re.compile(r"<@([A-Z0-9]+)>")
_RE_SPECIAL = re.compile(r"<([#!][^|>]+)(?:\|([^>]+))?>")
_RE_EMOJI = re.compile(r":([a-z0-9_+\-]+):")

# Частые эмодзи Viktor'а -> юникод. Неизвестные :коды: оставляем как есть.
_EMOJI = {
    "smile": "😊", "smiley": "😀", "wave": "👋", "+1": "👍", "-1": "👎",
    "white_check_mark": "✅", "heavy_check_mark": "✔️", "x": "❌", "warning": "⚠️",
    "rocket": "🚀", "tada": "🎉", "fire": "🔥", "sparkles": "✨", "link": "🔗",
    "point_right": "👉", "bulb": "💡", "hammer_and_wrench": "🛠️", "gear": "⚙️",
    "tropical_fish": "🐠", "eyes": "👀", "hourglass": "⏳", "hourglass_flowing_sand": "⏳",
    "red_circle": "🔴", "large_blue_circle": "🔵", "large_yellow_circle": "🟡",
    "large_green_circle": "🟢", "large_purple_circle": "🟣", "large_orange_circle": "🟠",
    "white_circle": "⚪", "black_circle": "⚫", "thumbsup": "👍", "ok_hand": "👌",
    "pray": "🙏", "rotating_light": "🚨", "package": "📦", "inbox_tray": "📥",
    "blush": "😊", "wink": "😉", "thinking_face": "🤔", "raised_hands": "🙌",
    "muscle": "💪", "100": "💯", "clap": "👏", "memo": "📝", "robot_face": "🤖",
    "point_down": "👇", "point_up": "👆", "mag": "🔍", "thread": "🧵",
    "books": "📚", "chart_with_upwards_trend": "📈", "bar_chart": "📊", "key": "🔑",
    "file_folder": "📁", "open_file_folder": "📂", "page_facing_up": "📄",
    "wrench": "🔧", "zap": "⚡", "bell": "🔔", "lock": "🔒", "globe_with_meridians": "🌐",
}


def _safe_filename(name: str) -> str:
    """Безопасное имя файла для сохранения на диск (без путей/спецсимволов)."""
    name = os.path.basename(name or "file")
    name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name).strip(". ")
    return name or "file"


# Viktor отдаёт сгенерированные картинки в webp — по умолчанию конвертируем в png.
_WEBP_TO_PNG = os.environ.get("VIKTOR_WEBP_TO_PNG", "1").strip() not in ("0", "false", "")


def _webp_to_png(src: str) -> str | None:
    """Конвертировать webp-файл в png рядом. Возвращает путь png или None при сбое."""
    try:
        from PIL import Image
    except Exception:  # noqa: BLE001 — Pillow не установлен
        return None
    dst = os.path.splitext(src)[0] + ".png"
    try:
        with Image.open(src) as im:
            im.save(dst, "PNG")
        if dst != src and os.path.exists(src):
            os.remove(src)  # webp больше не нужен
        return dst
    except Exception:  # noqa: BLE001 — битый файл / нет webp-поддержки
        return None


def slack_to_text(text: str) -> str:
    """Грубая конверсия Slack mrkdwn в обычный текст (Telegram/OpenCode)."""
    if not text:
        return ""
    text = _RE_LINK.sub(lambda m: m.group(2) or m.group(1), text)
    text = _RE_USERMENTION.sub(lambda m: f"@{m.group(1)}", text)
    text = _RE_SPECIAL.sub(lambda m: m.group(2) or m.group(1), text)
    text = _RE_EMOJI.sub(lambda m: _EMOJI.get(m.group(1), m.group(0)), text)
    text = text.replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")
    return text


class SlackConnector:
    _UA = (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
    )

    def __init__(self, settings: Settings, *, token: str | None = None,
                 cookie: str | None = None, viktor_dm: str | None = None,
                 viktor_user: str | None = None) -> None:
        self.s = settings
        # Явные креды (мульти-аккаунт из accounts.json) ИЛИ глобальные из .env/creds.json.
        if token is not None:
            self.token, self.cookie = token, (cookie or "")
            self._explicit = True  # авто-рефреш через общий профиль тут не годится
        else:
            self.token, self.cookie = load_creds(settings)
            self._explicit = False
        self._dm = viktor_dm or settings.viktor_dm
        self._user = viktor_user or settings.viktor_user
        self._refreshing = asyncio.Lock()
        self.http = httpx.AsyncClient(timeout=30, headers=self._headers())

    def _headers(self) -> dict[str, str]:
        return {"Cookie": f"d={self.cookie}", "User-Agent": self._UA}

    async def aclose(self) -> None:
        await self.http.aclose()

    async def _reload_creds(self) -> None:
        self.token, self.cookie = load_creds(self.s)
        self.http.headers["Cookie"] = f"d={self.cookie}"

    async def _refresh_creds(self) -> bool:
        """Запустить рефрешер (Playwright) подпроцессом и перечитать creds."""
        if self._explicit:
            # Креды аккаунта заданы явно (мульти-аккаунт) — общий Playwright-профиль
            # обновил бы НЕ тот аккаунт. Не рефрешим: пусть менеджер пометит протухание.
            return False
        async with self._refreshing:
            print("[slack] токен протух — обновляю через Playwright…", file=sys.stderr, flush=True)
            proc = await asyncio.create_subprocess_exec(
                sys.executable, "-m", "viktor_valency.refresh_creds",
                stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
            )
            out, _ = await proc.communicate()
            if proc.returncode != 0:
                print(f"[slack] рефреш не удался:\n{out.decode(errors='replace')}",
                      file=sys.stderr, flush=True)
                return False
            await self._reload_creds()
            print("[slack] creds обновлены", file=sys.stderr, flush=True)
            return True

    async def _call(self, method: str, data: dict[str, Any], _retried: bool = False) -> dict[str, Any]:
        payload = dict(data, token=self.token)
        r = await self.http.post(f"{BASE}/{method}", data=payload)
        r.raise_for_status()
        js = r.json()
        if not js.get("ok"):
            err = js.get("error")
            if err in _AUTH_ERRORS and not _retried:
                if await self._refresh_creds():
                    return await self._call(method, data, _retried=True)
            raise RuntimeError(f"Slack {method} error: {err}")
        return js

    async def auth_test(self) -> dict[str, Any]:
        return await self._call("auth.test", {})

    async def post_message(self, text: str, thread_ts: str = "") -> str:
        """Отправить текст Viktor'у. Если thread_ts задан — как reply в этот тред
        (= продолжение того же разговора). Возвращает ts отправленного сообщения."""
        data: dict[str, Any] = {"channel": self._dm, "text": text}
        if thread_ts:
            data["thread_ts"] = thread_ts
        js = await self._call("chat.postMessage", data)
        return js.get("ts", "")

    async def fetch_thread(self, thread_ts: str, after_ts: str = "0") -> list[dict[str, Any]]:
        """Ответы Viktor'а в треде нашего сообщения, новее after_ts, хронологически.

        Viktor отвечает реплаем (в треде), а не top-level, поэтому ответы берём
        через conversations.replies по ts отправленного нами сообщения.
        """
        js = await self._call(
            "conversations.replies",
            {"channel": self._dm, "ts": thread_ts, "limit": "100"},
        )
        after = float(after_ts or "0")
        out: list[dict[str, Any]] = []
        for m in js.get("messages", []):  # replies приходят хронологически
            if m.get("ts") == thread_ts:
                continue  # это наше корневое сообщение
            if m.get("user") != self._user:
                continue
            if float(m["ts"]) <= after:
                continue
            out.append({"ts": m["ts"], "text": slack_to_text(m.get("text") or ""),
                        "files": m.get("files") or []})
        return out

    async def fetch_response(self, our_ts: str, after_ts: str = "") -> list[dict[str, Any]]:
        """Новые сообщения Viktor'а после нашего поста — из ВСЕХ источников.

        Viktor отвечает непредсказуемо: реплаем в тред нашего сообщения, обычным
        top-level сообщением, а длинный разбор кладёт реплаями в СВОЙ под-тред
        («детали ниже в треде»). Собираем всё, дедупим по ts, сортируем.
        """
        after = float(after_ts or our_ts)
        floor = after_ts or our_ts
        seen: dict[str, str] = {}  # ts -> text
        files: dict[str, list[Any]] = {}  # ts -> вложения Viktor'а (картинки/видео и пр.)

        # 1) тред нашего сообщения
        try:
            for m in await self.fetch_thread(our_ts, floor):
                seen[m["ts"]] = m["text"]
                files[m["ts"]] = m.get("files") or []
        except Exception:  # noqa: BLE001 — треда может ещё не быть
            pass

        # 2) top-level сообщения после нашего (+ собираем тред-родителей Viktor'а)
        js = await self._call(
            "conversations.history",
            {"channel": self._dm, "oldest": our_ts, "inclusive": "false", "limit": "50"},
        )
        sub_threads: list[str] = []
        for m in js.get("messages", []):
            if m.get("user") != self._user:
                continue
            ts = m["ts"]
            if float(ts) > after:
                seen[ts] = slack_to_text(m.get("text") or "")
                files[ts] = m.get("files") or []
            # Viktor создал под-тред (детали) под своим сообщением — заберём его
            if ts != our_ts and int(m.get("reply_count", 0) or 0) > 0:
                sub_threads.append(ts)

        # 3) под-треды Viktor'а («детали ниже в треде 🧵»)
        for ts in sub_threads:
            try:
                for r in await self.fetch_thread(ts, floor):
                    seen[r["ts"]] = r["text"]
                    files[r["ts"]] = r.get("files") or []
            except Exception:  # noqa: BLE001
                pass

        return [{"ts": ts, "text": seen[ts], "files": files.get(ts, [])}
                for ts in sorted(seen, key=float)]

    async def download_file(self, f: dict[str, Any], dest_dir: str) -> dict[str, Any] | None:
        """Скачать вложение Viktor'а (картинку/видео и пр.) в dest_dir.

        url_private/url_private_download закрыты авторизацией — берём с Bearer-токеном
        (тем же, что для API). Возвращает {name, path, size, mimetype, skipped} или None.
        Если файл с таким именем и размером уже есть — повторно не качаем (skipped).
        """
        url = f.get("url_private_download") or f.get("url_private")
        if not url:
            return None
        os.makedirs(dest_dir, exist_ok=True)
        name = _safe_filename(f.get("name") or f.get("id") or "file")
        dest = os.path.join(dest_dir, name)
        expected = int(f.get("size") or 0)
        if os.path.exists(dest) and expected and os.path.getsize(dest) == expected:
            return {"name": name, "path": dest, "size": expected,
                    "mimetype": f.get("mimetype", ""), "skipped": True}
        if os.path.exists(dest):  # то же имя, другой файл — добавим суффикс id
            stem, ext = os.path.splitext(name)
            name = f"{stem}_{(f.get('id') or '')[:6]}{ext}"
            dest = os.path.join(dest_dir, name)
        r = await self.http.get(url, headers={"Authorization": f"Bearer {self.token}"})
        r.raise_for_status()
        with open(dest, "wb") as fh:
            fh.write(r.content)
        size = len(r.content)
        mimetype = f.get("mimetype", "")
        # Авто-конвертация webp -> png (Viktor генерит картинки в webp).
        is_webp = (mimetype == "image/webp" or name.lower().endswith(".webp")
                   or (r.content[:4] == b"RIFF" and r.content[8:12] == b"WEBP"))
        if _WEBP_TO_PNG and is_webp:
            png = _webp_to_png(dest)
            if png:
                dest, name = png, os.path.basename(png)
                size = os.path.getsize(dest)
                mimetype = "image/png"
        return {"name": name, "path": dest, "size": size,
                "mimetype": mimetype, "skipped": False}
