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>Списание (Loss): уменьшение склада с указанием причины
/// (брак/просрочка/повреждение/недостача/прочее). При проведении создаёт
/// <see cref="StockMovement"/> тип <see cref="MovementType.WriteOff"/> с
/// отрицательным Quantity. Unpost блокируется проверкой не нужно (мы
/// добавляем обратно — остаток всегда увеличится).</summary>
[ApiController]
[Authorize]
[Route("api/inventory/losses")]
public class LossesController : ControllerBase
{
    private readonly AppDbContext _db;
    private readonly IStockService _stock;

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

    public record LossListRow(
        Guid Id, string Number, DateTime Date, LossStatus Status, LossReason Reason,
        Guid StoreId, string StoreName,
        Guid CurrencyId, string CurrencyCode,
        decimal Total, int LineCount,
        DateTime? PostedAt);

    public record LossLineDto(
        Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle,
        string? UnitSymbol,
        decimal Quantity, decimal UnitCost, decimal LineTotal, int SortOrder,
        decimal? StockAtStore);

    public record LossDto(
        Guid Id, string Number, DateTime Date, LossStatus Status, LossReason Reason,
        Guid StoreId, string StoreName,
        Guid CurrencyId, string CurrencyCode,
        string? Notes,
        decimal Total, DateTime? PostedAt,
        IReadOnlyList<LossLineDto> Lines);

    public record LossLineInput(
        Guid ProductId,
        [Range(0, 1e10)] decimal Quantity,
        [Range(0, 1e10)] decimal UnitCost);

    public record LossInput(
        DateTime Date, Guid StoreId, Guid CurrencyId, LossReason Reason,
        string? Notes,
        IReadOnlyList<LossLineInput> Lines);

    [HttpGet]
    public async Task<ActionResult<PagedResult<LossListRow>>> List(
        [FromQuery] PagedRequest req,
        [FromQuery] LossStatus? status,
        [FromQuery] LossReason? reason,
        [FromQuery] Guid? storeId,
        CancellationToken ct)
    {
        var q = from l in _db.Losses.AsNoTracking()
                join st in _db.Stores on l.StoreId equals st.Id
                join cu in _db.Currencies on l.CurrencyId equals cu.Id
                select new { l, st, cu };

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

        var total = await q.CountAsync(ct);
        q = (req.Sort, req.Desc) switch
        {
            ("number", false) => q.OrderBy(x => x.l.Number),
            ("number", true) => q.OrderByDescending(x => x.l.Number),
            ("store", false) => q.OrderBy(x => x.st.Name).ThenByDescending(x => x.l.Date),
            ("store", true) => q.OrderByDescending(x => x.st.Name).ThenByDescending(x => x.l.Date),
            ("status", false) => q.OrderBy(x => x.l.Status).ThenByDescending(x => x.l.Date),
            ("status", true) => q.OrderByDescending(x => x.l.Status).ThenByDescending(x => x.l.Date),
            ("reason", false) => q.OrderBy(x => x.l.Reason).ThenByDescending(x => x.l.Date),
            ("reason", true) => q.OrderByDescending(x => x.l.Reason).ThenByDescending(x => x.l.Date),
            ("total", false) => q.OrderBy(x => x.l.Total).ThenByDescending(x => x.l.Date),
            ("total", true) => q.OrderByDescending(x => x.l.Total).ThenByDescending(x => x.l.Date),
            ("date", false) => q.OrderBy(x => x.l.Date).ThenBy(x => x.l.Number),
            _ => q.OrderByDescending(x => x.l.Date).ThenByDescending(x => x.l.Number),
        };
        var items = await q
            .Skip(req.Skip).Take(req.Take)
            .Select(x => new LossListRow(
                x.l.Id, x.l.Number, x.l.Date, x.l.Status, x.l.Reason,
                x.st.Id, x.st.Name,
                x.cu.Id, x.cu.Code,
                x.l.Total,
                x.l.Lines.Count,
                x.l.PostedAt))
            .ToListAsync(ct);

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

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

    [HttpPost, RequiresPermission("LossEdit")]
    public async Task<ActionResult<LossDto>> Create([FromBody] LossInput input, CancellationToken ct)
    {
        if (RequiredGuid.FirstMissing(
                (nameof(input.StoreId), input.StoreId),
                (nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
            return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
        if (input.Lines is null || input.Lines.Count == 0)
            return BadRequest(new { error = "Списание должно содержать хотя бы одну позицию." });

        var number = await GenerateNumberAsync(input.Date, ct);
        var loss = new Loss
        {
            Number = number,
            Date = input.Date,
            Status = LossStatus.Draft,
            StoreId = input.StoreId,
            CurrencyId = input.CurrencyId,
            Reason = input.Reason,
            Notes = input.Notes,
        };
        var order = 0;
        foreach (var l in input.Lines)
        {
            loss.Lines.Add(new LossLine
            {
                ProductId = l.ProductId,
                Quantity = l.Quantity,
                UnitCost = l.UnitCost,
                LineTotal = l.Quantity * l.UnitCost,
                SortOrder = order++,
            });
        }
        loss.Total = loss.Lines.Sum(x => x.LineTotal);

        _db.Losses.Add(loss);
        if (await SaveOrFkErrorAsync(ct) is { } err) return err;
        var dto = await GetInternal(loss.Id, ct);
        return CreatedAtAction(nameof(Get), new { id = loss.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("Currency") ? "currencyId"
                         : name.Contains("Product") ? "productId"
                         : "(unknown)";
            return BadRequest(new { error = $"Связанная запись не найдена: {field}.", field, constraint = name });
        }
    }

    [HttpPut("{id:guid}"), RequiresPermission("LossEdit")]
    public async Task<IActionResult> Update(Guid id, [FromBody] LossInput input, CancellationToken ct)
    {
        if (RequiredGuid.FirstMissing(
                (nameof(input.StoreId), input.StoreId),
                (nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
            return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
        if (input.Lines is null || input.Lines.Count == 0)
            return BadRequest(new { error = "Списание должно содержать хотя бы одну позицию." });
        var loss = await _db.Losses.Include(l => l.Lines).FirstOrDefaultAsync(l => l.Id == id, ct);
        if (loss is null) return NotFound();
        if (loss.Status != LossStatus.Draft)
            return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });

        loss.Date = input.Date;
        loss.StoreId = input.StoreId;
        loss.CurrencyId = input.CurrencyId;
        loss.Reason = input.Reason;
        loss.Notes = input.Notes;

        // ExecuteDelete + DbSet.Add — иначе EF8 на nav-collection путается и
        // UPDATE losses падает с DbUpdateConcurrencyException «0 rows affected»
        // даже без concurrency-токена. Тот же паттерн что в Supplies/Demands/RetailSales.Update.
        await _db.LossLines.Where(l => l.LossId == loss.Id).ExecuteDeleteAsync(ct);
        var order = 0;
        decimal total = 0;
        foreach (var l in input.Lines)
        {
            var lineTotal = l.Quantity * l.UnitCost;
            _db.LossLines.Add(new LossLine
            {
                LossId = loss.Id,
                ProductId = l.ProductId,
                Quantity = l.Quantity,
                UnitCost = l.UnitCost,
                LineTotal = lineTotal,
                SortOrder = order++,
            });
            total += lineTotal;
        }
        loss.Total = total;

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

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

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

        // Защита от ухода stock в минус.
        var byProduct = loss.Lines.GroupBy(l => l.ProductId)
            .Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) }).ToList();
        var productIds = byProduct.Select(x => x.ProductId).ToList();
        var stocks = await _db.Stocks
            .Where(s => s.StoreId == loss.StoreId && productIds.Contains(s.ProductId))
            .ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
        var conflicts = new List<object>();
        foreach (var r in byProduct)
        {
            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,
                    writeOffQty = 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);
        var now = DateTime.UtcNow;

        foreach (var line in loss.Lines)
        {
            await _stock.ApplyMovementAsync(new StockMovementDraft(
                ProductId: line.ProductId,
                StoreId: loss.StoreId,
                Quantity: -line.Quantity,
                Type: MovementType.WriteOff,
                DocumentType: "loss",
                DocumentId: loss.Id,
                DocumentNumber: loss.Number,
                UnitCost: line.UnitCost,
                OccurredAt: loss.Date,
                Notes: $"reason={loss.Reason}"), ct);
        }

        loss.Status = LossStatus.Posted;
        loss.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("LossEdit")]
    public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
    {
        var loss = await _db.Losses.Include(l => l.Lines).FirstOrDefaultAsync(l => l.Id == id, ct);
        if (loss is null) return NotFound();
        if (loss.Status != LossStatus.Posted) return Conflict(new { error = "Документ не проведён." });

        // Reverse: возвращаем товар обратно — остаток только увеличится, проверки на минус не нужно.
        foreach (var line in loss.Lines)
        {
            await _stock.ApplyMovementAsync(new StockMovementDraft(
                ProductId: line.ProductId,
                StoreId: loss.StoreId,
                Quantity: line.Quantity,
                Type: MovementType.WriteOff,
                DocumentType: "loss-reversal",
                DocumentId: loss.Id,
                DocumentNumber: loss.Number,
                UnitCost: line.UnitCost,
                OccurredAt: DateTime.UtcNow,
                Notes: $"Отмена проведения документа {loss.Number}"), ct);
        }

        loss.Status = LossStatus.Draft;
        loss.PostedAt = null;
        await _db.SaveChangesAsync(ct);
        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.Losses
            .Where(l => l.Number.StartsWith(prefix))
            .OrderByDescending(l => l.Number)
            .Select(l => l.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<LossDto?> GetInternal(Guid id, CancellationToken ct)
    {
        var row = await (from l in _db.Losses.AsNoTracking()
                         join st in _db.Stores on l.StoreId equals st.Id
                         join cu in _db.Currencies on l.CurrencyId equals cu.Id
                         where l.Id == id
                         select new { l, st, cu }).FirstOrDefaultAsync(ct);
        if (row is null) return null;

        var lines = await (from l in _db.LossLines.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.LossId == id
                           orderby l.SortOrder
                           select new LossLineDto(
                               l.Id, l.ProductId, p.Name, p.Article,
                               u.Name,
                               l.Quantity, l.UnitCost, l.LineTotal, l.SortOrder,
                               _db.Stocks.Where(s => s.ProductId == l.ProductId && s.StoreId == row.l.StoreId)
                                   .Select(s => (decimal?)s.Quantity).FirstOrDefault()))
                          .ToListAsync(ct);

        return new LossDto(
            row.l.Id, row.l.Number, row.l.Date, row.l.Status, row.l.Reason,
            row.st.Id, row.st.Name,
            row.cu.Id, row.cu.Code,
            row.l.Notes,
            row.l.Total, row.l.PostedAt,
            lines);
    }
}
