using foodmarket.Application.Catalog;
using foodmarket.Application.Common;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Catalog;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace foodmarket.Api.Controllers.Catalog;

[ApiController]
[Authorize]
[Route("api/catalog/products")]
public class ProductsController : ControllerBase
{
    private readonly AppDbContext _db;
    private readonly ITenantContext _tenant;

    public ProductsController(AppDbContext db, ITenantContext tenant)
    {
        _db = db;
        _tenant = tenant;
    }

    // Проверка пересечения штрихкодов с другими товарами организации.
    // Возвращает первый конфликт «код → товар» либо null если всё чисто.
    /// <summary>Проверяет что у каждого PriceType с IsRequired=true есть
    /// соответствующая запись в input.Prices с Amount > 0. Возвращает имя
    /// первого нарушенного типа либо null если всё ок.</summary>
    private async Task<string?> FindMissingRequiredPriceAsync(
        IReadOnlyList<ProductPriceInput>? prices, CancellationToken ct)
    {
        var required = await _db.PriceTypes
            .Where(pt => pt.IsRequired)
            .Select(pt => new { pt.Id, pt.Name })
            .ToListAsync(ct);
        if (required.Count == 0) return null;
        foreach (var pt in required)
        {
            var price = prices?.FirstOrDefault(p => p.PriceTypeId == pt.Id);
            if (price is null || price.Amount <= 0m) return pt.Name;
        }
        return null;
    }

    private async Task<(string Code, string ProductName)?> FindBarcodeConflictAsync(
        IEnumerable<string> codes, Guid? excludeProductId, CancellationToken ct)
    {
        var codeSet = codes.Where(c => !string.IsNullOrWhiteSpace(c)).Distinct().ToList();
        if (codeSet.Count == 0) return null;
        var hit = await _db.ProductBarcodes
            .Where(b => codeSet.Contains(b.Code) && (excludeProductId == null || b.ProductId != excludeProductId))
            .Select(b => new { b.Code, ProductName = b.Product!.Name })
            .FirstOrDefaultAsync(ct);
        return hit is null ? null : (hit.Code, hit.ProductName);
    }

    // Округление цен под настройку AllowFractionalPrices.
    // Возвращает true если орг разрешает дробные цены.
    private async Task<bool> AllowFractionalAsync(CancellationToken ct)
    {
        var orgId = _tenant.OrganizationId;
        if (orgId is null) return true;
        return await _db.Organizations.Where(o => o.Id == orgId)
            .Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
    }
    private static decimal RoundIfNeeded(decimal value, bool allowFractional) =>
        allowFractional ? value : Math.Round(value, 0, MidpointRounding.AwayFromZero);
    private static decimal? RoundIfNeeded(decimal? value, bool allowFractional) =>
        value is null ? null : RoundIfNeeded(value.Value, allowFractional);

    // Следующий числовой артикул для организации. Находит max(Article::int)
    // среди артикулов, которые полностью состоят из цифр, и прибавляет 1.
    // Если числовых артикулов нет — возвращает "1".
    private async Task<string> GenerateNextArticleAsync(CancellationToken ct)
    {
        var articles = await _db.Products
            .Where(p => p.Article != null && p.Article != "")
            .Select(p => p.Article!)
            .ToListAsync(ct);
        var next = 1;
        foreach (var a in articles)
        {
            if (int.TryParse(a, out var n) && n >= next) next = n + 1;
        }
        return next.ToString();
    }

    // Дефолт Vat для нового товара — из страны организации (Country.VatRate).
    private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
    {
        var orgId = _tenant.OrganizationId;
        if (orgId is null) return 0m;
        var countryCode = await _db.Organizations
            .Where(o => o.Id == orgId)
            .Select(o => o.CountryCode)
            .FirstOrDefaultAsync(ct);
        if (string.IsNullOrEmpty(countryCode)) return 0m;
        var rate = await _db.Countries
            .Where(c => c.Code == countryCode)
            .Select(c => (decimal?)c.VatRate)
            .FirstOrDefaultAsync(ct);
        return rate ?? 0m;
    }

    [HttpGet]
    public async Task<ActionResult<PagedResult<ProductDto>>> List(
        [FromQuery] PagedRequest req,
        [FromQuery] Guid? groupId,
        [FromQuery] bool? isService,
        [FromQuery] Packaging? packaging,
        [FromQuery] bool? isMarked,
        [FromQuery] decimal? purchasePriceFrom,
        [FromQuery] decimal? purchasePriceTo,
        [FromQuery] decimal? referencePriceFrom,
        [FromQuery] decimal? referencePriceTo,
        [FromQuery] decimal? systemPriceFrom,
        [FromQuery] decimal? systemPriceTo,
        CancellationToken ct)
    {
        var q = QueryIncludes().AsNoTracking();
        if (groupId is not null)
        {
            // Include the whole subtree: match on the group's Path prefix so sub-groups also show up.
            var root = await _db.ProductGroups.AsNoTracking().FirstOrDefaultAsync(g => g.Id == groupId, ct);
            if (root is not null)
            {
                var prefix = root.Path;
                q = q.Where(p => p.ProductGroup != null &&
                    (p.ProductGroup.Path == prefix || p.ProductGroup.Path.StartsWith(prefix + "/")));
            }
            else
            {
                q = q.Where(p => p.ProductGroupId == groupId);
            }
        }
        if (isService is not null) q = q.Where(p => p.IsService == isService);
        if (packaging is not null) q = q.Where(p => p.Packaging == packaging);
        if (isMarked is not null) q = q.Where(p => p.IsMarked == isMarked);
        // referencePriceFrom/To — новый, актуальный параметр; purchasePriceFrom/To
        // — обратная совместимость c прежним UI (тоже по ReferencePrice).
        var refFrom = referencePriceFrom ?? purchasePriceFrom;
        var refTo = referencePriceTo ?? purchasePriceTo;
        if (refFrom is not null) q = q.Where(p => p.ReferencePrice >= refFrom);
        if (refTo is not null) q = q.Where(p => p.ReferencePrice <= refTo);
        // Фильтр по системной (главной розничной) цене — берём Prices c PriceType.IsSystem=true.
        if (systemPriceFrom is not null)
            q = q.Where(p => p.Prices.Any(pr => pr.PriceType!.IsSystem && pr.Amount >= systemPriceFrom));
        if (systemPriceTo is not null)
            q = q.Where(p => p.Prices.Any(pr => pr.PriceType!.IsSystem && pr.Amount <= systemPriceTo));

        if (!string.IsNullOrWhiteSpace(req.Search))
        {
            var s = req.Search.Trim().ToLower();
            q = q.Where(p =>
                p.Name.ToLower().Contains(s) ||
                (p.Article != null && p.Article.ToLower().Contains(s)) ||
                p.Barcodes.Any(b => b.Code.Contains(s)));
        }

        var total = await q.CountAsync(ct);
        q = (req.Sort, req.Desc) switch
        {
            ("article", false) => q.OrderBy(p => p.Article).ThenBy(p => p.Name),
            ("article", true) => q.OrderByDescending(p => p.Article).ThenBy(p => p.Name),
            ("group", false) => q.OrderBy(p => p.ProductGroup!.Name).ThenBy(p => p.Name),
            ("group", true) => q.OrderByDescending(p => p.ProductGroup!.Name).ThenBy(p => p.Name),
            ("unit", false) => q.OrderBy(p => p.UnitOfMeasure!.Name).ThenBy(p => p.Name),
            ("unit", true) => q.OrderByDescending(p => p.UnitOfMeasure!.Name).ThenBy(p => p.Name),
            ("packaging", false) => q.OrderBy(p => p.Packaging).ThenBy(p => p.Name),
            ("packaging", true) => q.OrderByDescending(p => p.Packaging).ThenBy(p => p.Name),
            ("purchasePrice", false) => q.OrderBy(p => p.ReferencePrice).ThenBy(p => p.Name),
            ("purchasePrice", true) => q.OrderByDescending(p => p.ReferencePrice).ThenBy(p => p.Name),
            ("cost", false) => q.OrderBy(p => p.Cost).ThenBy(p => p.Name),
            ("cost", true) => q.OrderByDescending(p => p.Cost).ThenBy(p => p.Name),
            ("systemPrice", false) => q.OrderBy(p => p.Prices.Where(pr => pr.PriceType!.IsSystem).Select(pr => (decimal?)pr.Amount).FirstOrDefault()).ThenBy(p => p.Name),
            ("systemPrice", true) => q.OrderByDescending(p => p.Prices.Where(pr => pr.PriceType!.IsSystem).Select(pr => (decimal?)pr.Amount).FirstOrDefault()).ThenBy(p => p.Name),
            ("vat", false) => q.OrderBy(p => p.Vat).ThenBy(p => p.Name),
            ("vat", true) => q.OrderByDescending(p => p.Vat).ThenBy(p => p.Name),
            ("name", true) => q.OrderByDescending(p => p.Name),
            _ => q.OrderBy(p => p.Name),
        };
        var items = await q
            .Skip(req.Skip).Take(req.Take)
            .Select(Projection)
            .ToListAsync(ct);
        return new PagedResult<ProductDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
    }

    [HttpGet("{id:guid}")]
    public async Task<ActionResult<ProductDto>> Get(Guid id, CancellationToken ct)
    {
        var p = await QueryIncludes().AsNoTracking().Where(x => x.Id == id).Select(Projection).FirstOrDefaultAsync(ct);
        return p is null ? NotFound() : p;
    }

    [HttpPost, Authorize(Roles = "Admin,Storekeeper")]
    public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input, CancellationToken ct)
    {
        if (input.Barcodes is null || input.Barcodes.Count == 0)
            return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
        if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing)
            return BadRequest(new { error = $"Цена «{missing}» обязательна и должна быть больше 0." });
        var conflict = await FindBarcodeConflictAsync(input.Barcodes.Select(b => b.Code), null, ct);
        if (conflict is { } c)
            return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." });
        var allowFractional = await AllowFractionalAsync(ct);
        var e = new Product();
        Apply(e, input);
        e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional);
        // Если UI скрывает поле Vat (showVatEnabledOnProduct=false) и прислал null — дефолт из страны.
        if (input.Vat is null) e.Vat = await ResolveDefaultVatAsync(ct);
        // Авто-артикул: если пользователь не указал — генерируем числовой.
        if (string.IsNullOrWhiteSpace(e.Article)) e.Article = await GenerateNextArticleAsync(ct);

        foreach (var b in input.Barcodes ?? [])
            e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary });
        foreach (var pr in input.Prices ?? [])
            e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = RoundIfNeeded(pr.Amount, allowFractional), CurrencyId = pr.CurrencyId });

        _db.Products.Add(e);
        try
        {
            await _db.SaveChangesAsync(ct);
        }
        catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("IX_products_OrganizationId_Article") == true)
        {
            return BadRequest(new { error = $"Артикул «{e.Article}» уже занят в этой организации." });
        }
        var dto = await GetInternalAsync(e.Id, ct);
        return CreatedAtAction(nameof(Get), new { id = e.Id }, dto);
    }

    [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
    public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input, CancellationToken ct)
    {
        if (input.Barcodes is null || input.Barcodes.Count == 0)
            return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
        if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing)
            return BadRequest(new { error = $"Цена «{missing}» обязательна и должна быть больше 0." });
        var conflict = await FindBarcodeConflictAsync(input.Barcodes.Select(b => b.Code), id, ct);
        if (conflict is { } c)
            return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." });
        var e = await _db.Products
            .Include(p => p.Barcodes)
            .Include(p => p.Prices)
            .FirstOrDefaultAsync(x => x.Id == id, ct);
        if (e is null) return NotFound();

        var allowFractional = await AllowFractionalAsync(ct);
        Apply(e, input);
        e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional);

        // Merge barcodes по Code: одинаковые — обновляем поля, лишние удаляем,
        // новые добавляем. Это позволяет избежать массового DELETE+INSERT, на
        // котором EF может выдать DbUpdateConcurrencyException, если какой-то
        // child был удалён параллельно из БД.
        var inputBarcodes = (input.Barcodes ?? []).ToList();
        var byCode = e.Barcodes.ToDictionary(b => b.Code, b => b);
        var inputCodes = inputBarcodes.Select(b => b.Code).ToHashSet();
        foreach (var existing in e.Barcodes.ToList())
            if (!inputCodes.Contains(existing.Code)) _db.ProductBarcodes.Remove(existing);
        foreach (var b in inputBarcodes)
        {
            if (byCode.TryGetValue(b.Code, out var ex))
            {
                ex.Type = b.Type;
                ex.IsPrimary = b.IsPrimary;
            }
            else
            {
                e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary });
            }
        }

        // Merge prices по PriceTypeId.
        var inputPrices = (input.Prices ?? []).ToList();
        var byPriceType = e.Prices.ToDictionary(p => p.PriceTypeId, p => p);
        var inputPriceTypes = inputPrices.Select(p => p.PriceTypeId).ToHashSet();
        foreach (var existing in e.Prices.ToList())
            if (!inputPriceTypes.Contains(existing.PriceTypeId)) _db.ProductPrices.Remove(existing);
        foreach (var pr in inputPrices)
        {
            var amount = RoundIfNeeded(pr.Amount, allowFractional);
            if (byPriceType.TryGetValue(pr.PriceTypeId, out var ex))
            {
                ex.Amount = amount;
                ex.CurrencyId = pr.CurrencyId;
            }
            else
            {
                e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = amount, CurrencyId = pr.CurrencyId });
            }
        }

        try
        {
            await _db.SaveChangesAsync(ct);
        }
        catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("IX_products_OrganizationId_Article") == true)
        {
            return BadRequest(new { error = $"Артикул «{e.Article}» уже занят в этой организации." });
        }
        catch (DbUpdateConcurrencyException)
        {
            return Conflict(new { error = "Товар изменён в другом окне или сессии. Перезагрузите страницу и попробуйте снова." });
        }
        return NoContent();
    }

    /// <summary>«Привести розничную к себестоимости»: ставит дефолтную
    /// розничную цену = ceil(Cost * (1 + Group.MarkupPercent/100)). Если у
    /// группы товара не задан MarkupPercent — 400 с подсказкой.</summary>
    [HttpPost("{id:guid}/recalc-retail"), Authorize(Roles = "Admin,Storekeeper")]
    public async Task<IActionResult> RecalcRetail(Guid id, CancellationToken ct)
    {
        var p = await _db.Products
            .Include(x => x.ProductGroup)
            .Include(x => x.Prices)
            .FirstOrDefaultAsync(x => x.Id == id, ct);
        if (p is null) return NotFound();
        if (p.ProductGroup?.MarkupPercent is not decimal pct)
            return BadRequest(new { error = "У группы не задана наценка. Задайте её в настройках или введите цену вручную." });

        var allowFractional = await AllowFractionalAsync(ct);
        var raw = p.Cost * (1m + pct / 100m);
        var newRetail = allowFractional
            ? Math.Ceiling(raw * 100m) / 100m
            : Math.Ceiling(raw);

        var defaultType = await _db.PriceTypes
            .OrderByDescending(pt => pt.IsSystem)
            .ThenByDescending(pt => pt.IsRetail)
            .ThenBy(pt => pt.SortOrder)
            .ThenBy(pt => pt.Name)
            .FirstOrDefaultAsync(ct);
        if (defaultType is null)
            return BadRequest(new { error = "Нет ни одного типа цен. Создайте его в настройках." });

        var fallbackCurrency = await _db.Organizations.Select(o => o.DefaultCurrencyId).FirstOrDefaultAsync(ct)
            ?? await _db.Currencies.OrderBy(c => c.Code).Select(c => (Guid?)c.Id).FirstOrDefaultAsync(ct);
        if (fallbackCurrency is null)
            return BadRequest(new { error = "Не задана валюта по умолчанию." });

        var existing = p.Prices.FirstOrDefault(x => x.PriceTypeId == defaultType.Id);
        if (existing is null)
        {
            p.Prices.Add(new ProductPrice
            {
                PriceTypeId = defaultType.Id,
                Amount = newRetail,
                CurrencyId = fallbackCurrency.Value,
            });
        }
        else
        {
            existing.Amount = newRetail;
        }
        await _db.SaveChangesAsync(ct);
        return Ok(new { retail = newRetail });
    }

    [HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
    public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
    {
        var e = await _db.Products.FirstOrDefaultAsync(x => x.Id == id, ct);
        if (e is null) return NotFound();
        _db.Products.Remove(e);
        await _db.SaveChangesAsync(ct);
        return NoContent();
    }

    public record BarcodeDuplicate(string Code, IReadOnlyList<DuplicateProductRef> Products);
    public record DuplicateProductRef(Guid ProductId, string ProductName, string? Article);

    /// <summary>Находит штрихкоды, привязанные к более чем одному товару в текущей
    /// организации. Уникальный индекс это запрещает в новых записях, но реальная
    /// БД может содержать исторические дубли (например, после ручной правки).
    /// Используется UI чистки (/admin/cleanup) и отчётом MoySklad-импорта.</summary>
    [HttpGet("barcode-duplicates"), Authorize(Roles = "Admin")]
    public async Task<ActionResult<IReadOnlyList<BarcodeDuplicate>>> BarcodeDuplicates(CancellationToken ct)
    {
        var rows = await _db.ProductBarcodes
            .GroupBy(b => b.Code)
            .Where(g => g.Count() > 1)
            .Select(g => new { Code = g.Key, Items = g.Select(x => new { x.ProductId, ProductName = x.Product!.Name, x.Product.Article }).ToList() })
            .ToListAsync(ct);
        var result = rows
            .Select(r => new BarcodeDuplicate(r.Code,
                r.Items.Select(i => new DuplicateProductRef(i.ProductId, i.ProductName, i.Article)).ToList()))
            .ToList();
        return result;
    }

    public record QuickSearchItem(
        Guid Id, string Name, string? Article, string? DefaultBarcode,
        decimal? ReferencePrice, decimal? StockQty);

    /// <summary>Лёгкий поиск для inline-добавления строк в документы (приёмка,
    /// продажа). Ранжирует точное совпадение штрихкода → точное артикула →
    /// префикс артикула → префикс имени → имя contains. Возвращает остаток
    /// по storeId если передан, иначе сумму по всем складам организации.</summary>
    [HttpGet("quick-search")]
    public async Task<ActionResult<IReadOnlyList<QuickSearchItem>>> QuickSearch(
        [FromQuery] string? search,
        [FromQuery] Guid? storeId,
        [FromQuery] int limit = 20,
        CancellationToken ct = default)
    {
        var s = (search ?? "").Trim();
        if (s.Length == 0) return Array.Empty<QuickSearchItem>();
        var sLower = s.ToLower();
        if (limit <= 0 || limit > 50) limit = 20;

        var q = _db.Products.AsNoTracking().Where(p =>
            p.Name.ToLower().Contains(sLower) ||
            (p.Article != null && p.Article.ToLower().Contains(sLower)) ||
            p.Barcodes.Any(b => b.Code.Contains(s)));

        // Ранжирование выводим в память: SQL'ом аккуратно сортировать по
        // нескольким булевым приоритетам сложно, а лимит 20–50 строк
        // делает накладные расходы пренебрежимыми.
        var raw = await q.Select(p => new
        {
            p.Id, p.Name, p.Article, p.ReferencePrice,
            Barcodes = p.Barcodes.Select(b => new { b.Code, b.IsPrimary }).ToList(),
            StockQty = storeId == null
                ? (decimal?)_db.Stocks.Where(st => st.ProductId == p.Id).Sum(st => (decimal?)st.Quantity)
                : _db.Stocks.Where(st => st.ProductId == p.Id && st.StoreId == storeId)
                    .Select(st => (decimal?)st.Quantity).FirstOrDefault(),
        }).Take(limit * 4).ToListAsync(ct);

        int Rank(string name, string? article, IEnumerable<string> codes)
        {
            if (codes.Any(c => c.Equals(s, StringComparison.OrdinalIgnoreCase))) return 0;
            if (article != null && article.Equals(s, StringComparison.OrdinalIgnoreCase)) return 1;
            if (article != null && article.StartsWith(s, StringComparison.OrdinalIgnoreCase)) return 2;
            if (name.StartsWith(s, StringComparison.OrdinalIgnoreCase)) return 3;
            return 4;
        }

        var items = raw
            .Select(r => new
            {
                Item = new QuickSearchItem(
                    r.Id, r.Name, r.Article,
                    r.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault(),
                    r.ReferencePrice, r.StockQty),
                Rank = Rank(r.Name, r.Article, r.Barcodes.Select(b => b.Code)),
            })
            .OrderBy(x => x.Rank).ThenBy(x => x.Item.Name)
            .Take(limit)
            .Select(x => x.Item)
            .ToList();
        return items;
    }

    public record ByBarcodeResult(IReadOnlyList<QuickSearchItem> Items);

    /// <summary>Точный поиск по штрихкоду (для сканера). 0 → 404, 1 → объект,
    /// несколько → { items: [...] } чтобы UI показал диалог выбора.</summary>
    [HttpGet("by-barcode/{value}")]
    public async Task<ActionResult<object>> ByBarcode(
        string value, [FromQuery] Guid? storeId, CancellationToken ct)
    {
        var v = (value ?? "").Trim();
        if (v.Length == 0) return NotFound();
        var matches = await _db.Products.AsNoTracking()
            .Where(p => p.Barcodes.Any(b => b.Code == v))
            .Select(p => new
            {
                p.Id, p.Name, p.Article, p.ReferencePrice,
                Barcodes = p.Barcodes.Select(b => new { b.Code, b.IsPrimary }).ToList(),
                StockQty = storeId == null
                    ? (decimal?)_db.Stocks.Where(st => st.ProductId == p.Id).Sum(st => (decimal?)st.Quantity)
                    : _db.Stocks.Where(st => st.ProductId == p.Id && st.StoreId == storeId)
                        .Select(st => (decimal?)st.Quantity).FirstOrDefault(),
            })
            .ToListAsync(ct);
        if (matches.Count == 0) return NotFound();
        var items = matches.Select(r => new QuickSearchItem(
            r.Id, r.Name, r.Article,
            r.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault() ?? v,
            r.ReferencePrice, r.StockQty)).ToList();
        if (items.Count == 1) return items[0];
        return new ByBarcodeResult(items);
    }

    private IQueryable<Product> QueryIncludes() => _db.Products
        .Include(p => p.UnitOfMeasure)
        .Include(p => p.ProductGroup)
        .Include(p => p.DefaultSupplier)
        .Include(p => p.CountryOfOrigin)
        .Include(p => p.PurchaseCurrency)
        .Include(p => p.Barcodes)
        .Include(p => p.Prices).ThenInclude(pr => pr.PriceType)
        .Include(p => p.Prices).ThenInclude(pr => pr.Currency);

    private async Task<ProductDto> GetInternalAsync(Guid id, CancellationToken ct) =>
        await QueryIncludes().AsNoTracking().Where(x => x.Id == id).Select(Projection).FirstAsync(ct);

    private static readonly System.Linq.Expressions.Expression<Func<Product, ProductDto>> Projection = p =>
        new ProductDto(
            p.Id, p.Name, p.Article, p.Description,
            p.UnitOfMeasureId, p.UnitOfMeasure!.Name,
            p.Vat, p.VatEnabled,
            p.ProductGroupId, p.ProductGroup!.Name,
            p.DefaultSupplierId, p.DefaultSupplier != null ? p.DefaultSupplier.Name : null,
            p.CountryOfOriginId, p.CountryOfOrigin != null ? p.CountryOfOrigin.Name : null,
            p.IsService, p.Packaging, p.IsMarked,
            p.MinStock, p.MaxStock,
            p.ReferencePrice, p.ReferencePriceUpdatedAt,
            p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null,
            p.Cost, p.LastSupplyAt,
            p.ImageUrl,
            p.Prices.Select(pr => new ProductPriceDto(pr.Id, pr.PriceTypeId, pr.PriceType!.Name, pr.Amount, pr.CurrencyId, pr.Currency!.Code)).ToList(),
            p.Barcodes.Select(b => new ProductBarcodeDto(b.Id, b.Code, b.Type, b.IsPrimary)).ToList());

    private static void Apply(Product e, ProductInput i)
    {
        e.Name = i.Name;
        e.Article = i.Article;
        e.Description = i.Description;
        e.UnitOfMeasureId = i.UnitOfMeasureId;
        if (i.Vat is decimal v) e.Vat = v;
        e.VatEnabled = i.VatEnabled;
        e.ProductGroupId = i.ProductGroupId;
        e.DefaultSupplierId = i.DefaultSupplierId;
        e.CountryOfOriginId = i.CountryOfOriginId;
        e.IsService = i.IsService;
        e.Packaging = i.Packaging;
        e.IsMarked = i.IsMarked;
        e.MinStock = i.MinStock;
        e.MaxStock = i.MaxStock;
        // ReferencePriceUpdatedAt подбиваем только при реальной смене цены
        // (включая переход с null на значение и обратно). Этим помечаем,
        // что цена «свежая», 30-дневный таймер автоперезаписи отсчитывается заново.
        if (e.ReferencePrice != i.ReferencePrice)
        {
            e.ReferencePrice = i.ReferencePrice;
            e.ReferencePriceUpdatedAt = i.ReferencePrice.HasValue ? DateTime.UtcNow : null;
        }
        e.PurchaseCurrencyId = i.PurchaseCurrencyId;
        e.ImageUrl = i.ImageUrl;
    }
}
