from __future__ import annotations

import secrets
import math
from dataclasses import dataclass, field
from datetime import datetime, timezone

from sqlalchemy import delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker

from app.config import Settings
from app.db.base import utcnow
from app.db.models import (
    BroadcastPoll,
    BroadcastPollAnswer,
    Guide,
    GuidePhoto,
    InternalSquad,
    PromoCode,
    PromoCodeApplication,
    ReferralCode,
    ReferralInvite,
    RemnawaveUser,
    RemnawaveUserInternalSquad,
    SubscriptionRequestLog,
    TelegramUser,
)
from app.schemas.remnawave import RemnawaveUser as RemoteRemnawaveUser
from app.schemas.remnawave import SubscriptionRequestHistory
from app.services.remnawave_client import RemnawaveApiClient


@dataclass(slots=True)
class CachedUserView:
    record: RemnawaveUser
    internal_squads: list[str] = field(default_factory=list)
    recent_requests: list[SubscriptionRequestLog] = field(default_factory=list)


@dataclass(slots=True)
class ReferralSummary:
    total_invited: int = 0
    referral_code: str = ""
    applied_referral_code: str = ""
    applied_promo_code: str = ""
    applied_promo_discount_percent: int = 0
    applied_promo_plan_code: str | None = None
    applied_promo_expires_at: datetime | None = None
    recent_names: list[str] = field(default_factory=list)


@dataclass(slots=True)
class PromoCodeView:
    code: str
    plan_code: str | None
    discount_percent: int
    expires_at: datetime | None
    is_active: bool
    created_by_telegram_id: int | None = None
    created_at: datetime | None = None
    updated_at: datetime | None = None

    @property
    def is_expired(self) -> bool:
        return self.expires_at is not None and self.expires_at <= utcnow()

    @property
    def is_valid(self) -> bool:
        return self.is_active and not self.is_expired and self.discount_percent > 0


@dataclass(slots=True)
class GuideView:
    id: int
    title: str
    short_description: str
    body_text: str
    photo_file_ids: list[str] = field(default_factory=list)
    is_active: bool = True
    created_by_telegram_id: int | None = None
    created_at: datetime | None = None
    updated_at: datetime | None = None


@dataclass(slots=True)
class AdminStats:
    total_telegram_users: int = 0
    total_cached_users: int = 0
    active_cached_users: int = 0


@dataclass(slots=True)
class AdminTelegramUserListItem:
    telegram_id: int
    username: str | None
    first_name: str | None
    last_name: str | None
    language_code: str | None
    is_admin: bool
    is_blocked: bool
    last_seen_at: datetime
    created_at: datetime

    @property
    def display_name(self) -> str:
        value = " ".join(part for part in [self.first_name, self.last_name] if part).strip()
        return value or self.username or str(self.telegram_id)


@dataclass(slots=True)
class AdminTelegramUsersPage:
    items: list[AdminTelegramUserListItem] = field(default_factory=list)
    page: int = 1
    page_size: int = 8
    total_items: int = 0
    total_pages: int = 1


@dataclass(slots=True)
class AdminTelegramUserProfile:
    user: TelegramUser
    referral_code: str = ""
    applied_referral_code: str = ""
    invited_count: int = 0
    recent_invited_names: list[str] = field(default_factory=list)
    accesses: list[CachedUserView] = field(default_factory=list)


@dataclass(slots=True)
class BroadcastPollAnswerResult:
    poll: BroadcastPoll
    answer: BroadcastPollAnswer
    previous_answer: str | None = None


class SyncService:
    def __init__(
        self,
        *,
        settings: Settings,
        session_factory: async_sessionmaker[AsyncSession],
        remnawave_client: RemnawaveApiClient,
    ) -> None:
        self._settings = settings
        self._session_factory = session_factory
        self._remnawave_client = remnawave_client

    async def is_user_blocked(self, telegram_id: int) -> bool:
        async with self._session_factory() as session:
            is_blocked = await session.scalar(
                select(TelegramUser.is_blocked).where(
                    TelegramUser.telegram_id == telegram_id
                )
            )
        return bool(is_blocked)

    async def block_telegram_user(self, telegram_id: int) -> None:
        async with self._session_factory() as session:
            user = await session.scalar(
                select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
            )
            if user is not None:
                user.is_blocked = True
                user.updated_at = utcnow()
                await session.commit()

    async def unblock_telegram_user(self, telegram_id: int) -> None:
        async with self._session_factory() as session:
            user = await session.scalar(
                select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
            )
            if user is not None:
                user.is_blocked = False
                user.updated_at = utcnow()
                await session.commit()

    async def get_expiring_users(
        self,
        *,
        days_before: int,
        tolerance_hours: int = 6,
    ) -> list[tuple[int, int, datetime]]:
        """Return (remnawave_user.id, telegram_id, expire_at) for users expiring within window."""
        from datetime import timedelta

        now = utcnow()
        window_start = now + timedelta(days=days_before) - timedelta(hours=tolerance_hours)
        window_end = now + timedelta(days=days_before) + timedelta(hours=tolerance_hours)

        async with self._session_factory() as session:
            rows = await session.execute(
                select(
                    RemnawaveUser.id,
                    RemnawaveUser.telegram_id,
                    RemnawaveUser.expire_at,
                )
                .where(
                    RemnawaveUser.status == "ACTIVE",
                    RemnawaveUser.telegram_id.isnot(None),
                    RemnawaveUser.expire_at >= window_start,
                    RemnawaveUser.expire_at <= window_end,
                )
            )
            return [(row[0], row[1], row[2]) for row in rows.all()]

    async def register_telegram_user(
        self,
        *,
        telegram_id: int,
        username: str | None,
        first_name: str | None,
        last_name: str | None,
        language_code: str | None,
    ) -> None:
        async with self._session_factory() as session:
            await self._upsert_telegram_user(
                session,
                telegram_id=telegram_id,
                username=username,
                first_name=first_name,
                last_name=last_name,
                language_code=language_code,
            )
            await self._get_or_create_referral_code(session, telegram_id=telegram_id)
            await session.commit()

    async def create_promo_code(
        self,
        *,
        code: str,
        plan_code: str | None,
        discount_percent: int,
        expires_at: datetime | None,
        created_by_telegram_id: int,
    ) -> PromoCodeView:
        normalized_code = self.normalize_discount_code(code)
        if not normalized_code:
            raise ValueError("Введите слово для промокода.")
        if len(normalized_code) > 32:
            raise ValueError("Промокод должен быть не длиннее 32 символов.")
        if not all(ch.isalnum() or ch in "_-" for ch in normalized_code):
            raise ValueError("Промокод может содержать только буквы, цифры, _ и -.")

        resolved_discount = int(discount_percent)
        if resolved_discount <= 0 or resolved_discount > 100:
            raise ValueError("Скидка должна быть от 1 до 100%.")

        resolved_plan_code = (plan_code or "").strip() or None
        if resolved_plan_code and len(resolved_plan_code) > 64:
            raise ValueError("Код тарифа слишком длинный.")

        resolved_expires_at = self._normalize_datetime(expires_at)
        if resolved_expires_at is not None and resolved_expires_at <= utcnow():
            raise ValueError("Срок действия промокода должен быть в будущем.")

        async with self._session_factory() as session:
            duplicate = await session.scalar(
                select(PromoCode.id).where(PromoCode.code == normalized_code)
            )
            if duplicate is not None:
                raise ValueError("Такой промокод уже существует.")

            promo = PromoCode(
                code=normalized_code,
                plan_code=resolved_plan_code,
                discount_percent=resolved_discount,
                expires_at=resolved_expires_at,
                is_active=True,
                created_by_telegram_id=created_by_telegram_id,
            )
            session.add(promo)
            await session.commit()
            await session.refresh(promo)
            return self._promo_code_view(promo)

    async def get_admin_promo_codes(self, *, limit: int = 20) -> list[PromoCodeView]:
        resolved_limit = max(1, min(limit, 100))
        async with self._session_factory() as session:
            rows = list(
                (
                    await session.scalars(
                        select(PromoCode)
                        .order_by(PromoCode.created_at.desc(), PromoCode.id.desc())
                        .limit(resolved_limit)
                    )
                ).all()
            )
            return [self._promo_code_view(row) for row in rows]

    async def get_active_promo_for_user(self, telegram_id: int) -> PromoCodeView | None:
        async with self._session_factory() as session:
            promo = await self._get_active_promo_for_user(session, telegram_id=telegram_id)
            return self._promo_code_view(promo) if promo is not None else None

    async def create_guide(
        self,
        *,
        title: str,
        short_description: str,
        body_text: str,
        photo_file_ids: list[str],
        created_by_telegram_id: int,
    ) -> GuideView:
        resolved_title = " ".join((title or "").split()).strip()
        if not resolved_title:
            raise ValueError("Введите название гайда.")
        if len(resolved_title) > 255:
            raise ValueError("Название гайда должно быть не длиннее 255 символов.")

        resolved_description = " ".join((short_description or "").split()).strip()
        if len(resolved_description) > 512:
            raise ValueError("Короткое описание должно быть не длиннее 512 символов.")

        resolved_body = (body_text or "").strip()
        if not resolved_body:
            raise ValueError("Введите текст гайда.")

        resolved_photos: list[str] = []
        for file_id in photo_file_ids:
            normalized_file_id = str(file_id or "").strip()
            if normalized_file_id and normalized_file_id not in resolved_photos:
                resolved_photos.append(normalized_file_id)

        async with self._session_factory() as session:
            guide = Guide(
                title=resolved_title,
                short_description=resolved_description,
                body_text=resolved_body,
                is_active=True,
                created_by_telegram_id=created_by_telegram_id,
            )
            session.add(guide)
            await session.flush()

            for position, file_id in enumerate(resolved_photos):
                session.add(
                    GuidePhoto(
                        guide_id=guide.id,
                        file_id=file_id,
                        position=position,
                    )
                )

            await session.commit()
            return self._guide_view(guide, resolved_photos)

    async def get_guides(self, *, active_only: bool = True, limit: int = 50) -> list[GuideView]:
        resolved_limit = max(1, min(limit, 100))
        async with self._session_factory() as session:
            query = (
                select(Guide, GuidePhoto.file_id)
                .outerjoin(GuidePhoto, GuidePhoto.guide_id == Guide.id)
                .order_by(Guide.created_at.desc(), Guide.id.desc(), GuidePhoto.position.asc())
                .limit(resolved_limit * 10)
            )
            if active_only:
                query = query.where(Guide.is_active == True)  # noqa: E712
            rows = (await session.execute(query)).all()

        guides = self._build_guide_views(rows)
        return guides[:resolved_limit]

    async def get_guide(self, guide_id: int, *, active_only: bool = True) -> GuideView | None:
        async with self._session_factory() as session:
            query = (
                select(Guide, GuidePhoto.file_id)
                .outerjoin(GuidePhoto, GuidePhoto.guide_id == Guide.id)
                .where(Guide.id == guide_id)
                .order_by(GuidePhoto.position.asc())
            )
            if active_only:
                query = query.where(Guide.is_active == True)  # noqa: E712
            rows = (await session.execute(query)).all()

        guides = self._build_guide_views(rows)
        return guides[0] if guides else None

    async def delete_guide(self, guide_id: int) -> GuideView | None:
        guide = await self.get_guide(guide_id, active_only=False)
        if guide is None:
            return None

        async with self._session_factory() as session:
            await session.execute(delete(GuidePhoto).where(GuidePhoto.guide_id == guide_id))
            await session.execute(delete(Guide).where(Guide.id == guide_id))
            await session.commit()

        return guide

    async def consume_discount_for_user(self, telegram_id: int) -> None:
        """Burn the applied promo / referral discount so it cannot be reused.

        - For promo codes: deletes the row from ``promo_code_applications``.
        - For referral invites: sets ``discount_used = True`` so the referral
          bonus logic (inviter days) is preserved while the invited user
          no longer receives a price discount.
        - Sets ``discount_ever_used = True`` on ``telegram_users`` to permanently
          prevent any future discount code activation.
        """
        async with self._session_factory() as session:
            # 1. Burn promo-code application
            await session.execute(
                delete(PromoCodeApplication).where(
                    PromoCodeApplication.telegram_id == telegram_id
                )
            )

            # 2. Mark referral invite discount as used
            await session.execute(
                update(ReferralInvite)
                .where(
                    ReferralInvite.invited_telegram_id == telegram_id,
                    ReferralInvite.discount_used == False,  # noqa: E712
                )
                .values(discount_used=True)
            )

            # 3. Permanently mark the user so no new codes can be activated
            await session.execute(
                update(TelegramUser)
                .where(
                    TelegramUser.telegram_id == telegram_id,
                    TelegramUser.discount_ever_used == False,  # noqa: E712
                )
                .values(discount_ever_used=True)
            )

            await session.commit()

    async def is_referral_discount_used(self, telegram_id: int) -> bool:
        """Return True if the referral invite discount was already consumed."""
        async with self._session_factory() as session:
            discount_used = await session.scalar(
                select(ReferralInvite.discount_used).where(
                    ReferralInvite.invited_telegram_id == telegram_id
                )
            )
            return bool(discount_used)

    async def apply_referral_code(
        self,
        *,
        telegram_id: int,
        username: str | None,
        first_name: str | None,
        last_name: str | None,
        language_code: str | None,
        referral_code: str,
    ) -> str:
        async with self._session_factory() as session:
            invited = await self._upsert_telegram_user(
                session,
                telegram_id=telegram_id,
                username=username,
                first_name=first_name,
                last_name=last_name,
                language_code=language_code,
            )

            # Block activation if the user has already used a discount
            if invited.discount_ever_used:
                raise ValueError(
                    "Вы уже использовали промокод или реферальный код. "
                    "Повторная активация невозможна."
                )

            await self._get_or_create_referral_code(session, telegram_id=telegram_id)

            raw_code = self.normalize_discount_code(referral_code)
            promo = await self._find_promo_code(session, code=raw_code)
            if promo is not None:
                self._ensure_promo_can_be_applied(promo)
                await self._upsert_promo_application(
                    session,
                    telegram_id=telegram_id,
                    promo_code_id=promo.id,
                )
                await session.commit()
                return promo.code

            existing = await session.scalar(
                select(ReferralInvite).where(
                    ReferralInvite.invited_telegram_id == telegram_id
                )
            )
            if existing is not None:
                raise ValueError("Реферальный код уже применен к вашему аккаунту.")

            inviter_telegram_id = await self._find_inviter_by_referral_code(session, referral_code)
            if inviter_telegram_id is None:
                raise ValueError("Реферальный код не найден.")
            if inviter_telegram_id == telegram_id:
                raise ValueError("Нельзя использовать собственный реферальный код.")

            invite_created = await self._upsert_referral_invite(
                session,
                invited=invited,
                referred_by_telegram_id=inviter_telegram_id,
            )
            if not invite_created:
                raise ValueError("Не удалось применить реферальный код.")

            await session.execute(
                delete(PromoCodeApplication).where(
                    PromoCodeApplication.telegram_id == telegram_id
                )
            )
            await session.commit()
            return referral_code.strip().upper()

    async def get_referral_summary(self, telegram_id: int) -> ReferralSummary:
        async with self._session_factory() as session:
            referral_code = await self._get_or_create_referral_code(
                session,
                telegram_id=telegram_id,
            )
            await session.commit()
            total_invited = await session.scalar(
                select(func.count(ReferralInvite.id)).where(
                    ReferralInvite.inviter_telegram_id == telegram_id
                )
            )
            recent_rows = await session.execute(
                select(ReferralInvite.invited_display_name)
                .where(ReferralInvite.inviter_telegram_id == telegram_id)
                .order_by(ReferralInvite.created_at.desc())
                .limit(5)
            )
            recent_names = list(recent_rows.scalars().all())
            applied_referral_code = await session.scalar(
                select(ReferralCode.code)
                .join(
                    ReferralInvite,
                    ReferralInvite.inviter_telegram_id == ReferralCode.telegram_id,
                )
                .where(ReferralInvite.invited_telegram_id == telegram_id)
            )
            applied_promo = await self._get_active_promo_for_user(
                session,
                telegram_id=telegram_id,
            )

        return ReferralSummary(
            total_invited=int(total_invited or 0),
            referral_code=referral_code,
            applied_referral_code=applied_referral_code or "",
            applied_promo_code=applied_promo.code if applied_promo is not None else "",
            applied_promo_discount_percent=(
                int(applied_promo.discount_percent) if applied_promo is not None else 0
            ),
            applied_promo_plan_code=applied_promo.plan_code if applied_promo is not None else None,
            applied_promo_expires_at=applied_promo.expires_at if applied_promo is not None else None,
            recent_names=recent_names,
        )

    async def get_admin_stats(self) -> AdminStats:
        async with self._session_factory() as session:
            total_telegram_users = await session.scalar(
                select(func.count(TelegramUser.id))
            )
            total_cached_users = await session.scalar(
                select(func.count(RemnawaveUser.id))
            )
            active_cached_users = await session.scalar(
                select(func.count(RemnawaveUser.id)).where(RemnawaveUser.status == "ACTIVE")
            )

        return AdminStats(
            total_telegram_users=int(total_telegram_users or 0),
            total_cached_users=int(total_cached_users or 0),
            active_cached_users=int(active_cached_users or 0),
        )

    async def get_broadcast_telegram_ids(self) -> list[int]:
        async with self._session_factory() as session:
            rows = await session.scalars(
                select(TelegramUser.telegram_id)
                .where(TelegramUser.is_blocked == False)  # noqa: E712
                .order_by(TelegramUser.id.asc())
            )
            return list(rows.all())

    async def create_broadcast_poll(
        self,
        *,
        question_text: str,
        created_by_telegram_id: int,
    ) -> BroadcastPoll:
        async with self._session_factory() as session:
            poll = BroadcastPoll(
                question_text=question_text,
                created_by_telegram_id=created_by_telegram_id,
            )
            session.add(poll)
            await session.commit()
            return poll

    async def record_broadcast_poll_answer(
        self,
        *,
        poll_id: int,
        telegram_id: int,
        username: str | None,
        first_name: str | None,
        last_name: str | None,
        answer: str,
    ) -> BroadcastPollAnswerResult | None:
        normalized_answer = answer.strip().lower()
        if normalized_answer not in {"yes", "no"}:
            raise ValueError("Unsupported answer")

        async with self._session_factory() as session:
            poll = await session.scalar(
                select(BroadcastPoll).where(BroadcastPoll.id == poll_id)
            )
            if poll is None:
                return None

            record = await session.scalar(
                select(BroadcastPollAnswer).where(
                    BroadcastPollAnswer.poll_id == poll_id,
                    BroadcastPollAnswer.telegram_id == telegram_id,
                )
            )
            previous_answer = record.answer if record is not None else None
            if record is None:
                record = BroadcastPollAnswer(
                    poll_id=poll_id,
                    telegram_id=telegram_id,
                )
                session.add(record)

            record.username = username
            record.first_name = first_name
            record.last_name = last_name
            record.answer = normalized_answer
            record.updated_at = utcnow()

            await session.commit()
            return BroadcastPollAnswerResult(
                poll=poll,
                answer=record,
                previous_answer=previous_answer,
            )

    async def get_admin_telegram_users_page(
        self,
        *,
        page: int,
        page_size: int = 8,
    ) -> AdminTelegramUsersPage:
        resolved_page_size = max(1, min(page_size, 20))

        async with self._session_factory() as session:
            total_items = int(
                await session.scalar(select(func.count(TelegramUser.id))) or 0
            )
            total_pages = max(1, math.ceil(total_items / resolved_page_size)) if total_items else 1
            resolved_page = min(max(page, 1), total_pages)

            rows = list(
                (
                    await session.scalars(
                        select(TelegramUser)
                        .order_by(
                            TelegramUser.last_seen_at.desc(),
                            TelegramUser.created_at.desc(),
                            TelegramUser.id.desc(),
                        )
                        .offset((resolved_page - 1) * resolved_page_size)
                        .limit(resolved_page_size)
                    )
                ).all()
            )

        return AdminTelegramUsersPage(
            items=[
                AdminTelegramUserListItem(
                    telegram_id=row.telegram_id,
                    username=row.username,
                    first_name=row.first_name,
                    last_name=row.last_name,
                    language_code=row.language_code,
                    is_admin=row.is_admin,
                    is_blocked=row.is_blocked,
                    last_seen_at=row.last_seen_at,
                    created_at=row.created_at,
                )
                for row in rows
            ],
            page=resolved_page,
            page_size=resolved_page_size,
            total_items=total_items,
            total_pages=total_pages,
        )

    async def get_admin_telegram_user_profile(
        self,
        *,
        telegram_id: int,
    ) -> AdminTelegramUserProfile | None:
        async with self._session_factory() as session:
            user = await session.scalar(
                select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
            )
            if user is None:
                return None

            referral_code = await session.scalar(
                select(ReferralCode.code).where(ReferralCode.telegram_id == telegram_id)
            )
            invited_count = await session.scalar(
                select(func.count(ReferralInvite.id)).where(
                    ReferralInvite.inviter_telegram_id == telegram_id
                )
            )
            recent_rows = await session.execute(
                select(ReferralInvite.invited_display_name)
                .where(ReferralInvite.inviter_telegram_id == telegram_id)
                .order_by(ReferralInvite.created_at.desc())
                .limit(5)
            )
            recent_invited_names = list(recent_rows.scalars().all())
            applied_referral_code = await session.scalar(
                select(ReferralCode.code)
                .join(
                    ReferralInvite,
                    ReferralInvite.inviter_telegram_id == ReferralCode.telegram_id,
                )
                .where(ReferralInvite.invited_telegram_id == telegram_id)
            )

        accesses = await self.get_cached_users_for_telegram(telegram_id)

        return AdminTelegramUserProfile(
            user=user,
            referral_code=referral_code or "",
            applied_referral_code=applied_referral_code or "",
            invited_count=int(invited_count or 0),
            recent_invited_names=recent_invited_names,
            accesses=accesses,
        )

    async def sync_users_for_telegram(
        self,
        *,
        telegram_id: int,
        username: str | None,
        first_name: str | None,
        last_name: str | None,
        language_code: str | None,
    ) -> list[CachedUserView]:
        remote_users = await self._remnawave_client.get_users_by_telegram_id(telegram_id)

        async with self._session_factory() as session:
            owner = await self._upsert_telegram_user(
                session,
                telegram_id=telegram_id,
                username=username,
                first_name=first_name,
                last_name=last_name,
                language_code=language_code,
            )
            await self._sync_remote_users_for_telegram(
                session,
                telegram_id=telegram_id,
                remote_users=remote_users,
                owner=owner,
            )

            await session.commit()

        return await self.get_cached_users_for_telegram(telegram_id)

    async def refresh_cached_users_for_telegram(self, *, telegram_id: int) -> list[CachedUserView]:
        remote_users = await self._remnawave_client.get_users_by_telegram_id(telegram_id)

        async with self._session_factory() as session:
            owner = await session.scalar(
                select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
            )
            await self._sync_remote_users_for_telegram(
                session,
                telegram_id=telegram_id,
                remote_users=remote_users,
                owner=owner,
            )
            await session.commit()

        return await self.get_cached_users_for_telegram(telegram_id)

    async def link_user_by_short_uuid(
        self,
        *,
        short_uuid: str,
        telegram_id: int,
        username: str | None,
        first_name: str | None,
        last_name: str | None,
        language_code: str | None,
    ) -> CachedUserView:
        remote_user = await self._remnawave_client.get_user_by_short_uuid(short_uuid)

        if remote_user.telegram_id and remote_user.telegram_id != telegram_id:
            raise ValueError("Этот аккаунт уже привязан к другому Telegram ID.")

        updated_user = await self._remnawave_client.update_user(
            {
                "uuid": str(remote_user.uuid),
                "telegramId": telegram_id,
            }
        )

        async with self._session_factory() as session:
            owner = await self._upsert_telegram_user(
                session,
                telegram_id=telegram_id,
                username=username,
                first_name=first_name,
                last_name=last_name,
                language_code=language_code,
            )
            await self._upsert_remnawave_user(session, updated_user, owner)
            if self._settings.sync_subscription_history:
                history = await self._remnawave_client.get_user_subscription_history(
                    str(updated_user.uuid)
                )
                await self._upsert_history(session, str(updated_user.uuid), history)
            await session.commit()

        user = await self.get_cached_user_by_uuid(str(updated_user.uuid), include_history=True)
        if user is None:
            raise ValueError("Не удалось получить локальный кэш пользователя после привязки.")
        return user

    async def sync_user_by_identifier(
        self,
        *,
        identifier: str,
        include_history: bool = True,
    ) -> CachedUserView:
        resolved = await self._remnawave_client.resolve_user(identifier)
        remote_user = await self._remnawave_client.get_user_by_uuid(str(resolved.uuid))
        history = None

        if include_history and self._settings.sync_subscription_history:
            history = await self._remnawave_client.get_user_subscription_history(str(resolved.uuid))

        async with self._session_factory() as session:
            await self._upsert_remnawave_user(session, remote_user, owner=None)
            if history is not None:
                await self._upsert_history(session, str(resolved.uuid), history)
            await session.commit()

        user = await self.get_cached_user_by_uuid(str(resolved.uuid), include_history=include_history)
        if user is None:
            raise ValueError("Не удалось получить локальный кэш пользователя после синхронизации.")
        return user

    async def sync_all_users(self, batch_size: int) -> int:
        start = 0
        total_synced = 0

        while True:
            page = await self._remnawave_client.get_all_users(start=start, size=batch_size)
            if not page.users:
                break

            async with self._session_factory() as session:
                for remote_user in page.users:
                    await self._upsert_remnawave_user(session, remote_user, owner=None)
                    total_synced += 1
                await session.commit()

            start += len(page.users)
            if start >= page.total:
                break

        return total_synced

    async def get_cached_users_for_telegram(self, telegram_id: int) -> list[CachedUserView]:
        async with self._session_factory() as session:
            rows = await session.execute(
                select(RemnawaveUser, InternalSquad.name)
                .outerjoin(
                    RemnawaveUserInternalSquad,
                    RemnawaveUserInternalSquad.user_id == RemnawaveUser.id,
                )
                .outerjoin(
                    InternalSquad,
                    InternalSquad.uuid == RemnawaveUserInternalSquad.squad_uuid,
                )
                .where(RemnawaveUser.telegram_id == telegram_id)
                .order_by(RemnawaveUser.username.asc())
            )
            return self._build_views(rows.all())

    async def get_cached_user_by_uuid(
        self,
        user_uuid: str,
        *,
        include_history: bool = False,
    ) -> CachedUserView | None:
        async with self._session_factory() as session:
            rows = await session.execute(
                select(RemnawaveUser, InternalSquad.name)
                .outerjoin(
                    RemnawaveUserInternalSquad,
                    RemnawaveUserInternalSquad.user_id == RemnawaveUser.id,
                )
                .outerjoin(
                    InternalSquad,
                    InternalSquad.uuid == RemnawaveUserInternalSquad.squad_uuid,
                )
                .where(RemnawaveUser.rw_uuid == user_uuid)
            )
            views = self._build_views(rows.all())
            if not views:
                return None

            view = views[0]
            if include_history:
                history_rows = await session.execute(
                    select(SubscriptionRequestLog)
                    .where(SubscriptionRequestLog.user_uuid == user_uuid)
                    .order_by(SubscriptionRequestLog.request_at.desc())
                    .limit(5)
                )
                view.recent_requests = list(history_rows.scalars().all())
            return view

    async def _upsert_telegram_user(
        self,
        session: AsyncSession,
        *,
        telegram_id: int,
        username: str | None,
        first_name: str | None,
        last_name: str | None,
        language_code: str | None,
    ) -> TelegramUser:
        record = await session.scalar(
            select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
        )

        if record is None:
            record = TelegramUser(telegram_id=telegram_id)
            session.add(record)

        record.username = username
        record.first_name = first_name
        record.last_name = last_name
        record.language_code = language_code
        record.is_admin = self._settings.is_admin(telegram_id)
        record.last_seen_at = utcnow()
        record.updated_at = utcnow()

        await session.flush()
        return record

    async def _sync_remote_users_for_telegram(
        self,
        session: AsyncSession,
        *,
        telegram_id: int,
        remote_users: list[RemoteRemnawaveUser],
        owner: TelegramUser | None,
    ) -> None:
        active_uuids = {str(remote_user.uuid) for remote_user in remote_users}
        for remote_user in remote_users:
            await self._upsert_remnawave_user(session, remote_user, owner)

        filters = [RemnawaveUser.telegram_id == telegram_id]
        if owner is not None:
            filters.append(RemnawaveUser.owner_telegram_user_id == owner.id)

        stale_records = await session.scalars(
            select(RemnawaveUser).where(or_(*filters))
        )
        for record in stale_records:
            if record.rw_uuid in active_uuids:
                continue

            if record.telegram_id == telegram_id:
                record.telegram_id = None
            if owner is not None and record.owner_telegram_user_id == owner.id:
                record.owner_telegram_user_id = None
            record.synced_at = utcnow()

    async def _upsert_remnawave_user(
        self,
        session: AsyncSession,
        remote_user: RemoteRemnawaveUser,
        owner: TelegramUser | None,
    ) -> RemnawaveUser:
        owner_id = owner.id if owner is not None else None
        if owner_id is None and remote_user.telegram_id is not None:
            with session.no_autoflush:
                owner_id = await self._find_owner_id(session, remote_user.telegram_id)

        record = await session.scalar(
            select(RemnawaveUser).where(RemnawaveUser.rw_uuid == str(remote_user.uuid))
        )

        if record is None:
            record = RemnawaveUser(rw_uuid=str(remote_user.uuid))
            session.add(record)

        record.owner_telegram_user_id = owner_id
        record.rw_id = remote_user.id
        record.short_uuid = remote_user.short_uuid
        record.username = remote_user.username
        record.status = remote_user.status
        record.traffic_limit_bytes = remote_user.traffic_limit_bytes
        record.used_traffic_bytes = remote_user.user_traffic.used_traffic_bytes
        record.lifetime_used_traffic_bytes = remote_user.user_traffic.lifetime_used_traffic_bytes
        record.expire_at = self._normalize_datetime(remote_user.expire_at)
        record.telegram_id = remote_user.telegram_id
        record.email = remote_user.email
        record.description = remote_user.description
        record.tag = remote_user.tag
        record.hwid_device_limit = remote_user.hwid_device_limit
        record.external_squad_uuid = self._maybe_str(remote_user.external_squad_uuid)
        record.trojan_password = remote_user.trojan_password
        record.vless_uuid = str(remote_user.vless_uuid)
        record.ss_password = remote_user.ss_password
        record.last_triggered_threshold = remote_user.last_triggered_threshold
        record.sub_revoked_at = self._normalize_datetime(remote_user.sub_revoked_at)
        record.sub_last_user_agent = remote_user.sub_last_user_agent
        record.sub_last_opened_at = self._normalize_datetime(remote_user.sub_last_opened_at)
        record.last_traffic_reset_at = self._normalize_datetime(remote_user.last_traffic_reset_at)
        record.subscription_url = remote_user.subscription_url
        record.online_at = self._normalize_datetime(remote_user.user_traffic.online_at)
        record.first_connected_at = self._normalize_datetime(remote_user.user_traffic.first_connected_at)
        record.last_connected_node_uuid = self._maybe_str(remote_user.user_traffic.last_connected_node_uuid)
        record.remote_created_at = self._normalize_datetime(remote_user.created_at)
        record.remote_updated_at = self._normalize_datetime(remote_user.updated_at)
        record.synced_at = utcnow()

        await session.flush()
        await session.execute(
            delete(RemnawaveUserInternalSquad).where(
                RemnawaveUserInternalSquad.user_id == record.id
            )
        )

        for squad in remote_user.active_internal_squads:
            squad_uuid = str(squad.uuid)
            squad_record = await session.get(InternalSquad, squad_uuid)
            if squad_record is None:
                squad_record = InternalSquad(uuid=squad_uuid, name=squad.name, synced_at=utcnow())
                session.add(squad_record)
            else:
                squad_record.name = squad.name
                squad_record.synced_at = utcnow()

            session.add(
                RemnawaveUserInternalSquad(
                    user_id=record.id,
                    squad_uuid=squad_uuid,
                )
            )

        await session.flush()
        return record

    async def _upsert_history(
        self,
        session: AsyncSession,
        user_uuid: str,
        history: SubscriptionRequestHistory,
    ) -> None:
        for item in history.records:
            existing = await session.scalar(
                select(SubscriptionRequestLog).where(
                    SubscriptionRequestLog.remote_id == item.id
                )
            )
            if existing is None:
                existing = SubscriptionRequestLog(remote_id=item.id, user_uuid=user_uuid)
                session.add(existing)

            existing.user_uuid = user_uuid
            existing.request_at = self._normalize_datetime(item.request_at)
            existing.request_ip = item.request_ip
            existing.user_agent = item.user_agent
            existing.synced_at = utcnow()

    async def _upsert_referral_invite(
        self,
        session: AsyncSession,
        *,
        invited: TelegramUser,
        referred_by_telegram_id: int | None,
    ) -> bool:
        if referred_by_telegram_id is None or referred_by_telegram_id == invited.telegram_id:
            return False

        existing = await session.scalar(
            select(ReferralInvite).where(
                ReferralInvite.invited_telegram_id == invited.telegram_id
            )
        )
        if existing is not None:
            return False

        display_name = " ".join(
            part for part in [invited.first_name, invited.last_name] if part
        ).strip() or invited.username or str(invited.telegram_id)

        session.add(
            ReferralInvite(
                inviter_telegram_id=referred_by_telegram_id,
                invited_telegram_id=invited.telegram_id,
                invited_username=invited.username,
                invited_display_name=display_name,
            )
        )
        return True

    async def _get_or_create_referral_code(
        self,
        session: AsyncSession,
        *,
        telegram_id: int,
    ) -> str:
        existing = await session.scalar(
            select(ReferralCode).where(ReferralCode.telegram_id == telegram_id)
        )
        if existing is not None:
            return existing.code

        alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
        while True:
            code = "".join(secrets.choice(alphabet) for _ in range(8))
            duplicate = await session.scalar(
                select(ReferralCode.id).where(ReferralCode.code == code)
            )
            if duplicate is None:
                session.add(ReferralCode(telegram_id=telegram_id, code=code))
                await session.flush()
                return code

    async def _find_inviter_by_referral_code(
        self,
        session: AsyncSession,
        referred_by_referral_code: str | None,
    ) -> int | None:
        raw_code = (referred_by_referral_code or "").strip().upper()
        if not raw_code:
            return None

        return await session.scalar(
            select(ReferralCode.telegram_id).where(ReferralCode.code == raw_code)
        )

    async def _find_promo_code(
        self,
        session: AsyncSession,
        *,
        code: str,
    ) -> PromoCode | None:
        raw_code = self.normalize_discount_code(code)
        if not raw_code:
            return None

        return await session.scalar(
            select(PromoCode).where(PromoCode.code == raw_code)
        )

    async def _get_active_promo_for_user(
        self,
        session: AsyncSession,
        *,
        telegram_id: int,
    ) -> PromoCode | None:
        promo = await session.scalar(
            select(PromoCode)
            .join(
                PromoCodeApplication,
                PromoCodeApplication.promo_code_id == PromoCode.id,
            )
            .where(PromoCodeApplication.telegram_id == telegram_id)
        )
        if promo is None or not self._promo_is_valid(promo):
            return None
        return promo

    async def _upsert_promo_application(
        self,
        session: AsyncSession,
        *,
        telegram_id: int,
        promo_code_id: int,
    ) -> None:
        application = await session.scalar(
            select(PromoCodeApplication).where(
                PromoCodeApplication.telegram_id == telegram_id
            )
        )
        if application is None:
            session.add(
                PromoCodeApplication(
                    telegram_id=telegram_id,
                    promo_code_id=promo_code_id,
                )
            )
            return

        application.promo_code_id = promo_code_id
        application.updated_at = utcnow()

    async def _find_owner_id(self, session: AsyncSession, telegram_id: int | None) -> int | None:
        if telegram_id is None:
            return None

        return await session.scalar(
            select(TelegramUser.id).where(TelegramUser.telegram_id == telegram_id)
        )

    @staticmethod
    def _normalize_datetime(value: datetime | None) -> datetime | None:
        if value is None:
            return None
        if value.tzinfo is None:
            return value
        return value.astimezone(timezone.utc).replace(tzinfo=None)

    @staticmethod
    def _maybe_str(value: object | None) -> str | None:
        return str(value) if value is not None else None

    @staticmethod
    def normalize_discount_code(value: str | None) -> str:
        return "".join((value or "").strip().upper().split())

    @classmethod
    def _ensure_promo_can_be_applied(cls, promo: PromoCode) -> None:
        if not promo.is_active:
            raise ValueError("Промокод отключён.")
        if cls._promo_is_expired(promo):
            raise ValueError("Срок действия промокода истёк.")
        if promo.discount_percent <= 0:
            raise ValueError("Промокод не даёт скидку.")

    @classmethod
    def _promo_is_valid(cls, promo: PromoCode) -> bool:
        return bool(promo.is_active) and not cls._promo_is_expired(promo) and promo.discount_percent > 0

    @staticmethod
    def _promo_is_expired(promo: PromoCode) -> bool:
        return promo.expires_at is not None and promo.expires_at <= utcnow()

    @staticmethod
    def _promo_code_view(promo: PromoCode) -> PromoCodeView:
        return PromoCodeView(
            code=promo.code,
            plan_code=promo.plan_code,
            discount_percent=int(promo.discount_percent),
            expires_at=promo.expires_at,
            is_active=bool(promo.is_active),
            created_by_telegram_id=promo.created_by_telegram_id,
            created_at=promo.created_at,
            updated_at=promo.updated_at,
        )

    @staticmethod
    def _guide_view(guide: Guide, photo_file_ids: list[str]) -> GuideView:
        return GuideView(
            id=guide.id,
            title=guide.title,
            short_description=guide.short_description,
            body_text=guide.body_text,
            photo_file_ids=photo_file_ids,
            is_active=bool(guide.is_active),
            created_by_telegram_id=guide.created_by_telegram_id,
            created_at=guide.created_at,
            updated_at=guide.updated_at,
        )

    @classmethod
    def _build_guide_views(cls, rows: list[tuple[Guide, str | None]]) -> list[GuideView]:
        guide_map: dict[int, GuideView] = {}
        for guide, file_id in rows:
            view = guide_map.get(guide.id)
            if view is None:
                view = cls._guide_view(guide, [])
                guide_map[guide.id] = view
            if file_id and file_id not in view.photo_file_ids:
                view.photo_file_ids.append(file_id)
        return list(guide_map.values())

    @staticmethod
    def _build_views(rows: list[tuple[RemnawaveUser, str | None]]) -> list[CachedUserView]:
        view_map: dict[int, CachedUserView] = {}
        for user, squad_name in rows:
            view = view_map.setdefault(user.id, CachedUserView(record=user))
            if squad_name and squad_name not in view.internal_squads:
                view.internal_squads.append(squad_name)
        return list(view_map.values())
