"""Chat service layer.

All chat business logic lives here; routes (added in a later phase) stay thin
and call into these functions. Conversations are unique per buyer+seller+listing
and messages are immutable once created.
"""

from __future__ import annotations

from flask import current_app
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload

from app.chat.constants import MAX_MESSAGE_LENGTH
from app.chat.validators import detect_contact_info
from app.extensions import db
from app.models import (
    Conversation,
    ConversationStatus,
    Listing,
    Message,
    Notification,
    NotificationType,
    RelatedEntityType,
    User,
)
from app.models.base import utcnow
from app.notifications.services import create_notification
from app.premium.permissions import can_send_unlimited_messages


class ChatError(Exception):
    """Base error for chat operations that violate a business rule."""


class SelfConversationError(ChatError):
    """Raised when a user tries to start a conversation on their own listing."""


class NotParticipantError(ChatError):
    """Raised when a user acts on a conversation they are not part of."""


class ConversationClosedError(ChatError):
    """Raised when a message is sent to a closed conversation."""


class EmptyMessageError(ChatError):
    """Raised when a message has no content after trimming."""


class MessageTooLongError(ChatError):
    """Raised when a message exceeds :data:`MAX_MESSAGE_LENGTH`."""


class ContactInfoBlockedError(ChatError):
    """Raised when a message appears to share personal contact information."""


class MessageLimitReachedError(ChatError):
    """Raised when a free conversation has exhausted its message allowance."""


def _is_participant(conversation: Conversation, user: User) -> bool:
    return user.id in (conversation.buyer_id, conversation.seller_id)


def _messages_per_rewarded_ad() -> int:
    """Messages granted per completed rewarded ad (single config value).

    Reads ``MESSAGES_PER_REWARDED_AD`` (falling back to the legacy
    ``REWARDED_UNLOCK_MESSAGES``), so the reward size is changed in one place.
    """
    return current_app.config.get(
        "MESSAGES_PER_REWARDED_AD",
        current_app.config.get("REWARDED_UNLOCK_MESSAGES", 5),
    )


def _remaining_for(conversation: Conversation, user: User) -> int:
    """Messages this user has left in *their* quota for this conversation.

    ``FREE_MESSAGE_LIMIT + rewarded unlocks - counted messages sent``. Each
    participant's quota is independent, so this never depends on the other side.
    """
    free_limit = current_app.config.get("FREE_MESSAGE_LIMIT", 10)
    return (
        free_limit
        + conversation.unlocked_messages_for(user)
        - conversation.messages_used_by(user)
    )


def get_messaging_state(conversation: Conversation, user: User) -> dict:
    """Return the current user's message-allowance state for the UI/rewarded flow.

    The quota is per participant and every text message counts from the very
    first one; premium conversations are unlimited. A free participant can watch a
    rewarded advertisement at any time to add more messages — the reward stacks
    indefinitely with no per-conversation cap. When this user's own allowance is
    exhausted the composer is replaced by the rewarded-advertisement card; the
    other participant is unaffected.
    """
    unlock_size = _messages_per_rewarded_ad()
    free_limit = current_app.config.get("FREE_MESSAGE_LIMIT", 10)
    # Premium members have unlimited messaging: either the conversation is flagged
    # premium (legacy/admin) or the participant currently has an active
    # subscription. Existing conversations benefit automatically once premium
    # becomes active — this is resolved live, not cached on the conversation.
    premium = conversation.is_premium_conversation or can_send_unlimited_messages(user)
    counting_active = not premium
    used = conversation.messages_used_by(user)
    unlocked = conversation.unlocked_messages_for(user)
    ads_watched = conversation.ads_watched_by(user)
    allowance = free_limit + unlocked  # total capacity (the "/ Y" denominator)
    remaining = allowance - used
    limit_reached = counting_active and not premium and remaining <= 0
    # A free participant may always watch another rewarded ad while the
    # conversation is active — there is no cap; each ad simply adds more messages.
    can_watch_ad = counting_active and not premium and conversation.status == ConversationStatus.ACTIVE
    return {
        "premium": premium,
        "counting_active": counting_active,
        "used": used,
        "unlocked": unlocked,
        "allowance": allowance,
        "total_remaining": max(remaining, 0),
        "limit_reached": limit_reached,
        "ads_watched": ads_watched,
        "can_watch_ad": can_watch_ad,
        "unlock_size": unlock_size,
    }


def unlock_rewarded_messages(conversation: Conversation, user: User, ad=None) -> int:
    """Grant additional messages after a rewarded advertisement is watched.

    Every completed rewarded ad adds :func:`_messages_per_rewarded_ad` messages to
    this participant's quota and records the advertisement as viewed. There is no
    per-conversation cap — the reward stacks indefinitely, so a user can keep
    watching ads to keep chatting. Returns the number of messages added.
    """
    if not _is_participant(conversation, user):
        raise NotParticipantError("You are not a participant in this conversation.")
    if conversation.status != ConversationStatus.ACTIVE:
        raise ConversationClosedError("This conversation is closed.")

    unlock_size = _messages_per_rewarded_ad()
    # Credit only this participant's quota; the other side is untouched.
    conversation.record_unlock(user, unlock_size)
    if ad is not None:
        ad.impression_count = (ad.impression_count or 0) + 1
    db.session.commit()
    return unlock_size


def get_or_create_conversation(buyer: User, listing: Listing) -> Conversation:
    """Return the unique conversation for (buyer, listing's seller, listing).

    Creates it if absent. Enforces one conversation per buyer+seller+listing via
    the database unique constraint and is safe under concurrent requests.
    """
    if listing.seller_id == buyer.id:
        raise SelfConversationError("You cannot start a conversation on your own listing.")

    existing = db.session.scalar(
        db.select(Conversation).filter_by(
            buyer_id=buyer.id, seller_id=listing.seller_id, listing_id=listing.id
        )
    )
    if existing is not None:
        return existing

    conversation = Conversation(
        buyer_id=buyer.id,
        seller_id=listing.seller_id,
        listing_id=listing.id,
        status=ConversationStatus.ACTIVE,
        conversation_started_at=utcnow(),
    )
    db.session.add(conversation)
    try:
        db.session.commit()
    except IntegrityError:
        # A concurrent request created it first; return that row.
        db.session.rollback()
        return db.session.scalar(
            db.select(Conversation).filter_by(
                buyer_id=buyer.id, seller_id=listing.seller_id, listing_id=listing.id
            )
        )
    return conversation


# Inbox sort options exposed to the route/UI. Keys are stable (used in the URL
# and session); labels are shown in the dropdown. "newest" is the default.
CONVERSATION_SORT_OPTIONS = [
    ("newest", "Newest First"),
    ("oldest", "Oldest First"),
    ("title_az", "Listing Name (A → Z)"),
    ("title_za", "Listing Name (Z → A)"),
]
DEFAULT_CONVERSATION_SORT = "newest"
_VALID_CONVERSATION_SORTS = {key for key, _ in CONVERSATION_SORT_OPTIONS}


def get_user_conversations(user: User, page: int, sort: str = DEFAULT_CONVERSATION_SORT):
    """Return a pagination of the user's conversations, ordered by ``sort``.

    Ordering is done entirely in the database (never in Python/JS). Eager-loads
    the relationships the inbox renders (listing + its images, and both
    participants' profiles) so the page runs a small, constant number of queries
    instead of one per conversation.

    ``sort`` is one of the keys in :data:`CONVERSATION_SORT_OPTIONS`; unknown
    values fall back to the default (newest activity first).
    """
    if sort not in _VALID_CONVERSATION_SORTS:
        sort = DEFAULT_CONVERSATION_SORT
    per_page = current_app.config.get("DEFAULT_PAGE_SIZE", 20)

    # Effective activity time: the last message, or the conversation's start if
    # it has none. Matches the timestamp the inbox displays.
    activity = db.func.coalesce(
        Conversation.last_message_at, Conversation.conversation_started_at
    )

    query = (
        db.select(Conversation)
        .where(
            db.or_(
                Conversation.buyer_id == user.id,
                Conversation.seller_id == user.id,
            )
        )
        .options(
            selectinload(Conversation.listing).selectinload(Listing.images),
            selectinload(Conversation.buyer).selectinload(User.profile),
            selectinload(Conversation.seller).selectinload(User.profile),
        )
    )

    if sort in ("title_az", "title_za"):
        # Join the listing purely for ORDER BY (many-to-one, so no row fan-out).
        query = query.join(Conversation.listing)
        title_order = Listing.title.asc() if sort == "title_az" else Listing.title.desc()
        query = query.order_by(title_order, Conversation.id.desc())
    elif sort == "oldest":
        query = query.order_by(activity.asc(), Conversation.id.asc())
    else:  # "newest" (default)
        query = query.order_by(activity.desc(), Conversation.id.desc())

    return db.paginate(query, page=page, per_page=per_page, error_out=False)


def get_conversation_by_id_for_user_or_none(conversation_id: int, user: User) -> Conversation | None:
    """Return a conversation by id if the user is a participant, otherwise None."""
    return db.session.scalar(
        db.select(Conversation).where(
            Conversation.id == conversation_id,
            db.or_(
                Conversation.buyer_id == user.id,
                Conversation.seller_id == user.id,
            ),
        )
    )


def get_unread_conversation_ids(user: User, conversation_ids) -> set[int]:
    """Return the subset of conversation ids that have unread messages for ``user``.

    A conversation is unread if it contains a non-deleted message from the other
    participant with no ``read_at``. One query, so the inbox stays efficient.
    """
    ids = list(conversation_ids)
    if not ids:
        return set()
    rows = db.session.scalars(
        db.select(Message.conversation_id)
        .distinct()
        .where(
            Message.conversation_id.in_(ids),
            Message.sender_id != user.id,
            Message.read_at.is_(None),
            Message.deleted_at.is_(None),
        )
    )
    return set(rows)


def get_conversation_for_user_or_404(public_id: str, user: User) -> Conversation:
    """Return a conversation by public id only if the user is a participant.

    Aborts with 404 otherwise (does not reveal that the conversation exists).
    """
    return db.first_or_404(
        db.select(Conversation).where(
            Conversation.public_id == public_id,
            db.or_(
                Conversation.buyer_id == user.id,
                Conversation.seller_id == user.id,
            ),
        )
    )


def _validate_message_text(text: str) -> str:
    """Trim and validate message content; return the clean body or raise."""
    body = (text or "").strip()
    if not body:
        raise EmptyMessageError("Message cannot be empty.")
    if len(body) > MAX_MESSAGE_LENGTH:
        raise MessageTooLongError(
            f"Message cannot exceed {MAX_MESSAGE_LENGTH} characters."
        )
    if detect_contact_info(body):
        raise ContactInfoBlockedError(
            "For your safety, keep the conversation on Student Bazaar. "
            "Sharing contact details is only allowed after an offer is accepted."
        )
    return body


def send_message(conversation: Conversation, sender: User, text: str) -> Message:
    """Create an immutable message after enforcing the chat rules.

    Validates participant, active conversation, non-empty/trimmed content,
    max length and no contact info; updates ``last_message_at`` and sets
    ``first_reply_at`` on the second participant's first reply.
    """
    if not _is_participant(conversation, sender):
        raise NotParticipantError("You are not a participant in this conversation.")
    if conversation.status != ConversationStatus.ACTIVE:
        raise ConversationClosedError("This conversation is closed.")

    body = _validate_message_text(text)
    now = utcnow()

    # Message-limit enforcement (server-side). Every text message counts,
    # starting from the first one; premium is unlimited. Premium applies when the
    # conversation is flagged premium OR the sender has an active subscription,
    # so premium members bypass the quota even via the API. The quota is per
    # participant: only the sender's own allowance is checked.
    counting_active = not (
        conversation.is_premium_conversation or can_send_unlimited_messages(sender)
    )
    if counting_active and _remaining_for(conversation, sender) <= 0:
        raise MessageLimitReachedError(
            "You've reached your free message limit for this conversation. "
            "Watch a sponsored advertisement to unlock 5 additional messages."
        )

    # The first reply is the second participant's first message in the thread.
    if conversation.first_reply_at is None:
        prior_from_other = db.session.scalar(
            db.select(db.func.count())
            .select_from(Message)
            .where(
                Message.conversation_id == conversation.id,
                Message.sender_id != sender.id,
                Message.deleted_at.is_(None),
            )
        )
        if prior_from_other:
            conversation.first_reply_at = now

    message = Message(
        conversation_id=conversation.id,
        sender_id=sender.id,
        message=body,
    )
    conversation.last_message_at = now
    # Count this message against the sender's own quota only.
    if counting_active:
        conversation.record_message_used(sender)
    db.session.add(message)
    db.session.commit()

    recipient = (
        conversation.seller
        if sender.id == conversation.buyer_id
        else conversation.buyer
    )
    existing_unread = db.session.scalar(
        db.select(Notification)
        .where(
            Notification.user_id == recipient.id,
            Notification.notification_type == NotificationType.MESSAGE,
            Notification.related_entity_type == RelatedEntityType.CONVERSATION,
            Notification.related_entity_id == conversation.id,
            Notification.read_at.is_(None),
        )
        .limit(1)
    )

    if existing_unread is None:
        sender_name = (
            sender.profile.full_name
            if getattr(sender, "profile", None) and sender.profile.full_name
            else getattr(sender, "full_name", None)
            or sender.email
            or "Someone"
        )
        listing_title = (
            conversation.listing.title
            if getattr(conversation, "listing", None)
            and getattr(conversation.listing, "title", None)
            else "your listing"
        )
        create_notification(
            user=recipient,
            notification_type=NotificationType.MESSAGE,
            title="New message",
            message=f"{sender_name} sent you a message about '{listing_title}'.",
            related_entity_type=RelatedEntityType.CONVERSATION,
            related_entity_id=conversation.id,
        )

    return message


def mark_conversation_read(conversation: Conversation, user: User) -> None:
    """Mark the other participant's unread messages as read for ``user``."""
    if not _is_participant(conversation, user):
        raise NotParticipantError("You are not a participant in this conversation.")
    db.session.execute(
        db.update(Message)
        .where(
            Message.conversation_id == conversation.id,
            Message.sender_id != user.id,
            Message.read_at.is_(None),
            Message.deleted_at.is_(None),
        )
        .values(read_at=utcnow())
    )
    db.session.commit()


def close_conversation(conversation: Conversation, user: User) -> None:
    """Close a conversation (participant only). Not exposed in the UI yet."""
    if not _is_participant(conversation, user):
        raise NotParticipantError("You are not a participant in this conversation.")
    conversation.status = ConversationStatus.CLOSED
    db.session.commit()


def find_conversation(buyer: User, listing: Listing) -> Conversation | None:
    """Return the buyer's existing conversation for a listing, or None."""
    return db.session.scalar(
        db.select(Conversation).filter_by(
            buyer_id=buyer.id, seller_id=listing.seller_id, listing_id=listing.id
        )
    )


def start_conversation_with_message(buyer: User, listing: Listing, text: str):
    """Start (or reuse) a conversation by sending its first message.

    The message is validated *before* any conversation is created, so an invalid
    first message never leaves an empty conversation behind (lazy creation).
    Returns ``(conversation, message)``.
    """
    if listing.seller_id == buyer.id:
        raise SelfConversationError("You cannot start a conversation on your own listing.")
    _validate_message_text(text)  # raises before creating anything
    conversation = get_or_create_conversation(buyer, listing)
    message = send_message(conversation, buyer, text)
    return conversation, message
