using System.ComponentModel.DataAnnotations;
using foodmarket.Application.Common;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace foodmarket.Api.Controllers.Inventory;

/// <summary>Инвентаризация (пересчёт). Создание подгружает текущие остатки
/// склада в <c>bookQty</c>; пользователь вносит фактические количества;
/// при Post создаются корректирующие движения <see cref="MovementType.InventoryAdjustment"/>
/// на <c>diff = actual - book</c> (положительный — приход, отрицательный
/// — списание).</summary>
[ApiController]
[Authorize]
[Route("api/inventory/inventories")]
public class InventoriesController : ControllerBase
{
    private readonly AppDbContext _db;
    private readonly IStockService _stock;

    public InventoriesController(AppDbContext db, IStockService stock)
    {
        _db = db;
        _stock = stock;
    }

    public record InventoryListRow(
        Guid Id, string Number, DateTime Date, InventoryStatus Status,
        Guid StoreId, string StoreName,
        int LineCount,
        decimal SurplusValue, decimal ShortageValue,
        DateTime? PostedAt);

    public record InventoryLineDto(
        Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol,
        decimal BookQty, decimal ActualQty, decimal Diff, decimal UnitCost,
        int SortOrder);

    public record InventoryDto(
        Guid Id, string Number, DateTime Date, InventoryStatus Status,
        Guid StoreId, string StoreName,
        string? Notes,
        DateTime? PostedAt,
        IReadOnlyList<InventoryLineDto> Lines);

    public record InventoryLineInput(
        Guid ProductId,
        [Range(0, 1e10)] decimal ActualQty);

    public record InventoryInput(
        DateTime Date, Guid StoreId,
        string? Notes,
        /// <summary>Если null/пусто — контроллер сам заполнит строками всеми
        /// товарами склада с их текущим Stock в качестве bookQty и actual=0.</summary>
        IReadOnlyList<InventoryLineInput>? Lines);

    [HttpGet]
    public async Task<ActionResult<PagedResult<InventoryListRow>>> List(
        [FromQuery] PagedRequest req,
        [FromQuery] InventoryStatus? status,
        [FromQuery] Guid? storeId,
        CancellationToken ct)
    {
        var q = from i in _db.InventoryDocs.AsNoTracking()
                join st in _db.Stores on i.StoreId equals st.Id
                select new { i, st };

        if (status is not null) q = q.Where(x => x.i.Status == status);
        if (storeId is not null) q = q.Where(x => x.i.StoreId == storeId);
        if (!string.IsNullOrWhiteSpace(req.Search))
        {
            var s = req.Search.Trim().ToLower();
            q = q.Where(x => x.i.Number.ToLower().Contains(s));
        }

        var total = await q.CountAsync(ct);
        q = (req.Sort, req.Desc) switch
        {
            ("number", false) => q.OrderBy(x => x.i.Number),
            ("number", true) => q.OrderByDescending(x => x.i.Number),
            ("status", false) => q.OrderBy(x => x.i.Status).ThenByDescending(x => x.i.Date),
            ("status", true) => q.OrderByDescending(x => x.i.Status).ThenByDescending(x => x.i.Date),
            ("date", false) => q.OrderBy(x => x.i.Date).ThenBy(x => x.i.Number),
            _ => q.OrderByDescending(x => x.i.Date).ThenByDescending(x => x.i.Number),
        };
        var items = await q
            .Skip(req.Skip).Take(req.Take)
            .Select(x => new InventoryListRow(
                x.i.Id, x.i.Number, x.i.Date, x.i.Status,
                x.st.Id, x.st.Name,
                x.i.Lines.Count,
                x.i.Lines.Where(l => l.Diff > 0).Sum(l => l.Diff * l.UnitCost),
                x.i.Lines.Where(l => l.Diff < 0).Sum(l => l.Diff * l.UnitCost),
                x.i.PostedAt))
            .ToListAsync(ct);

        return new PagedResult<InventoryListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
    }

    [HttpGet("{id:guid}")]
    public async Task<ActionResult<InventoryDto>> Get(Guid id, CancellationToken ct)
    {
        var dto = await GetInternal(id, ct);
        return dto is null ? NotFound() : Ok(dto);
    }

    [HttpPost, RequiresPermission("InventoryEdit")]
    public async Task<ActionResult<InventoryDto>> Create([FromBody] InventoryInput input, CancellationToken ct)
    {
        if (RequiredGuid.FirstMissing((nameof(input.StoreId), input.StoreId)) is { } missing)
            return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });

        var number = await GenerateNumberAsync(input.Date, ct);
        var doc = new InventoryDoc
        {
            Number = number,
            Date = input.Date,
            Status = InventoryStatus.Draft,
            StoreId = input.StoreId,
            Notes = input.Notes,
        };

        // Если строки не указаны — подтягиваем все товары с ненулевым stock на складе.
        if (input.Lines is null || input.Lines.Count == 0)
        {
            var stocks = await (from s in _db.Stocks.AsNoTracking()
                                join p in _db.Products.AsNoTracking() on s.ProductId equals p.Id
                                where s.StoreId == input.StoreId
                                select new { s.ProductId, s.Quantity, p.Cost })
                .ToListAsync(ct);
            var order = 0;
            foreach (var st in stocks.OrderBy(x => x.ProductId))
            {
                doc.Lines.Add(new InventoryLine
                {
                    ProductId = st.ProductId,
                    BookQty = st.Quantity,
                    ActualQty = 0,
                    Diff = -st.Quantity,
                    UnitCost = st.Cost,
                    SortOrder = order++,
                });
            }
        }
        else
        {
            var productIds = input.Lines.Select(l => l.ProductId).Distinct().ToList();
            var book = await _db.Stocks.Where(s => s.StoreId == input.StoreId && productIds.Contains(s.ProductId))
                .ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
            var costs = await _db.Products.Where(p => productIds.Contains(p.Id))
                .ToDictionaryAsync(p => p.Id, p => p.Cost, ct);
            var order = 0;
            foreach (var l in input.Lines)
            {
                book.TryGetValue(l.ProductId, out var b);
                costs.TryGetValue(l.ProductId, out var c);
                doc.Lines.Add(new InventoryLine
                {
                    ProductId = l.ProductId,
                    BookQty = b,
                    ActualQty = l.ActualQty,
                    Diff = l.ActualQty - b,
                    UnitCost = c,
                    SortOrder = order++,
                });
            }
        }

        _db.InventoryDocs.Add(doc);
        if (await SaveOrFkErrorAsync(ct) is { } err) return err;
        var dto = await GetInternal(doc.Id, ct);
        return CreatedAtAction(nameof(Get), new { id = doc.Id }, dto);
    }

    private async Task<ActionResult?> SaveOrFkErrorAsync(CancellationToken ct)
    {
        try
        {
            await _db.SaveChangesAsync(ct);
            return null;
        }
        catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503")
        {
            var name = pg.ConstraintName ?? "";
            string field = name.Contains("Store") ? "storeId"
                         : name.Contains("Product") ? "productId"
                         : "(unknown)";
            return BadRequest(new { error = $"Связанная запись не найдена: {field}.", field, constraint = name });
        }
    }

    [HttpPut("{id:guid}"), RequiresPermission("InventoryEdit")]
    public async Task<IActionResult> Update(Guid id, [FromBody] InventoryInput input, CancellationToken ct)
    {
        var doc = await _db.InventoryDocs.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct);
        if (doc is null) return NotFound();
        if (doc.Status != InventoryStatus.Draft)
            return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });

        doc.Date = input.Date;
        doc.Notes = input.Notes;
        // StoreId на UPDATE не меняем — это пересчитало бы bookQty целиком.

        if (input.Lines is not null && input.Lines.Count > 0)
        {
            // Обновление actualQty по существующим строкам — in-place правка
            // (без Add/Remove на навигации, чтобы не словить EF8 bug на
            // nav-collection, см. фикс на Products/Enters/Losses.Update).
            var byProduct = doc.Lines.ToDictionary(l => l.ProductId);
            var existingCount = doc.Lines.Count;
            foreach (var ln in input.Lines)
            {
                if (byProduct.TryGetValue(ln.ProductId, out var existing))
                {
                    existing.ActualQty = ln.ActualQty;
                    existing.Diff = ln.ActualQty - existing.BookQty;
                }
                else
                {
                    // Новая строка — подгружаем book на момент изменения.
                    // Добавляем через DbSet, не через doc.Lines.Add, чтобы EF
                    // не путал nav-collection и xmin-токен на InventoryDoc.
                    var b = await _db.Stocks.Where(s => s.StoreId == doc.StoreId && s.ProductId == ln.ProductId)
                        .Select(s => (decimal?)s.Quantity).FirstOrDefaultAsync(ct) ?? 0m;
                    var c = await _db.Products.Where(p => p.Id == ln.ProductId).Select(p => p.Cost).FirstOrDefaultAsync(ct);
                    _db.InventoryLines.Add(new InventoryLine
                    {
                        InventoryDocId = doc.Id,
                        ProductId = ln.ProductId,
                        BookQty = b,
                        ActualQty = ln.ActualQty,
                        Diff = ln.ActualQty - b,
                        UnitCost = c,
                        SortOrder = existingCount++,
                    });
                }
            }
        }

        if (await SaveOrFkErrorAsync(ct) is { } err) return err;
        return NoContent();
    }

    [HttpDelete("{id:guid}"), RequiresPermission("InventoryEdit")]
    public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
    {
        var doc = await _db.InventoryDocs.FirstOrDefaultAsync(d => d.Id == id, ct);
        if (doc is null) return NotFound();
        if (doc.Status != InventoryStatus.Draft)
            return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." });
        _db.InventoryDocs.Remove(doc);
        await _db.SaveChangesAsync(ct);
        return NoContent();
    }

    [HttpPost("{id:guid}/post"), RequiresPermission("InventoryEdit")]
    public async Task<IActionResult> Post(Guid id, CancellationToken ct)
    {
        var doc = await _db.InventoryDocs.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct);
        if (doc is null) return NotFound();
        if (doc.Status == InventoryStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
        if (doc.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });

        var withDiff = doc.Lines.Where(l => l.Diff != 0m).ToList();
        if (withDiff.Count == 0)
            return BadRequest(new { error = "Нет расхождений учётного и фактического количества — нечего проводить." });

        await using var tx = await _db.Database.BeginTransactionAsync(
            System.Data.IsolationLevel.Serializable, ct);
        var now = DateTime.UtcNow;

        foreach (var line in withDiff)
        {
            await _stock.ApplyMovementAsync(new StockMovementDraft(
                ProductId: line.ProductId,
                StoreId: doc.StoreId,
                Quantity: line.Diff, // positive: surplus; negative: shortage
                Type: MovementType.InventoryAdjustment,
                DocumentType: "inventory",
                DocumentId: doc.Id,
                DocumentNumber: doc.Number,
                UnitCost: line.UnitCost,
                OccurredAt: doc.Date,
                Notes: line.Diff > 0 ? "surplus" : "shortage"), ct);
        }

        doc.Status = InventoryStatus.Posted;
        doc.PostedAt = now;
        try
        {
            await _db.SaveChangesAsync(ct);
            await tx.CommitAsync(ct);
        }
        catch (Exception ex) when (IsSerializationConflict(ex))
        {
            return Conflict(new { error = "Документ проводится параллельно другим запросом. Повторите попытку." });
        }
        return NoContent();
    }

    [HttpPost("{id:guid}/unpost"), RequiresPermission("InventoryEdit")]
    public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
    {
        var doc = await _db.InventoryDocs.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct);
        if (doc is null) return NotFound();
        if (doc.Status != InventoryStatus.Posted) return Conflict(new { error = "Документ не проведён." });

        // Reverse: для каждой строки c diff != 0 — обратное движение на -diff.
        // Защита от ухода в минус: если diff был положительный (излишек), при unpost
        // мы списываем (-diff = -surplus) — стоит проверить что эта величина в наличии.
        var positive = doc.Lines.Where(l => l.Diff > 0)
            .GroupBy(l => l.ProductId)
            .Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Diff) }).ToList();
        var productIds = positive.Select(x => x.ProductId).ToList();
        var stocks = await _db.Stocks
            .Where(s => s.StoreId == doc.StoreId && productIds.Contains(s.ProductId))
            .ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
        var conflicts = new List<object>();
        foreach (var r in positive)
        {
            stocks.TryGetValue(r.ProductId, out var available);
            if (available < r.Quantity)
            {
                var name = await _db.Products.Where(p => p.Id == r.ProductId)
                    .Select(p => p.Name).FirstOrDefaultAsync(ct);
                conflicts.Add(new
                {
                    productId = r.ProductId,
                    productName = name,
                    reverseQty = r.Quantity,
                    available,
                });
            }
        }
        if (conflicts.Count > 0)
        {
            return Conflict(new
            {
                error = "Нельзя отменить проведение: остаток уйдёт в минус (излишек уже расходован).",
                lines = conflicts,
            });
        }

        await using var tx = await _db.Database.BeginTransactionAsync(
            System.Data.IsolationLevel.Serializable, ct);

        foreach (var line in doc.Lines.Where(l => l.Diff != 0m))
        {
            await _stock.ApplyMovementAsync(new StockMovementDraft(
                ProductId: line.ProductId,
                StoreId: doc.StoreId,
                Quantity: -line.Diff,
                Type: MovementType.InventoryAdjustment,
                DocumentType: "inventory-reversal",
                DocumentId: doc.Id,
                DocumentNumber: doc.Number,
                UnitCost: line.UnitCost,
                OccurredAt: DateTime.UtcNow,
                Notes: $"Отмена проведения документа {doc.Number}"), ct);
        }

        doc.Status = InventoryStatus.Draft;
        doc.PostedAt = null;
        try
        {
            await _db.SaveChangesAsync(ct);
            await tx.CommitAsync(ct);
        }
        catch (Exception ex) when (IsSerializationConflict(ex))
        {
            return Conflict(new { error = "Документ обрабатывается параллельно. Повторите попытку." });
        }
        return NoContent();
    }

    private static bool IsSerializationConflict(Exception ex)
    {
        for (Exception? e = ex; e is not null; e = e.InnerException)
        {
            if (e is System.Data.Common.DbException { SqlState: "40001" or "40P01" })
                return true;
        }
        return false;
    }

    private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken ct)
    {
        var year = date.Year;
        var prefix = $"И-{year}-";
        var lastNumber = await _db.InventoryDocs
            .Where(i => i.Number.StartsWith(prefix))
            .OrderByDescending(i => i.Number)
            .Select(i => i.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}";
    }

    private async Task<InventoryDto?> GetInternal(Guid id, CancellationToken ct)
    {
        var row = await (from i in _db.InventoryDocs.AsNoTracking()
                         join st in _db.Stores on i.StoreId equals st.Id
                         where i.Id == id
                         select new { i, st }).FirstOrDefaultAsync(ct);
        if (row is null) return null;

        var lines = await (from l in _db.InventoryLines.AsNoTracking()
                           join p in _db.Products on l.ProductId equals p.Id
                           join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
                           where l.InventoryDocId == id
                           orderby l.SortOrder
                           select new InventoryLineDto(
                               l.Id, l.ProductId, p.Name, p.Article, u.Name,
                               l.BookQty, l.ActualQty, l.Diff, l.UnitCost, l.SortOrder))
                          .ToListAsync(ct);

        return new InventoryDto(
            row.i.Id, row.i.Number, row.i.Date, row.i.Status,
            row.st.Id, row.st.Name,
            row.i.Notes, row.i.PostedAt,
            lines);
    }
}
