"""Telegram uploader that streams media without writing it to disk."""

from __future__ import annotations

import asyncio
import io
import logging
import re
from collections.abc import AsyncGenerator
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlsplit

from aiogram import Bot
from aiogram.exceptions import (
    TelegramAPIError,
    TelegramForbiddenError,
    TelegramNetworkError,
    TelegramRetryAfter,
    TelegramServerError,
)
from aiogram.types import (
    BufferedInputFile,
    ChatMemberAdministrator,
    ChatMemberOwner,
    InputFile,
)
from PIL import Image, ImageOps, UnidentifiedImageError

from app.config import Settings
from app.database.models import Video
from app.database.repository import Repository
from app.errors import (
    AccessDeniedError,
    AppError,
    ContentTooLargeError,
    DestinationError,
    DownloadCancelledError,
    InvalidMediaError,
    JobCancelledError,
    NetworkError,
    NotFoundError,
    PermanentSiteError,
    RateLimitedError,
    UploadRejectedError,
    UploadUncertainError,
)
from app.services.caption_parser import normalize_text, split_caption
from app.services.http_client import SafeHttpClient
from app.utils.files import (
    ALLOWED_MEDIA_EXTENSIONS,
    build_download_filename,
    human_size,
)

logger = logging.getLogger(__name__)

ChatTarget = int | str
_USERNAME_RE = re.compile(r"^@[A-Za-z][A-Za-z0-9_]{4,31}$")
_EXTENSION_BY_MIME = {
    "video/mp4": ".mp4",
    "video/quicktime": ".mov",
    "video/webm": ".webm",
    "video/ogg": ".ogv",
    "video/x-msvideo": ".avi",
    "video/x-flv": ".flv",
    "video/mpeg": ".mpg",
    "video/mp2t": ".ts",
}


def normalize_chat_target(raw: str) -> ChatTarget:
    value = raw.strip()
    if not value:
        raise DestinationError("مقصد خالی است.")
    if _USERNAME_RE.fullmatch(value):
        return value
    try:
        number = int(value)
    except ValueError as exc:
        raise DestinationError(
            "شناسه مقصد باید عددی (مانند -1001234567890) یا نام کاربری @channel باشد."
        ) from exc
    if number == 0:
        raise DestinationError("شناسه صفر برای چت معتبر نیست.")
    return number


@dataclass(frozen=True, slots=True)
class Destination:
    chat_id: ChatTarget
    chat_type: str
    title: str


@dataclass(frozen=True, slots=True)
class UploadResult:
    message_id: int
    destination: Destination
    extra_messages: int


class HttpStreamInputFile(InputFile):
    """An aiogram input file backed by a validated HTTP response.

    aiogram consumes async generators as multipart fields, so Telegram receives
    the bytes directly from the source. No temporary or permanent media file is
    created on the host.
    """

    def __init__(
        self,
        http: SafeHttpClient,
        url: str,
        *,
        max_bytes: int,
        referer: str | None,
        cancel_event: asyncio.Event,
        filename: str,
    ) -> None:
        super().__init__(filename=filename)
        self.http = http
        self.url = url
        self.max_bytes = max_bytes
        self.referer = referer
        self.cancel_event = cancel_event

    async def read(self, bot: Bot) -> AsyncGenerator[bytes, None]:  # noqa: ARG002
        async for chunk in self.http.iter_media(
            self.url,
            max_bytes=self.max_bytes,
            cancel_event=self.cancel_event,
            referer=self.referer,
        ):
            yield chunk


class Uploader:
    def __init__(
        self,
        settings: Settings,
        bot: Bot,
        repo: Repository,
        http: SafeHttpClient,
    ) -> None:
        self.settings = settings
        self.bot = bot
        self.repo = repo
        self.http = http

    async def _read_destination_setting(self) -> ChatTarget | None:
        raw = await self.repo.get_setting("destination_chat_id")
        if raw:
            try:
                return normalize_chat_target(raw)
            except DestinationError:
                logger.error("stored destination setting is malformed; falling back to .env")
        if self.settings.default_chat_id is not None:
            return self.settings.default_chat_id
        return None

    async def resolve_destination(self) -> Destination:
        target = await self._read_destination_setting()
        if target is None:
            raise DestinationError(
                "مقصد انتشار تنظیم نشده است. با /settings یا دکمه «تنظیم مقصد» یک چت یا کانال تعیین کنید."
            )
        return await self.validate_target(target)

    async def validate_target(self, target: ChatTarget) -> Destination:
        try:
            chat = await self.bot.get_chat(target)
        except TelegramForbiddenError as exc:
            raise DestinationError(
                "ربات به مقصد دسترسی ندارد. ربات را به کانال اضافه و مدیر کنید."
            ) from exc
        except TelegramAPIError as exc:
            raise DestinationError("مقصد پیدا نشد یا شناسه آن معتبر نیست.") from exc
        if chat.type == "private":
            raise DestinationError("مقصد انتشار باید کانال یا گروه باشد، نه چت خصوصی.")
        if chat.type == "channel":
            try:
                member = await self.bot.get_chat_member(chat.id, self.bot.id)
            except TelegramAPIError as exc:
                raise DestinationError("وضعیت دسترسی ربات در کانال قابل بررسی نیست.") from exc
            is_owner = isinstance(member, ChatMemberOwner)
            is_admin = isinstance(member, ChatMemberAdministrator)
            can_post = not is_admin or member.can_post_messages is True
            if not (is_owner or is_admin) or not can_post:
                raise DestinationError(
                    "ربات باید در کانال مدیر باشد و اجازه «ارسال پیام» داشته باشد."
                )
        elif chat.type in {"group", "supergroup"}:
            try:
                member = await self.bot.get_chat_member(chat.id, self.bot.id)
            except TelegramAPIError as exc:
                raise DestinationError(
                    "ربات عضو این گروه نیست یا وضعیت عضویت آن قابل بررسی نیست."
                ) from exc
            status = str(getattr(member, "status", ""))
            if status in {"left", "kicked"}:
                raise DestinationError("ربات از این گروه حذف شده است.")
            if getattr(member, "can_send_messages", True) is False:
                raise DestinationError("ربات در این گروه اجازه ارسال پیام ندارد.")
        return Destination(chat_id=chat.id, chat_type=chat.type, title=chat.title or str(chat.id))

    async def send_test_message(self, target: ChatTarget) -> Destination:
        destination = await self.validate_target(target)
        try:
            await self.bot.send_message(
                chat_id=destination.chat_id,
                text="✅ مقصد انتشار با موفقیت بررسی شد.",
                disable_web_page_preview=True,
            )
        except TelegramAPIError as exc:
            raise DestinationError(
                "ارسال پیام آزمایشی ناموفق بود. دسترسی ربات و شناسه مقصد را بررسی کنید."
            ) from exc
        return destination

    async def upload(
        self,
        video: Video,
        destination: Destination,
        cancel_event: asyncio.Event,
    ) -> UploadResult:
        fresh = await self.repo.get_video(video.id)
        if fresh is None:
            raise UploadRejectedError("رکورد ویدئو در دیتابیس پیدا نشد.")
        if fresh.status.value == "UPLOADED" and fresh.telegram_message_id:
            return UploadResult(fresh.telegram_message_id, destination, 0)
        if fresh.status.value == "UPLOAD_UNKNOWN":
            raise UploadUncertainError()

        if cancel_event.is_set():
            raise JobCancelledError()
        if not fresh.media_url:
            raise UploadRejectedError(
                "لینک مستقیم این ویدئو موجود نیست؛ ارسال بدون ذخیره‌سازی ممکن نیست."
            )

        limit = min(self.settings.max_file_size_bytes, self.settings.telegram_upload_limit_bytes)
        if fresh.file_size is not None and fresh.file_size > limit:
            raise UploadRejectedError(
                f"حجم ویدئو {human_size(fresh.file_size)} از سقف مجاز "
                f"({human_size(limit)}) بیشتر است. ارسال انجام نشد."
            )

        caption_text = normalize_text(fresh.caption) or normalize_text(fresh.title) or "ویدئو"
        caption, extras = split_caption(caption_text, self.settings.telegram_caption_limit)
        thumbnail = await self._prepare_thumbnail(fresh)
        filename = build_download_filename(fresh.title, self._media_extension(fresh))
        duration = (
            int(round(fresh.duration_seconds))
            if fresh.duration_seconds is not None and fresh.duration_seconds > 0
            else None
        )

        attempts = self.settings.retry_count + 1
        message = None
        for attempt in range(1, attempts + 1):
            if cancel_event.is_set():
                raise JobCancelledError()
            try:
                input_file = HttpStreamInputFile(
                    self.http,
                    fresh.media_url,
                    max_bytes=limit,
                    referer=fresh.page_url,
                    cancel_event=cancel_event,
                    filename=filename,
                )
                message = await self.bot.send_video(
                    chat_id=destination.chat_id,
                    video=input_file,
                    caption=caption or None,
                    parse_mode=None,
                    supports_streaming=True,
                    duration=duration,
                    thumbnail=thumbnail,
                )
                break
            except DownloadCancelledError as exc:
                raise JobCancelledError() from exc
            except ContentTooLargeError as exc:
                raise UploadRejectedError(
                    f"حجم ویدئو از سقف مجاز ({human_size(limit)}) بیشتر است."
                ) from exc
            except InvalidMediaError as exc:
                raise UploadRejectedError(
                    "منبع، فایل ویدئوی معتبر برنگرداند و ارسال انجام نشد."
                ) from exc
            except (AccessDeniedError, NotFoundError, PermanentSiteError, RateLimitedError) as exc:
                raise UploadRejectedError(
                    "دریافت مستقیم ویدئو از منبع ناموفق بود و ارسال انجام نشد."
                ) from exc
            except NetworkError as exc:
                # The source stream failed after the Telegram request started.
                # The post is almost certainly absent, but a manual check avoids
                # any chance of a duplicate.
                logger.error("source stream failed during upload: %s", type(exc).__name__)
                raise UploadUncertainError() from exc
            except TelegramRetryAfter as exc:
                if attempt >= attempts:
                    raise UploadRejectedError(
                        "تلگرام پس از چند تلاش همچنان محدودیت نرخ اعمال می‌کند."
                    ) from exc
                delay = max(1.0, float(exc.retry_after))
                if delay > 300:
                    raise UploadRejectedError(
                        "مدت محدودیت نرخ تلگرام بیش از حد مجاز این عملیات است."
                    ) from exc
                logger.warning("Telegram flood wait: retrying after %.0fs", delay)
                if await self._wait_or_cancel(delay, cancel_event):
                    raise JobCancelledError() from exc
            except (TelegramNetworkError, TelegramServerError) as exc:
                # The request may have reached Telegram. Retrying could create
                # a duplicate channel post, so require manual reconciliation.
                logger.error("ambiguous Telegram upload failure: %s", type(exc).__name__)
                raise UploadUncertainError() from exc
            except TelegramAPIError as exc:
                message_text = str(exc).lower()
                if "file is too big" in message_text:
                    raise UploadRejectedError(
                        "تلگرام فایل را بیش از حد بزرگ دانست. سقف آپلود را در تنظیمات بررسی کنید."
                    ) from exc
                logger.error(
                    "Telegram rejected upload: %s (%s)",
                    type(exc).__name__,
                    getattr(exc, "code", "unknown"),
                )
                raise UploadRejectedError() from exc
            except AppError as exc:
                logger.error("source or upload validation failed: %s", type(exc).__name__)
                raise UploadRejectedError() from exc
            except asyncio.CancelledError:
                raise
            except Exception as exc:  # noqa: BLE001
                logger.exception("unexpected error while uploading")
                raise UploadUncertainError() from exc

        if message is None or not getattr(message, "message_id", 0):
            raise UploadUncertainError("Telegram response did not contain a message ID")
        extra_sent = 0
        for extra in extras:
            if cancel_event.is_set():
                break
            try:
                await self.bot.send_message(
                    chat_id=destination.chat_id,
                    text=extra,
                    parse_mode=None,
                    disable_web_page_preview=True,
                )
                extra_sent += 1
            except TelegramAPIError:
                logger.warning("supplementary caption message could not be sent")
                break
        logger.info(
            "upload finished: message_id=%s, extras=%s",
            message.message_id,
            extra_sent,
        )
        return UploadResult(message.message_id, destination, extra_sent)

    async def _prepare_thumbnail(self, video: Video) -> BufferedInputFile | None:
        if not self.settings.send_thumbnails or not video.thumbnail_url:
            return None
        try:
            raw = await self.http.fetch_bytes(
                video.thumbnail_url,
                max_bytes=5 * 1024 * 1024,
                allowed_prefixes=(b"\xff\xd8\xff", b"\x89PNG\r\n\x1a\n", b"RIFF"),
                referer=video.page_url,
            )
        except AppError as exc:
            logger.debug("remote thumbnail rejected: %s", type(exc).__name__)
            return None
        data = await asyncio.to_thread(self._thumbnail_bytes, raw)
        if data is None:
            return None
        return BufferedInputFile(data, filename="thumbnail.jpg")

    @staticmethod
    def _thumbnail_bytes(raw: bytes) -> bytes | None:
        try:
            with Image.open(io.BytesIO(raw)) as source:
                source.load()
                if source.width * source.height > 25_000_000:
                    return None
                image = ImageOps.exif_transpose(source)
                if image.mode not in {"RGB", "L"}:
                    image = image.convert("RGB")
                image.thumbnail((320, 320), Image.Resampling.LANCZOS)
                for quality in (82, 65, 50, 40):
                    buffer = io.BytesIO()
                    image.save(buffer, format="JPEG", quality=quality, optimize=True)
                    if buffer.tell() <= 190 * 1024:
                        return buffer.getvalue()
            return None
        except (OSError, UnidentifiedImageError, ValueError):
            return None

    @staticmethod
    def _media_extension(video: Video) -> str:
        suffix = Path(urlsplit(video.media_url or "").path).suffix.lower()
        if suffix in ALLOWED_MEDIA_EXTENSIONS:
            return suffix
        return _EXTENSION_BY_MIME.get((video.media_type or "").lower(), ".mp4")

    @staticmethod
    async def _wait_or_cancel(seconds: float, cancel_event: asyncio.Event) -> bool:
        if seconds <= 0:
            return cancel_event.is_set()
        try:
            await asyncio.wait_for(cancel_event.wait(), timeout=seconds)
        except TimeoutError:
            return False
        return True
