using System.Text.Json;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Persistence;
using foodmarket.Shared.Pos.V1;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace foodmarket.Api.Controllers.Pos;

/// <summary>API синхронизации с оффлайн-кассами (food-market.pos, WPF/Windows).
///
/// Эндпоинты:
/// • <c>GET /api/pos/v1/sync?since=ISO8601&amp;storeId=…</c> — выгрузка изменений
///   с указанной reference time. Возвращает товары, цены, остатки на момент
///   ответа, контрагентов и список архивированных товаров.
/// • <c>POST /api/pos/v1/sales</c> — приём батча продаж от кассы.
///   <see cref="PosSaleBatchDto.IdempotencyKey"/> + per-sale ClientSaleId —
///   двойная идемпотентность: повтор того же батча возвращает тот же
///   результат без дублей в БД.
///
/// Multi-tenant: запросы идут под обычным OpenIddict JWT с claim
/// <c>org_id</c>. Дополнительно POS привязан к одному магазину через
/// <c>storeId</c> query/body — контроллер валидирует, что store
/// принадлежит организации.
///
/// Версионирование: префикс v1 в URL зафиксирован, добавления новых
/// необязательных полей в DTO не ломают v1; для breaking changes —
/// рядом будет v2.</summary>
[ApiController]
[Authorize]
[Route("api/pos/v1")]
public class PosController : ControllerBase
{
    private readonly AppDbContext _db;
    private readonly ITenantContext _tenant;
    private readonly IStockService _stock;
    private readonly ILogger<PosController> _log;

    public PosController(AppDbContext db, ITenantContext tenant, IStockService stock, ILogger<PosController> log)
    {
        _db = db;
        _tenant = tenant;
        _stock = stock;
        _log = log;
    }

    [HttpGet("sync")]
    public async Task<ActionResult<PosSyncResponse>> Sync(
        [FromQuery] DateTime? since,
        [FromQuery] Guid? storeId,
        CancellationToken ct)
    {
        var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
        var resolvedStore = await ResolveStoreAsync(storeId, ct);
        if (resolvedStore is null)
            return BadRequest(new { error = "Магазин не найден или не принадлежит организации.", field = "storeId" });

        var threshold = since ?? DateTime.MinValue.ToUniversalTime();
        var serverTime = DateTime.UtcNow;

        // Товары: дельта по UpdatedAt/CreatedAt. У записи Entity.UpdatedAt
        // может быть null если её ни разу не правили после CreatedAt; берём max.
        var products = await (from p in _db.Products.AsNoTracking()
                              join u in _db.UnitsOfMeasure.AsNoTracking() on p.UnitOfMeasureId equals u.Id
                              let stamp = p.UpdatedAt ?? p.CreatedAt
                              where stamp > threshold
                              select new { p, u, stamp })
            .ToListAsync(ct);

        var productIds = products.Select(x => x.p.Id).ToList();
        var barcodes = await _db.ProductBarcodes.AsNoTracking()
            .Where(b => productIds.Contains(b.ProductId))
            .OrderByDescending(b => b.IsPrimary)
            .Select(b => new { b.ProductId, b.Code })
            .ToListAsync(ct);
        var barcodesByProduct = barcodes.GroupBy(b => b.ProductId)
            .ToDictionary(g => g.Key, g => (IReadOnlyList<string>)g.Select(x => x.Code).ToList());

        var productDtos = products.Select(x => new ProductSyncDto
        {
            Id = x.p.Id,
            Name = x.p.Name,
            Article = x.p.Article,
            Barcodes = barcodesByProduct.TryGetValue(x.p.Id, out var b) ? b : Array.Empty<string>(),
            UnitCode = x.u.Code,
            Packaging = (int)x.p.Packaging,
            VatPercent = x.p.Vat,
            VatEnabled = x.p.VatEnabled,
            IsMarked = x.p.IsMarked,
            // Product не имеет IsArchived в текущей доменной модели; всегда false.
            // Когда добавим архив — заполняется здесь, без правки контракта.
            IsArchived = false,
            UpdatedAt = x.stamp,
        }).ToList();

        // Цены: всё что изменилось после since. PriceType подтягиваем чтобы
        // POS мог выбрать «системный».
        var prices = await (from pp in _db.ProductPrices.AsNoTracking()
                            join pt in _db.PriceTypes.AsNoTracking() on pp.PriceTypeId equals pt.Id
                            join cu in _db.Currencies.AsNoTracking() on pp.CurrencyId equals cu.Id
                            let stamp = pp.UpdatedAt ?? pp.CreatedAt
                            where stamp > threshold
                            select new PriceSyncDto
                            {
                                ProductId = pp.ProductId,
                                PriceTypeId = pt.Id,
                                PriceTypeName = pt.Name,
                                IsSystem = pt.IsSystem,
                                Amount = pp.Amount,
                                CurrencyCode = cu.Code,
                                UpdatedAt = stamp,
                            }).ToListAsync(ct);

        // Остатки: всегда полный снимок на момент ответа. since-фильтр не
        // применяется к остаткам — кассе всегда нужен актуальный stock.
        var stocks = await (from s in _db.Stocks.AsNoTracking()
                            where s.StoreId == resolvedStore.Value && s.Quantity != 0m
                            select new StockSyncDto
                            {
                                ProductId = s.ProductId,
                                StoreId = s.StoreId,
                                Quantity = s.Quantity,
                                AsOf = serverTime,
                            }).ToListAsync(ct);

        // Контрагенты-покупатели. Поставщиков на POS не шлём.
        var counterparties = await (from c in _db.Counterparties.AsNoTracking()
                                    let stamp = c.UpdatedAt ?? c.CreatedAt
                                    where stamp > threshold
                                    select new CounterpartySyncDto
                                    {
                                        Id = c.Id,
                                        Name = c.Name,
                                        Phone = c.Phone,
                                        Email = c.Email,
                                        Bin = c.Bin,
                                        Iin = c.Iin,
                                        UpdatedAt = stamp,
                                    }).ToListAsync(ct);

        // DeletedProductIds: в текущей модели Product hard-delete'ятся; для
        // soft-delete заведём отдельный feed; пока пустой массив.
        return Ok(new PosSyncResponse
        {
            ServerTime = serverTime,
            Products = productDtos,
            Prices = prices,
            Stocks = stocks,
            Counterparties = counterparties,
            DeletedProductIds = Array.Empty<Guid>(),
        });
    }

    [HttpPost("sales")]
    public async Task<ActionResult<PosSaleBatchResponse>> AcceptSales(
        [FromBody] PosSaleBatchDto batch,
        [FromQuery] Guid? storeId,
        CancellationToken ct)
    {
        if (batch is null || batch.Sales is null)
            return BadRequest(new { error = "Пустое тело батча.", field = "batch" });
        if (batch.IdempotencyKey == Guid.Empty)
            return BadRequest(new { error = "Идемпотентный ключ не указан.", field = "idempotencyKey" });

        var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
        var resolvedStore = await ResolveStoreAsync(storeId, ct);
        if (resolvedStore is null)
            return BadRequest(new { error = "Магазин не найден или не принадлежит организации.", field = "storeId" });

        // 1) Идемпотентность на уровне батча. Сначала проверяем cache.
        var existing = await _db.PosBatchAcks.AsNoTracking()
            .FirstOrDefaultAsync(a => a.IdempotencyKey == batch.IdempotencyKey, ct);
        if (existing is not null)
        {
            var prev = JsonSerializer.Deserialize<PosSaleBatchResponse>(existing.ResponseJson)!;
            return Ok(prev with { ReplayedFromCache = true });
        }

        // 2) Идемпотентность на уровне отдельной продажи: ищем уже созданные
        // RetailSale, у которых Notes начинается с маркера ClientSaleId.
        // (Альтернативой было бы добавить отдельную колонку ClientSaleId; для
        // минимизации миграций используем Notes-префикс — он уже nullable.)
        var clientSaleIds = batch.Sales.Select(s => s.ClientSaleId).Distinct().ToList();
        var markers = clientSaleIds.Select(g => $"pos:{g:N}").ToList();
        var alreadyByMarker = await _db.RetailSales.AsNoTracking()
            .Where(s => s.Notes != null && markers.Contains(s.Notes.Substring(0, Math.Min(36, s.Notes.Length))))
            .Select(s => new { s.Id, s.Number, s.Notes })
            .ToListAsync(ct);
        // Возвращает строки где Notes начинается с маркера; восстанавливаем csid.
        var alreadyByClient = alreadyByMarker
            .Where(x => x.Notes!.StartsWith("pos:"))
            .ToDictionary(
                x => Guid.ParseExact(x.Notes!.Substring(4, 32), "N"),
                x => (Id: x.Id, Number: x.Number));

        var accepted = new List<PosSaleAcceptedDto>();
        var failed = new List<PosSaleFailedDto>();

        foreach (var sale in batch.Sales)
        {
            if (alreadyByClient.TryGetValue(sale.ClientSaleId, out var prior))
            {
                accepted.Add(new PosSaleAcceptedDto
                {
                    ClientSaleId = sale.ClientSaleId,
                    ServerSaleId = prior.Id,
                    ServerSaleNumber = prior.Number,
                });
                continue;
            }

            try
            {
                var (serverSale, serverNumber) = await CreateAndPostSaleAsync(
                    sale, resolvedStore.Value, ct);
                accepted.Add(new PosSaleAcceptedDto
                {
                    ClientSaleId = sale.ClientSaleId,
                    ServerSaleId = serverSale,
                    ServerSaleNumber = serverNumber,
                });
            }
            catch (PosSaleRejectedException ex)
            {
                failed.Add(new PosSaleFailedDto
                {
                    ClientSaleId = sale.ClientSaleId,
                    Error = ex.Message,
                    Field = ex.Field,
                });
            }
        }

        // 3) Записываем ack под уникальным ключом. Если параллельный POS-запрос
        // успел нас обогнать — словим 23505 и вернём его ack.
        var response = new PosSaleBatchResponse
        {
            IdempotencyKey = batch.IdempotencyKey,
            Accepted = accepted,
            Failed = failed,
            ReplayedFromCache = false,
        };
        var ack = new PosBatchAck
        {
            IdempotencyKey = batch.IdempotencyKey,
            ResponseJson = JsonSerializer.Serialize(response),
        };
        _db.PosBatchAcks.Add(ack);
        try
        {
            await _db.SaveChangesAsync(ct);
        }
        catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23505")
        {
            // Конфликт по уникальному индексу — параллельный запрос успел
            // сохранить ack. Возвращаем тот ack как replay.
            var other = await _db.PosBatchAcks.AsNoTracking()
                .FirstAsync(a => a.IdempotencyKey == batch.IdempotencyKey, ct);
            var prev = JsonSerializer.Deserialize<PosSaleBatchResponse>(other.ResponseJson)!;
            return Ok(prev with { ReplayedFromCache = true });
        }
        return Ok(response);
    }

    /// <summary>Создаёт RetailSale + проводит, возвращая (id, number).
    /// Pre-flight: проверка остатков. При неудаче кидает <see cref="PosSaleRejectedException"/>,
    /// который ловится наверху и формирует Failed-запись батч-ответа.</summary>
    private async Task<(Guid Id, string Number)> CreateAndPostSaleAsync(
        PosSaleDto src, Guid storeId, CancellationToken ct)
    {
        if (src.Lines.Count == 0)
            throw new PosSaleRejectedException("Пустой чек.", "lines");

        // Pre-flight на остатки. Сделано до создания RetailSale чтобы зря не
        // дёргать БД при пустой полке.
        var byProduct = src.Lines.GroupBy(l => l.ProductId)
            .Select(g => new { ProductId = g.Key, Qty = g.Sum(x => x.Quantity) }).ToList();
        var productIds = byProduct.Select(x => x.ProductId).ToList();
        var stocks = await _db.Stocks
            .Where(s => s.StoreId == storeId && productIds.Contains(s.ProductId))
            .ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
        foreach (var x in byProduct)
        {
            stocks.TryGetValue(x.ProductId, out var avail);
            if (avail < x.Qty)
                throw new PosSaleRejectedException(
                    $"Недостаточный остаток для товара {x.ProductId}.", "productId");
        }

        var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
        decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);

        var currencyId = await _db.Currencies.AsNoTracking()
            .Where(c => c.Code == "KZT").Select(c => c.Id).FirstOrDefaultAsync(ct);
        if (currencyId == Guid.Empty)
            throw new PosSaleRejectedException("Не найдена валюта KZT.", "currency");

        var number = await GenerateNumberAsync(src.OccurredAt, ct);
        var sale = new RetailSale
        {
            Number = number,
            Date = src.OccurredAt,
            Status = RetailSaleStatus.Draft,
            StoreId = storeId,
            CurrencyId = currencyId,
            CustomerId = src.CustomerId,
            CashierUserId = src.CashierUserId,
            Payment = (PaymentMethod)src.Payment,
            PaidCash = R(src.PaidCash),
            PaidCard = R(src.PaidCard),
            // Маркер для per-sale идемпотентности (см. AcceptSales).
            Notes = $"pos:{src.ClientSaleId:N}" + (string.IsNullOrEmpty(src.Notes) ? "" : "\n" + src.Notes),
        };
        var order = 0;
        decimal subtotal = 0m, discountTotal = 0m;
        foreach (var l in src.Lines)
        {
            var price = R(l.UnitPrice);
            var disc = R(l.Discount);
            var lineTotal = l.Quantity * price - disc;
            _db.RetailSaleLines.Add(new RetailSaleLine
            {
                RetailSaleId = sale.Id,
                ProductId = l.ProductId,
                Quantity = l.Quantity,
                UnitPrice = price,
                Discount = disc,
                LineTotal = lineTotal,
                VatPercent = l.VatPercent,
                SortOrder = order++,
            });
            subtotal += l.Quantity * price;
            discountTotal += disc;
        }
        sale.Subtotal = subtotal;
        sale.DiscountTotal = discountTotal;
        sale.Total = subtotal - discountTotal;

        _db.RetailSales.Add(sale);

        // Проведение: -Quantity StockMovement RetailSale.
        foreach (var l in src.Lines)
        {
            await _stock.ApplyMovementAsync(new StockMovementDraft(
                ProductId: l.ProductId,
                StoreId: storeId,
                Quantity: -l.Quantity,
                Type: MovementType.RetailSale,
                DocumentType: "retail-sale",
                DocumentId: sale.Id,
                DocumentNumber: sale.Number,
                UnitCost: l.UnitPrice,
                OccurredAt: src.OccurredAt), ct);
        }
        sale.Status = RetailSaleStatus.Posted;
        sale.PostedAt = DateTime.UtcNow;
        try
        {
            await _db.SaveChangesAsync(ct);
        }
        catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23505")
        {
            // Уникальный конфликт по (OrgId, Number) — крайне редкий race на
            // GenerateNumberAsync; пересоздавать номер сложно без транзакции,
            // поэтому отказываем и POS попробует в следующем батче.
            foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementError("retail-sale", "number_conflict");
            throw new PosSaleRejectedException("Конфликт номера чека, повторите.", "number");
        }
        foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementPosted("retail-sale");
        return (sale.Id, sale.Number);
    }

    private async Task<Guid?> ResolveStoreAsync(Guid? requested, CancellationToken ct)
    {
        if (requested is { } id)
        {
            var ok = await _db.Stores.AsNoTracking().AnyAsync(s => s.Id == id, ct);
            return ok ? id : null;
        }
        // Дефолт — главный склад организации.
        var main = await _db.Stores.AsNoTracking()
            .Where(s => s.IsMain).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);
        if (main is not null) return main;
        // Иначе — любой активный.
        return await _db.Stores.AsNoTracking()
            .Where(s => s.IsActive).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);
    }

    private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken ct)
    {
        var prefix = $"ПР-{date.Year}-";
        var lastNumber = await _db.RetailSales
            .Where(s => s.Number.StartsWith(prefix))
            .OrderByDescending(s => s.Number)
            .Select(s => s.Number)
            .FirstOrDefaultAsync(ct);
        var seq = 1;
        if (lastNumber is not null && int.TryParse(lastNumber[prefix.Length..], out var last))
            seq = last + 1;
        return $"{prefix}{seq:D6}";
    }
}

/// <summary>Внутреннее исключение для отказа в продаже на уровне отдельного
/// чека: контроллер ловит и формирует строку Failed.</summary>
internal sealed class PosSaleRejectedException : Exception
{
    public string? Field { get; }
    public PosSaleRejectedException(string message, string? field) : base(message)
        => Field = field;
}
