"""
inventory/services/inventory_service.py
────────────────────────────────────────
All stock movement logic lives here.

Rules:
• Every stock change creates an InventoryTransaction (immutable ledger).
• InventoryBalance is the fast-lookup cache; always updated atomically.
• Book.stock is also updated to keep the store's stock check in sync.
• Never edit or delete InventoryTransaction rows — create a reversal instead.
"""

import logging
from decimal import Decimal

from django.db import transaction
from django.db.models import F, Sum
from django.utils import timezone

from catalog_app.models import AuthorApplicationBook, Book
from inventory.models import (
    AuditLog,
    Bookstore,
    BookstoreListing,
    InventoryBalance,
    InventoryTransaction,
    StockLocation,
)

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------

def _get_or_create_balance(book: Book, location: StockLocation) -> InventoryBalance:
    balance, _ = InventoryBalance.objects.get_or_create(
        book=book, location=location,
        defaults={"quantity_on_hand": 0, "quantity_reserved": 0},
    )
    return balance


def _adjust_balance(book: Book, location: StockLocation, delta: int) -> None:
    """
    Atomically add `delta` to quantity_on_hand for (book, location).
    delta > 0 → stock in; delta < 0 → stock out.
    """
    _get_or_create_balance(book, location)
    InventoryBalance.objects.filter(book=book, location=location).update(
        quantity_on_hand=F("quantity_on_hand") + delta
    )


def _sync_book_stock(book: Book) -> None:
    """
    Recompute Book.stock from Main Warehouse only — not every location.

    Book.stock is what the website and the Tai Bookstore POS both read to
    decide what's available to sell, and both physically fulfill from
    MAIN-WH. Stock consigned to an external bookstore has left the
    warehouse and isn't sellable from there, so it must not inflate this
    number — otherwise the website could sell copies that are actually
    sitting on someone else's shelf. Total stock across every location
    (including consigned bookstores) is still available via
    report_service.stock_by_location() / inventory_valuation_by_book() for
    internal reporting — those query InventoryBalance directly and are
    unaffected by this.
    """
    total = (
        InventoryBalance.objects
        .filter(book=book, location__code="MAIN-WH")
        .aggregate(total=Sum("quantity_on_hand"))["total"] or 0
    )
    Book.objects.filter(pk=book.pk).update(stock=total)


def _audit(user, action: str, obj, old=None, new=None, ip=None) -> None:
    AuditLog.objects.create(
        user=user,
        action=action,
        model_name=type(obj).__name__,
        object_id=str(obj.pk),
        object_repr=str(obj),
        old_values=old,
        new_values=new,
        ip_address=ip,
    )


def _get_application_book(book: Book):
    """
    The AuthorApplicationBook this Book was created from (with its
    application/package preloaded), or None for books added directly by
    staff — those have no "agreed price" to auto-list against.
    """
    return (
        AuthorApplicationBook.objects
        .filter(created_book=book)
        .select_related("application__package")
        .first()
    )


def _create_internal_listing(book: Book, *, agreed_price, payee_cut_percent, note: str, user=None) -> None:
    """
    Shared creation step for both _ensure_internal_listing() and
    ensure_internal_listing_for_book() below. bookstore_cut_percent is 0
    here: there's no third party taking a cut, so the residual
    my_profit_amount calc in calculate_item_margins() naturally ends up as
    (agreed-price margin + any markup) — no special-casing needed anywhere
    that reads bookstore_cut_amount as a real external payable.
    """
    internal_bookstore = Bookstore.objects.filter(is_internal=True).first()
    if not internal_bookstore:
        logger.warning(
            "No internal Bookstore (is_internal=True) configured — "
            "skipping Tai Bookstore/website auto-listing for %s.", book.title,
        )
        return
    if BookstoreListing.objects.filter(book=book, bookstore=internal_bookstore).exists():
        return

    BookstoreListing.objects.create(
        book=book,
        bookstore=internal_bookstore,
        agreed_unit_price=agreed_price or Decimal("0"),
        bookstore_cut_percent=Decimal("0"),
        payee_cut_percent=payee_cut_percent,
        notes=note,
        created_by=user,
    )
    logger.info("Auto-listed %s at Tai Bookstore/website.", book.title)


def _ensure_internal_listing(book: Book, user=None) -> None:
    """
    First-stock auto-listing at Tai Bookstore/website — every application
    book gets one, regardless of package tier, since that distribution is
    universal (only *external* bookstore distribution is tier-gated).
    """
    app_book = _get_application_book(book)
    if not app_book:
        return
    package = app_book.application.package
    _create_internal_listing(
        book,
        agreed_price=app_book.selling_price_kes,
        payee_cut_percent=Decimal("100") - package.my_keep_percent,
        note=f"Auto-listed on first stock receipt ({package.name} package).",
        user=user,
    )


def ensure_internal_listing_for_book(book: Book, user=None) -> None:
    """
    Book-agnostic safeguard, called when Book.pos_price is set on a book
    that has no internal BookstoreListing yet — e.g. one added directly by
    staff, never through an author distribution application (those already
    get one automatically on first stock receipt via _ensure_internal_listing
    above). Without a listing, a POS markup would inflate the author's
    payout proportionally instead of staying pinned to the agreed price,
    same as everywhere else. my_keep_percent is resolved exactly like the
    "Direct" 2-way split in sales_service.calculate_item_margins() (per-book
    override → payee default → 25%), so this doesn't change what a sale at
    the original price would already have paid out — it only protects
    against markup going forward.
    """
    app_book = _get_application_book(book)
    if app_book:
        _ensure_internal_listing(book, user=user)
        return

    payee = book.publisher or book.author
    my_keep_percent = (
        book.my_keep_percent
        if book.my_keep_percent is not None
        else (
            payee.default_my_keep_percent
            if payee and payee.default_my_keep_percent is not None
            else Decimal("30.00")
        )
    )
    _create_internal_listing(
        book,
        agreed_price=book.selling_price_kes or book.price,
        payee_cut_percent=Decimal("100") - my_keep_percent,
        note="Auto-listed to keep the agreed price protected once a POS markup price was set.",
        user=user,
    )


def _ensure_external_listing(book: Book, bookstore, user=None) -> None:
    """
    Listing at one specific external bookstore, created the moment
    consignment stock is actually dispatched there (see
    dispatch_to_bookstore() below) — not proactively for every active
    bookstore, since a bookstore has nothing to sell until it actually has
    the book.
    """
    app_book = _get_application_book(book)
    if not app_book or not app_book.application.package.allows_external_bookstores:
        return
    if BookstoreListing.objects.filter(book=book, bookstore=bookstore).exists():
        return

    package = app_book.application.package
    if package.external_bookstore_author_percent is None:
        logger.warning(
            "Package %s has no external_bookstore_author_percent set — "
            "skipping listing for %s at %s.", package.name, book.title, bookstore.name,
        )
        return

    BookstoreListing.objects.create(
        book=book,
        bookstore=bookstore,
        agreed_unit_price=app_book.selling_price_kes or Decimal("0"),
        bookstore_cut_percent=bookstore.default_bookstore_cut_percent,
        payee_cut_percent=package.external_bookstore_author_percent,
        notes=f"Auto-listed on dispatch ({package.name} package allows external bookstores).",
        created_by=user,
    )
    logger.info("Auto-listed %s at bookstore %s.", book.title, bookstore.name)


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

@transaction.atomic
def receive_stock(
    *,
    book: Book,
    location: StockLocation,
    quantity: int,
    reference: str = "",
    notes: str = "",
    user=None,
) -> InventoryTransaction:
    """Record stock received from an author / supplier into a location."""
    if quantity <= 0:
        raise ValueError("quantity must be positive.")

    tx = InventoryTransaction.objects.create(
        book=book,
        destination_location=location,
        quantity=quantity,
        transaction_type=InventoryTransaction.TYPE_RECEIPT_FROM_AUTHOR,
        transaction_reference=reference,
        notes=notes,
        performed_by=user,
    )
    _adjust_balance(book, location, +quantity)
    _sync_book_stock(book)

    if not book.is_listed:
        book.refresh_from_db(fields=["stock"])
        if book.stock > 0:
            book.is_listed = True
            book.save(update_fields=["is_listed"])
            logger.info("Auto-listed %s after first stock receipt.", book.title)

    # "Receive Stock" accepts any active location, not just MAIN-WH — if
    # stock is ever received straight into a bookstore's own location
    # (bypassing MAIN-WH entirely), that's the same as a dispatch there and
    # should list at that specific bookstore, not Tai/website.
    destination_bookstore = getattr(location, "bookstore", None)
    if destination_bookstore is not None:
        _ensure_external_listing(book, destination_bookstore, user=user)
    elif location.code == "MAIN-WH":
        _ensure_internal_listing(book, user=user)

    _audit(user, AuditLog.ACTION_CREATE, tx,
           new={"book": book.pk, "location": location.pk, "qty": quantity})
    logger.info("Stock received: %s × %d → %s (ref=%s)", book.title, quantity, location.name, reference)
    return tx


@transaction.atomic
def transfer_stock(
    *,
    book: Book,
    source: StockLocation,
    destination: StockLocation,
    quantity: int,
    reference: str = "",
    notes: str = "",
    user=None,
) -> InventoryTransaction:
    """Move stock between two internal locations."""
    if quantity <= 0:
        raise ValueError("quantity must be positive.")
    src_balance = InventoryBalance.objects.select_for_update().get(book=book, location=source)
    if src_balance.quantity_available < quantity:
        raise ValueError(
            f"Insufficient stock at {source.name}: "
            f"available {src_balance.quantity_available}, requested {quantity}."
        )

    tx = InventoryTransaction.objects.create(
        book=book,
        source_location=source,
        destination_location=destination,
        quantity=quantity,
        transaction_type=InventoryTransaction.TYPE_TRANSFER,
        transaction_reference=reference,
        notes=notes,
        performed_by=user,
    )
    _adjust_balance(book, source, -quantity)
    _adjust_balance(book, destination, +quantity)
    _sync_book_stock(book)

    # Staff commonly move consignment stock to a bookstore via this generic
    # "Internal Transfer" op (through the PendingInventoryTransaction
    # maker-checker flow) rather than calling dispatch_to_bookstore()
    # directly — so the same auto-listing needs to fire here too, whenever
    # the destination happens to be a bookstore's linked stock location.
    destination_bookstore = getattr(destination, "bookstore", None)
    if destination_bookstore is not None:
        _ensure_external_listing(book, destination_bookstore, user=user)

    _audit(user, AuditLog.ACTION_CREATE, tx,
           new={"book": book.pk, "src": source.pk, "dst": destination.pk, "qty": quantity})
    return tx


@transaction.atomic
def dispatch_to_bookstore(
    *,
    book: Book,
    bookstore,
    quantity: int,
    source: StockLocation,
    reference: str = "",
    notes: str = "",
    user=None,
) -> InventoryTransaction:
    """Send consignment stock to a bookstore."""
    if quantity <= 0:
        raise ValueError("quantity must be positive.")
    src_balance = InventoryBalance.objects.select_for_update().get(book=book, location=source)
    if src_balance.quantity_available < quantity:
        raise ValueError(f"Insufficient stock at {source.name}.")

    dest = bookstore.stock_location
    if not dest:
        raise ValueError(f"Bookstore '{bookstore.name}' has no linked stock location.")

    tx = InventoryTransaction.objects.create(
        book=book,
        source_location=source,
        destination_location=dest,
        quantity=quantity,
        transaction_type=InventoryTransaction.TYPE_DISPATCH_TO_BOOKSTORE,
        transaction_reference=reference,
        notes=notes,
        performed_by=user,
    )
    _adjust_balance(book, source, -quantity)
    _adjust_balance(book, dest, +quantity)
    _sync_book_stock(book)
    _ensure_external_listing(book, bookstore, user=user)
    _audit(user, AuditLog.ACTION_CREATE, tx,
           new={"bookstore": bookstore.pk, "qty": quantity})
    return tx


@transaction.atomic
def return_from_bookstore(
    *,
    book: Book,
    bookstore,
    quantity: int,
    destination: StockLocation,
    reference: str = "",
    notes: str = "",
    user=None,
) -> InventoryTransaction:
    """Receive unsold consignment back from a bookstore."""
    if quantity <= 0:
        raise ValueError("quantity must be positive.")

    src = bookstore.stock_location
    if not src:
        raise ValueError(f"Bookstore '{bookstore.name}' has no linked stock location.")

    tx = InventoryTransaction.objects.create(
        book=book,
        source_location=src,
        destination_location=destination,
        quantity=quantity,
        transaction_type=InventoryTransaction.TYPE_RETURN_FROM_BOOKSTORE,
        transaction_reference=reference,
        notes=notes,
        performed_by=user,
    )
    _adjust_balance(book, src, -quantity)
    _adjust_balance(book, destination, +quantity)
    _sync_book_stock(book)
    return tx


@transaction.atomic
def dispatch_to_event(
    *,
    book: Book,
    event,
    quantity: int,
    source: StockLocation,
    reference: str = "",
    notes: str = "",
    user=None,
) -> InventoryTransaction:
    if quantity <= 0:
        raise ValueError("quantity must be positive.")
    src_balance = InventoryBalance.objects.select_for_update().get(book=book, location=source)
    if src_balance.quantity_available < quantity:
        raise ValueError(f"Insufficient stock at {source.name}.")

    dest = event.stock_location
    if not dest:
        raise ValueError(f"Event '{event.name}' has no linked stock location.")

    tx = InventoryTransaction.objects.create(
        book=book,
        source_location=source,
        destination_location=dest,
        quantity=quantity,
        transaction_type=InventoryTransaction.TYPE_DISPATCH_TO_EVENT,
        transaction_reference=reference,
        notes=notes,
        performed_by=user,
    )
    _adjust_balance(book, source, -quantity)
    _adjust_balance(book, dest, +quantity)
    _sync_book_stock(book)
    return tx


@transaction.atomic
def return_from_event(
    *,
    book: Book,
    event,
    quantity: int,
    destination: StockLocation,
    reference: str = "",
    notes: str = "",
    user=None,
) -> InventoryTransaction:
    src = event.stock_location
    if not src:
        raise ValueError(f"Event '{event.name}' has no linked stock location.")

    tx = InventoryTransaction.objects.create(
        book=book,
        source_location=src,
        destination_location=destination,
        quantity=quantity,
        transaction_type=InventoryTransaction.TYPE_RETURN_FROM_EVENT,
        transaction_reference=reference,
        notes=notes,
        performed_by=user,
    )
    _adjust_balance(book, src, -quantity)
    _adjust_balance(book, destination, +quantity)
    _sync_book_stock(book)
    return tx


@transaction.atomic
def write_off_stock(
    *,
    book: Book,
    location: StockLocation,
    quantity: int,
    reference: str = "",
    notes: str = "",
    user=None,
) -> InventoryTransaction:
    """Remove damaged / lost stock from a location."""
    if quantity <= 0:
        raise ValueError("quantity must be positive.")

    tx = InventoryTransaction.objects.create(
        book=book,
        source_location=location,
        quantity=quantity,
        transaction_type=InventoryTransaction.TYPE_WRITE_OFF,
        transaction_reference=reference,
        notes=notes,
        performed_by=user,
    )
    _adjust_balance(book, location, -quantity)
    _sync_book_stock(book)
    _audit(user, AuditLog.ACTION_CREATE, tx,
           new={"book": book.pk, "location": location.pk, "qty": -quantity})
    return tx


@transaction.atomic
def adjust_stock(
    *,
    book: Book,
    location: StockLocation,
    new_quantity: int,
    reference: str = "",
    notes: str = "",
    user=None,
) -> InventoryTransaction:
    """
    Set absolute quantity at a location (physical stocktake result).
    Creates an adjustment transaction for the delta.
    """
    balance = _get_or_create_balance(book, location)
    delta = new_quantity - balance.quantity_on_hand

    tx = InventoryTransaction.objects.create(
        book=book,
        source_location=location if delta < 0 else None,
        destination_location=location if delta >= 0 else None,
        quantity=abs(delta),
        transaction_type=InventoryTransaction.TYPE_ADJUSTMENT,
        transaction_reference=reference,
        notes=notes,
        performed_by=user,
    )
    _adjust_balance(book, location, delta)
    _sync_book_stock(book)
    _audit(user, AuditLog.ACTION_UPDATE, tx,
           old={"quantity_on_hand": balance.quantity_on_hand},
           new={"quantity_on_hand": new_quantity})
    return tx


@transaction.atomic
def reverse_transaction(
    *,
    original: InventoryTransaction,
    notes: str = "",
    user=None,
) -> InventoryTransaction:
    """Create a mirror reversal of any transaction."""
    if original.is_reversed:
        raise ValueError("Transaction is already reversed.")

    reversal = InventoryTransaction.objects.create(
        book=original.book,
        source_location=original.destination_location,
        destination_location=original.source_location,
        quantity=original.quantity,
        transaction_type=InventoryTransaction.TYPE_REVERSAL,
        transaction_reference=f"REVERSAL-{original.pk}",
        notes=notes or f"Reversal of transaction #{original.pk}",
        performed_by=user,
    )
    original.is_reversed = True
    original.reversed_by = reversal
    original.save(update_fields=["is_reversed", "reversed_by"])

    # Undo balance effects
    if original.source_location:
        _adjust_balance(original.book, original.source_location, +original.quantity)
    if original.destination_location:
        _adjust_balance(original.book, original.destination_location, -original.quantity)
    _sync_book_stock(original.book)

    _audit(user, AuditLog.ACTION_REVERSE, original,
           new={"reversal_id": reversal.pk})
    return reversal


# ---------------------------------------------------------------------------
# Reporting helpers
# ---------------------------------------------------------------------------

def get_stock_summary():
    """Return a queryset of all InventoryBalance rows with related data."""
    return (
        InventoryBalance.objects
        .select_related("book", "book__author", "location")
        .filter(location__is_active=True)
        .order_by("book__title", "location__name")
    )


def get_low_stock_books():
    """
    Return books where total on-hand stock ≤ book.reorder_level.
    Only includes books with track_inventory=True.
    """
    from django.db.models import OuterRef, Subquery
    books = (
        Book.objects
        .filter(track_inventory=True)
        .annotate(
            on_hand=Sum(
                "inventory_balances__quantity_on_hand",
                default=0,
            )
        )
        .filter(on_hand__lte=F("reorder_level"))
        .select_related("author")
        .order_by("on_hand")
    )
    return books


def get_inventory_valuation():
    """Return total value of stock on hand using selling_price_kes."""
    from django.db.models import ExpressionWrapper, FloatField
    balances = (
        InventoryBalance.objects
        .select_related("book", "location")
        .filter(location__is_active=True, quantity_on_hand__gt=0)
    )
    total = Decimal("0")
    for b in balances:
        price = b.book.selling_price_kes or b.book.price or Decimal("0")
        total += price * b.quantity_on_hand
    return total
