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

namespace foodmarket.Api.Controllers.Purchases;

/// <summary>Оприходование (Enter) — постановка товара на склад без поставщика.
/// Используется при запуске учёта (начальные остатки) и для излишков
/// инвентаризации. Зеркалит <see cref="SuppliesController"/>, но проще:
/// нет SupplierId, нет пересчёта <c>Product.Cost</c> (UnitCost — балансовая
/// цена для отчёта).</summary>
[ApiController]
[Authorize]
[Route("api/inventory/enters")]
public class EntersController : ControllerBase
{
    private readonly AppDbContext _db;
    private readonly IStockService _stock;

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

    public record EnterListRow(
        Guid Id, string Number, DateTime Date, EnterStatus Status,
        Guid StoreId, string StoreName,
        Guid CurrencyId, string CurrencyCode,
        decimal Total, int LineCount,
        DateTime? PostedAt);

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

    public record EnterDto(
        Guid Id, string Number, DateTime Date, EnterStatus Status,
        Guid StoreId, string StoreName,
        Guid CurrencyId, string CurrencyCode,
        string? Notes,
        decimal Total, DateTime? PostedAt,
        IReadOnlyList<EnterLineDto> Lines);

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

    public record EnterInput(
        DateTime Date, Guid StoreId, Guid CurrencyId,
        string? Notes,
        IReadOnlyList<EnterLineInput> Lines);

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

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

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

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

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

    [HttpPost, RequiresPermission("EnterEdit")]
    public async Task<ActionResult<EnterDto>> Create([FromBody] EnterInput 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 enter = new Enter
        {
            Number = number,
            Date = input.Date,
            Status = EnterStatus.Draft,
            StoreId = input.StoreId,
            CurrencyId = input.CurrencyId,
            Notes = input.Notes,
        };
        var order = 0;
        foreach (var l in input.Lines)
        {
            enter.Lines.Add(new EnterLine
            {
                ProductId = l.ProductId,
                Quantity = l.Quantity,
                UnitCost = l.UnitCost,
                LineTotal = l.Quantity * l.UnitCost,
                SortOrder = order++,
            });
        }
        enter.Total = enter.Lines.Sum(x => x.LineTotal);

        _db.Enters.Add(enter);
        if (await SaveOrFkErrorAsync(ct) is { } err) return err;
        var dto = await GetInternal(enter.Id, ct);
        return CreatedAtAction(nameof(Get), new { id = enter.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("EnterEdit")]
    public async Task<IActionResult> Update(Guid id, [FromBody] EnterInput 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 enter = await _db.Enters.Include(e => e.Lines).FirstOrDefaultAsync(e => e.Id == id, ct);
        if (enter is null) return NotFound();
        if (enter.Status != EnterStatus.Draft)
            return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });

        enter.Date = input.Date;
        enter.StoreId = input.StoreId;
        enter.CurrencyId = input.CurrencyId;
        enter.Notes = input.Notes;

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

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

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

    [HttpPost("{id:guid}/post"), RequiresPermission("EnterEdit")]
    public async Task<IActionResult> Post(Guid id, CancellationToken ct)
    {
        var enter = await _db.Enters.Include(e => e.Lines).FirstOrDefaultAsync(e => e.Id == id, ct);
        if (enter is null) return NotFound();
        if (enter.Status == EnterStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
        if (enter.Lines.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 enter.Lines)
        {
            await _stock.ApplyMovementAsync(new StockMovementDraft(
                ProductId: line.ProductId,
                StoreId: enter.StoreId,
                Quantity: line.Quantity,
                Type: MovementType.Enter,
                DocumentType: "enter",
                DocumentId: enter.Id,
                DocumentNumber: enter.Number,
                UnitCost: line.UnitCost,
                OccurredAt: enter.Date), ct);
        }

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

        // Reverse: проверяем что текущий остаток позволяет «снять» оприходование
        // без ухода в минус (часть товара может быть уже продана).
        var reverseByProduct = enter.Lines
            .GroupBy(l => l.ProductId)
            .Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) })
            .ToList();
        var productIds = reverseByProduct.Select(r => r.ProductId).ToList();
        var stocks = await _db.Stocks
            .Where(s => s.StoreId == enter.StoreId && productIds.Contains(s.ProductId))
            .ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
        var conflicts = new List<object>();
        foreach (var r in reverseByProduct)
        {
            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,
            });
        }

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

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

        var lines = await (from l in _db.EnterLines.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.EnterId == id
                           orderby l.SortOrder
                           select new EnterLineDto(
                               l.Id, l.ProductId, p.Name, p.Article,
                               u.Name,
                               l.Quantity, l.UnitCost, l.LineTotal, l.SortOrder))
                          .ToListAsync(ct);

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