using System.Globalization;
using System.Text.RegularExpressions;
using ClosedXML.Excel;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Master.Suppliers.Dtos;
using SolutionErp.Application.Master.Suppliers.Import;
using SolutionErp.Domain.Master;

namespace SolutionErp.Infrastructure.Services;

/// <summary>
/// Import NCC từ Excel "Database NCC" (Supplier Phase B, Approach A). Layout-locked THEO FILE NÀY.
/// ClosedXML (0.105, đã có). Đọc theo CHỈ SỐ CỘT TUYỆT ĐỐI (row.Cell(i)) — KHÔNG CellsUsed()
/// (bỏ ô rỗng → lệch cột). Header row = 4, data từ row 5.
///
/// 🔴 CRITICAL: dedup Code phải CASE-INSENSITIVE (unique-index SQL Server = CI). Nếu so Ordinal,
/// "truonggiang" (file) vs "TRUONGGIANG" (DB) → miss → quyết INSERT → confirm nổ unique-violation
/// → 500 cả batch. Dùng StringComparer.OrdinalIgnoreCase ở CẢ preview + confirm; store Code = .Trim()
/// giữ nguyên hoa/thường để hiển thị.
/// </summary>
public sealed class SupplierExcelImportService(
    IApplicationDbContext db,
    ILogger<SupplierExcelImportService> logger) : ISupplierExcelImportService
{
    private const int HeaderRow = 4;
    private const int DataStartRow = 5;
    private const int ColumnCount = 30;

    // ⚠️⚠️ MUST-VERIFY: header row-4 chuẩn của file "Database NCC" thật (đang ở máy anh Kiệt, KHÔNG
    // trong repo lúc scaffold). Đây là BEST-GUESS theo mapping cột. Khi upload thật lần đầu mà báo
    // "Sai layout", COPY chuỗi "Header nhận được" trong Warnings dán vào đây (mỗi token 1 cột, đúng
    // 30 phần tử). NormalizeHeader() sẽ upper + gộp-space nên viết thường/HOA đều được.
    // 30 token row-4 BYTE-EXACT theo file "Database NCC" thật (S113 re-bake, spec §②). Giữ NGUYÊN
    // ký tự \n trong ô + trailing-space (c7) → single-source cho CẢ validator (NormalizeHeader collapse
    // \n→space) LẪN BuildTemplate() (ghi \n vào cell = header xuống dòng giống hệt file gốc, 0 drift).
    private static readonly string[] ExpectedHeaderTokens =
    {
        "STT",                                                       // 1
        "GÓI THẦU",                                                  // 2  → PackageCategory
        "PHÂN LOẠI\n(NTP/NCC/Cả hai)",                               // 3  → Type
        "TÊN VIẾT TẮT\n(Dùng trong HĐ)",                             // 4  → Code (upsert key)
        "TÊN CÔNG TY\n(Đầy đủ, đúng pháp lý)",                       // 5  → Name
        "ĐỊA CHỈ XUẤT HÓA ĐƠN\n(Địa chỉ đăng ký kinh doanh)",       // 6  → Address
        "ĐỊA CHỈ VĂN PHÒNG \n(nếu có)",                              // 7  → OfficeAddress (trailing-space trước \n)
        "SỐ ĐIỆN THOẠI\nCÔNG TY",                                   // 8  → Phone
        "FAX",                                                       // 9  → Fax
        "SỐ TÀI KHOẢN+ TÊN+CN. NGÂN HÀNG\n(Đầy đủ, đúng pháp lý)",   // 10 → BankAccount
        "SỐ TK PHỤ\n(nếu có)",                                       // 11 → SecondaryBankAccount
        "MÃ SỐ THUẾ",                                                // 12 → TaxCode
        "NGƯỜI ĐẠI DIỆN\nPHÁP LUẬT",                                 // 13 → LegalRepresentative
        "CHỨC VỤ\nĐẠI DIỆN",                                         // 14 → LegalRepTitle
        "GIẤY ỦY QUYỀN\n(số, ngày, người ủy quyền)",                // 15 → AuthorizationNote
        "Link GUQ",                                                  // 16 → LinkGuq
        "Link GPKD",                                                 // 17 → LinkGpkd
        "Link HSNL",                                                 // 18 → LinkHsnl
        "NGƯỜI LIÊN HỆ\nCHÍNH",                                      // 19 → ContactPerson
        "CHỨC VỤ\nNGƯỜI LH",                                         // 20 → ContactTitle
        "SĐT CHÍNH",                                                 // 21 → ContactPhone
        "EMAIL",                                                     // 22 → Email
        "ĐỊA CHỈ GỬI THƯ",                                          // 23 → MailingAddress
        "NGƯỜI NHẬN THƯ/\nSDT",                                      // 24 → MailRecipient
        "NGUỒN\nGIỚI THIỆU",                                         // 25 → ReferralSource
        "NGƯỜI PHỤ TRÁCH\n(PMH)",                                    // 26 → OwnerPmh
        "TÌNH TRẠNG\nHIỆN TẠI",                                      // 27 → Status
        "GHI CHÚ / LÝ DO\nBLACKLIST",                                // 28 → Note
        "NGÀY CẬP NHẬT\nCUỐI",                                       // 29 → SourceUpdatedAt
        "NGƯỜI CẬP NHẬT",                                            // 30 → SourceUpdatedBy
    };

    private static readonly HashSet<string> ErrorLiterals = new(StringComparer.OrdinalIgnoreCase)
    { "#REF!", "#N/A", "#VALUE!", "#DIV/0!", "#NAME?", "#NULL!", "#NUM!" };

    private static readonly string[] DateFormats =
    { "dd/MM/yyyy", "d/M/yyyy", "dd/MM/yyyy HH:mm", "yyyy-MM-dd", "yyyy/MM/dd", "dd-MM-yyyy", "d-M-yyyy", "MM/dd/yyyy" };

    // MaxLength per field (khớp SupplierConfiguration — EF = source of truth).
    private const int MaxCode = 50, MaxName = 200, MaxTaxCode = 20, MaxPhone = 30, MaxEmail = 100,
        MaxAddress = 500, MaxContactPerson = 200, MaxNote = 1000, MaxPackageCategory = 300,
        MaxOfficeAddress = 500, MaxMailingAddress = 500, MaxFax = 50, MaxBankAccount = 500,
        MaxSecondaryBankAccount = 500, MaxLegalRepresentative = 200, MaxLegalRepTitle = 150,
        MaxAuthorizationNote = 1000, MaxLink = 1000, MaxContactTitle = 150, MaxContactPhone = 50,
        MaxMailRecipient = 200, MaxReferralSource = 300, MaxOwnerPmh = 150, MaxSourceUpdatedBy = 200;

    // ========================================================================
    // PREVIEW — parse + validate + classify. KHÔNG ghi DB.
    // ========================================================================
    public async Task<SupplierImportPreviewDto> PreviewAsync(Stream xlsx, CancellationToken ct = default)
    {
        var (layoutValid, rows, warnings) = ParseWorkbook(xlsx);
        var preview = new SupplierImportPreviewDto { Warnings = warnings, LayoutValid = layoutValid };
        if (!layoutValid) return preview;   // file bị từ chối (sai layout) — Rows rỗng

        // Load TẤT CẢ NCC hiện có → 2 CI dict (MST + Code). Dataset nhỏ ~vài chục, collation-independent.
        var existing = await db.Suppliers.AsNoTracking().ToListAsync(ct);
        var existingByCode = BuildCiIndex(existing);
        var existingByMst = BuildMstIndex(existing);

        foreach (var row in rows)
        {
            if (IsRowEmpty(row)) { row.Status = RowImportStatus.Skip; continue; }

            // v2: CHỈ Name bắt buộc. Blank Code KHÔNG còn là lỗi (= draft ẩn, publish tay sau).
            if (string.IsNullOrWhiteSpace(row.Name))
            {
                row.Status = RowImportStatus.Error;
                row.Messages.Add("Thiếu 'Tên nhà cung cấp' (Name) — bắt buộc.");
                continue;
            }

            var mstKey = NormalizeMst(row.TaxCode);
            if (mstKey is null)
            {
                row.MstMissing = true;                 // cảnh báo MỀM — KHÔNG chặn (R6 mềm)
                row.Messages.Add("⚠️ Chưa có MST — không kiểm tra được trùng, có thể bị trùng.");
            }

            var codeKey = string.IsNullOrWhiteSpace(row.Code) ? null : row.Code!.Trim();
            if (codeKey is null)
                row.Messages.Add("Chưa nhập Mã NCC — sẽ lưu nháp (ẩn), công bố sau.");

            // Dedup precedence: MST (ưu tiên) → Code (backstop) → New.
            if (mstKey is not null && existingByMst.TryGetValue(mstKey, out var exM))
            {
                row.Status = RowImportStatus.Update;
                row.ExistingSupplierId = exM.Id;
            }
            else if (codeKey is not null && existingByCode.TryGetValue(codeKey, out var exC))
            {
                row.Status = RowImportStatus.Update;
                row.ExistingSupplierId = exC.Id;
            }
            else
            {
                row.Status = RowImportStatus.New;
            }
        }

        preview.Rows = rows;
        preview.NewCount = rows.Count(x => x.Status == RowImportStatus.New);
        preview.UpdateCount = rows.Count(x => x.Status == RowImportStatus.Update);
        preview.SkipCount = rows.Count(x => x.Status == RowImportStatus.Skip);
        preview.ErrorCount = rows.Count(x => x.Status == RowImportStatus.Error);
        return preview;
    }

    // ========================================================================
    // CONFIRM — ALL-OR-NOTHING. Re-validate → hard-error abort / else upsert + 1 SaveChanges.
    // ========================================================================
    public async Task<SupplierImportResultDto> ConfirmAsync(
        IReadOnlyList<SupplierImportRowDto> rows, string actor, CancellationToken ct = default)
    {
        var result = new SupplierImportResultDto();

        // Pass 1 — hard-error scan (CHỈ Name; v2 blank Code = draft hợp lệ). Hàng rỗng = skip. Bất kỳ lỗi → abort.
        var hardErrors = new List<string>();
        foreach (var row in rows)
        {
            if (IsRowEmpty(row)) continue;
            if (string.IsNullOrWhiteSpace(row.Name))
                hardErrors.Add($"Dòng {row.RowIndex}: thiếu Tên nhà cung cấp.");
        }
        if (hardErrors.Count > 0)
        {
            result.Errors = hardErrors;
            result.Committed = false;   // commit nothing (all-or-nothing)
            return result;
        }

        // Load existing TRACKED (fill-nulls mutate trực tiếp). Query filter loại IsDeleted. 2 index: MST + Code.
        var existing = await db.Suppliers.ToListAsync(ct);
        var existingByCode = BuildCiIndex(existing);
        var existingByMst = BuildMstIndex(existing);
        var batchByCode = new Dictionary<string, Supplier>(StringComparer.OrdinalIgnoreCase);
        var batchByMst = new Dictionary<string, Supplier>(StringComparer.OrdinalIgnoreCase);
        var countedUpdates = new HashSet<Supplier>();   // existing đã tính Updated → tránh đếm 2 lần khi 2 dòng cùng match

        foreach (var row in rows)
        {
            if (IsRowEmpty(row)) { result.Skipped++; continue; }

            NormalizeLengths(row);              // safety-net: clamp maxlen (chống 500 nếu client bỏ qua preview)
            var codeKey = string.IsNullOrWhiteSpace(row.Code) ? null : row.Code!.Trim();
            var mstKey = NormalizeMst(row.TaxCode);

            // Resolve target theo precedence: existing-MST → existing-Code → batch-MST → batch-Code.
            Supplier? target = null;
            var isExisting = false;
            if (mstKey is not null && existingByMst.TryGetValue(mstKey, out var exM)) { target = exM; isExisting = true; }
            else if (codeKey is not null && existingByCode.TryGetValue(codeKey, out var exC)) { target = exC; isExisting = true; }
            else if (mstKey is not null && batchByMst.TryGetValue(mstKey, out var bM)) { target = bM; }
            else if (codeKey is not null && batchByCode.TryGetValue(codeKey, out var bC)) { target = bC; }

            if (target is not null)
            {
                FillNulls(target, row);         // #73-safe: chỉ điền field đang null, KHÔNG đè non-null (không đụng IsPublic)
                if (isExisting)
                {
                    if (countedUpdates.Add(target)) result.Updated++;
                    else result.Skipped++;      // existing đã tính Updated ở dòng trước → gộp, không đếm lại
                }
                else result.Skipped++;          // gộp vào bản ghi vừa thêm trong batch
                continue;
            }

            // New. Blank Code (thiếu Mã NCC) → lưu NHÁP IsPublic=false (R4); có Code → public-able.
            var entity = NewSupplier(row);
            entity.IsPublic = !string.IsNullOrWhiteSpace(row.Code);
            db.Suppliers.Add(entity);
            if (codeKey is not null) batchByCode[codeKey] = entity;
            if (mstKey is not null) batchByMst[mstKey] = entity;   // blank-MST KHÔNG vào batchByMst → 2 blank-MST = 2 insert
            result.Inserted++;
            if (!entity.IsPublic) result.DraftCount++;
        }

        await db.SaveChangesAsync(ct);          // single atomic commit (interceptor set CreatedBy/UpdatedBy)
        result.Committed = true;
        logger.LogInformation(
            "Supplier Excel import by {Actor}: inserted={Inserted}, updated={Updated}, skipped={Skipped}, draft={Draft}",
            actor, result.Inserted, result.Updated, result.Skipped, result.DraftCount);
        return result;
    }

    // ========================================================================
    // TEMPLATE — .xlsx mẫu trống (30 token header ROW 4, freeze row 4). Single-source ExpectedHeaderTokens (0 drift).
    // ========================================================================
    public byte[] BuildTemplate()
    {
        using var wb = new XLWorkbook();
        var ws = wb.Worksheets.Add("Sheet1");
        for (int c = 1; c <= ColumnCount; c++)
            ws.Cell(HeaderRow, c).Value = ExpectedHeaderTokens[c - 1];   // ROW 4; row 1-3 để trống (giống file gốc)
        var header = ws.Row(HeaderRow);
        header.Style.Font.Bold = true;
        header.Style.Alignment.WrapText = true;                          // \n trong ô hiện xuống dòng
        ws.SheetView.FreezeRows(HeaderRow);
        using var ms = new MemoryStream();
        wb.SaveAs(ms);
        return ms.ToArray();
    }

    // ========================================================================
    // PARSE
    // ========================================================================
    private static (bool LayoutValid, List<SupplierImportRowDto> Rows, List<string> Warnings) ParseWorkbook(Stream xlsx)
    {
        var warnings = new List<string>();
        XLWorkbook wb;
        try { wb = new XLWorkbook(xlsx); }
        catch (Exception ex)
        {
            warnings.Add("Không đọc được file (.xlsx). Chi tiết: " + ex.Message);
            return (false, new List<SupplierImportRowDto>(), warnings);
        }

        using (wb)
        {
            IXLWorksheet ws;
            try { ws = wb.Worksheet(1); }
            catch { warnings.Add("File Excel không có sheet nào."); return (false, new(), warnings); }

            // Header fingerprint — row 4, 30 cột tuyệt đối. Mismatch → từ chối (KHÔNG map để tránh sai cột).
            var actualTokens = new string[ColumnCount];
            for (int c = 1; c <= ColumnCount; c++)
                actualTokens[c - 1] = NormalizeHeader(ws.Cell(HeaderRow, c).GetString());
            var actual = string.Join(" | ", actualTokens);
            var expected = string.Join(" | ", ExpectedHeaderTokens.Select(NormalizeHeader));

            if (!string.Equals(actual, expected, StringComparison.Ordinal))
            {
                warnings.Add("Sai layout Excel — từ chối file (không map để tránh sai cột). "
                           + "Nếu đây LÀ file chuẩn, cập nhật ExpectedHeaderTokens theo 'Header nhận được' dưới đây.");
                warnings.Add("Header nhận được: " + actual);
                warnings.Add("Header mong đợi: " + expected);
                return (false, new(), warnings);
            }

            var rows = new List<SupplierImportRowDto>();
            int lastRow = ws.LastRowUsed()?.RowNumber() ?? (DataStartRow - 1);
            for (int r = DataStartRow; r <= lastRow; r++)
                rows.Add(MapRow(ws, r));
            return (true, rows, warnings);
        }
    }

    private static SupplierImportRowDto MapRow(IXLWorksheet ws, int r)
    {
        var m = new List<string>();
        return new SupplierImportRowDto
        {
            RowIndex = r,
            PackageCategory      = Clamp(ReadRaw(ws, r, 2), MaxPackageCategory, "Gói thầu", r, m),
            Type                 = MapType(ReadRaw(ws, r, 3), r, m),
            Code                 = Clamp(ReadRaw(ws, r, 4)?.Trim(), MaxCode, "Tên viết tắt", r, m),
            Name                 = Clamp(ReadRaw(ws, r, 5), MaxName, "Tên NCC", r, m),
            Address              = Clamp(ReadRaw(ws, r, 6), MaxAddress, "Địa chỉ", r, m),
            OfficeAddress        = Clamp(ReadRaw(ws, r, 7), MaxOfficeAddress, "Địa chỉ VP", r, m),
            Phone                = Clamp(ReadRaw(ws, r, 8), MaxPhone, "Điện thoại", r, m),
            Fax                  = Clamp(ReadRaw(ws, r, 9), MaxFax, "Fax", r, m),
            BankAccount          = Clamp(ReadRaw(ws, r, 10), MaxBankAccount, "Số TK", r, m),
            SecondaryBankAccount = Clamp(ReadRaw(ws, r, 11), MaxSecondaryBankAccount, "Số TK phụ", r, m),
            TaxCode              = Clamp(ReadRaw(ws, r, 12), MaxTaxCode, "MST", r, m),
            LegalRepresentative  = Clamp(ReadRaw(ws, r, 13), MaxLegalRepresentative, "Người đại diện", r, m),
            LegalRepTitle        = Clamp(ReadRaw(ws, r, 14), MaxLegalRepTitle, "Chức vụ đại diện", r, m),
            AuthorizationNote    = Clamp(ReadRaw(ws, r, 15), MaxAuthorizationNote, "Giấy ủy quyền", r, m),
            LinkGuq              = Clamp(ReadRaw(ws, r, 16), MaxLink, "Link GUQ", r, m),
            LinkGpkd             = Clamp(ReadRaw(ws, r, 17), MaxLink, "Link GPKD", r, m),
            LinkHsnl             = Clamp(ReadRaw(ws, r, 18), MaxLink, "Link HSNL", r, m),
            ContactPerson        = Clamp(ReadRaw(ws, r, 19), MaxContactPerson, "Người liên hệ", r, m),
            ContactTitle         = Clamp(ReadRaw(ws, r, 20), MaxContactTitle, "Chức vụ liên hệ", r, m),
            ContactPhone         = Clamp(ReadRaw(ws, r, 21), MaxContactPhone, "SĐT liên hệ", r, m),
            Email                = Clamp(ReadRaw(ws, r, 22), MaxEmail, "Email", r, m),
            MailingAddress       = Clamp(ReadRaw(ws, r, 23), MaxMailingAddress, "Địa chỉ gửi thư", r, m),
            MailRecipient        = Clamp(ReadRaw(ws, r, 24), MaxMailRecipient, "Người nhận thư", r, m),
            ReferralSource       = Clamp(ReadRaw(ws, r, 25), MaxReferralSource, "Nguồn giới thiệu", r, m),
            OwnerPmh             = Clamp(ReadRaw(ws, r, 26), MaxOwnerPmh, "Phụ trách", r, m),
            SupplierStatus       = MapStatus(ReadRaw(ws, r, 27)),
            Note                 = Clamp(ReadRaw(ws, r, 28), MaxNote, "Ghi chú", r, m),
            SourceUpdatedAt      = ReadDate(ws, r, 29),
            SourceUpdatedBy      = Clamp(ReadRaw(ws, r, 30), MaxSourceUpdatedBy, "Người cập nhật", r, m),
            Messages = m,
        };
    }

    // ========================================================================
    // CELL READERS — absolute index, error-literal → null, giữ RAW (không normalize) cho path/composite.
    // ========================================================================
    private static string? ReadRaw(IXLWorksheet ws, int row, int col)
    {
        var cell = ws.Cell(row, col);
        if (cell.IsEmpty()) return null;
        if (cell.DataType == XLDataType.Error) return null;
        var s = cell.GetString();                       // auto-decode shared-string
        if (string.IsNullOrWhiteSpace(s)) return null;
        if (ErrorLiterals.Contains(s.Trim())) return null;  // literal #REF! / #N/A / ...
        return s;                                        // RAW: giữ NAS backslash + composite (không trim/upper)
    }

    private static DateTime? ReadDate(IXLWorksheet ws, int row, int col)
    {
        var cell = ws.Cell(row, col);
        if (cell.IsEmpty() || cell.DataType == XLDataType.Error) return null;
        if (cell.DataType == XLDataType.DateTime && cell.TryGetValue<DateTime>(out var dt)) return dt;
        var s = cell.GetString();
        if (string.IsNullOrWhiteSpace(s) || ErrorLiterals.Contains(s.Trim())) return null;
        s = s.Trim();
        if (DateTime.TryParseExact(s, DateFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var ex)) return ex;
        if (DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var iv)) return iv;
        return null;                                     // unparseable → null (decision)
    }

    private static string NormalizeHeader(string? raw)
    {
        if (string.IsNullOrEmpty(raw)) return string.Empty;
        var collapsed = Regex.Replace(raw.Replace("\n", " ").Replace("\r", " "), @"\s+", " ");
        // .Normalize(FormC) — de-risk file Excel thật lưu dấu tiếng Việt dạng NFD (close-review S112):
        // đưa cả header kỳ-vọng lẫn header-thật về cùng dạng canonical trước khi so Ordinal.
        return collapsed.Trim().ToUpperInvariant().Normalize(System.Text.NormalizationForm.FormC);
    }

    // ========================================================================
    // FIELD MAPPING
    // ========================================================================
    private static SupplierType MapType(string? raw, int rowIndex, List<string> messages)
    {
        var t = (raw ?? string.Empty).Trim().ToLowerInvariant();
        if (t.Length == 0) return SupplierType.NhaCungCap;  // trống → default (decision 5), im lặng
        if (t.Contains("cả hai") || t.Contains("ca hai") || t.Contains("ntp/ncc") || t.Contains("ncc/ntp"))
            return SupplierType.CaHai;
        if (t == "ntp" || t.Contains("thầu phụ") || t.Contains("thau phu"))
            return SupplierType.NhaThauPhu;
        if (t == "ncc" || t.Contains("cung cấp") || t.Contains("cung cap"))
            return SupplierType.NhaCungCap;
        messages.Add($"Dòng {rowIndex}: PHÂN LOẠI '{raw}' không nhận dạng → mặc định NCC.");
        return SupplierType.NhaCungCap;                     // unknown → default (decision 5, không reject)
    }

    private static SupplierStatus? MapStatus(string? raw)
    {
        if (string.IsNullOrWhiteSpace(raw)) return null;
        var s = StripLeadingNonLetter(raw).Trim().ToLowerInvariant();
        if (s.Length == 0) return null;
        if (s.Contains("đang hoạt động") || s.Contains("dang hoat dong") || s.Contains("hoạt động") || s.Contains("hoat dong"))
            return SupplierStatus.DangHoatDong;
        if (s.Contains("blacklist") || s.Contains("black"))
            return SupplierStatus.Blacklist;
        if (s.Contains("ngừng") || s.Contains("ngung"))
            return SupplierStatus.NgungHopTac;
        return null;                                        // unknown → null (decision, không reject)
    }

    private static string StripLeadingNonLetter(string v)
    {
        int i = 0;
        while (i < v.Length && !char.IsLetter(v[i])) i++;   // char.IsLetter true cho chữ Việt Unicode
        return v.Substring(i);
    }

    private static string? Clamp(string? v, int max, string field, int rowIndex, List<string> messages)
    {
        if (v is null || v.Length <= max) return v;
        messages.Add($"Dòng {rowIndex}: '{field}' dài {v.Length} ký tự > {max} — đã cắt bớt.");
        return v.Substring(0, max);
    }

    private static void NormalizeLengths(SupplierImportRowDto r)
    {
        r.Code = Cut(r.Code, MaxCode); r.Name = Cut(r.Name, MaxName); r.TaxCode = Cut(r.TaxCode, MaxTaxCode);
        r.Phone = Cut(r.Phone, MaxPhone); r.Email = Cut(r.Email, MaxEmail); r.Address = Cut(r.Address, MaxAddress);
        r.ContactPerson = Cut(r.ContactPerson, MaxContactPerson); r.Note = Cut(r.Note, MaxNote);
        r.PackageCategory = Cut(r.PackageCategory, MaxPackageCategory); r.OfficeAddress = Cut(r.OfficeAddress, MaxOfficeAddress);
        r.MailingAddress = Cut(r.MailingAddress, MaxMailingAddress); r.Fax = Cut(r.Fax, MaxFax);
        r.BankAccount = Cut(r.BankAccount, MaxBankAccount); r.SecondaryBankAccount = Cut(r.SecondaryBankAccount, MaxSecondaryBankAccount);
        r.LegalRepresentative = Cut(r.LegalRepresentative, MaxLegalRepresentative); r.LegalRepTitle = Cut(r.LegalRepTitle, MaxLegalRepTitle);
        r.AuthorizationNote = Cut(r.AuthorizationNote, MaxAuthorizationNote); r.LinkGuq = Cut(r.LinkGuq, MaxLink);
        r.LinkGpkd = Cut(r.LinkGpkd, MaxLink); r.LinkHsnl = Cut(r.LinkHsnl, MaxLink);
        r.ContactTitle = Cut(r.ContactTitle, MaxContactTitle); r.ContactPhone = Cut(r.ContactPhone, MaxContactPhone);
        r.MailRecipient = Cut(r.MailRecipient, MaxMailRecipient); r.ReferralSource = Cut(r.ReferralSource, MaxReferralSource);
        r.OwnerPmh = Cut(r.OwnerPmh, MaxOwnerPmh); r.SourceUpdatedBy = Cut(r.SourceUpdatedBy, MaxSourceUpdatedBy);
    }

    private static string? Cut(string? v, int max) => v is not null && v.Length > max ? v.Substring(0, max) : v;

    // ========================================================================
    // UPSERT HELPERS
    // ========================================================================
    // FILL-NULLS (decision 4): chỉ set field ĐANG NULL trong DB, KHÔNG bao giờ đè non-null.
    // Bỏ Code/Name/Type (identity/required non-null) — mirror DbInitializer fill-nulls pattern.
    private static void FillNulls(Supplier e, SupplierImportRowDto r)
    {
        if (e.TaxCode is null && r.TaxCode is not null) e.TaxCode = r.TaxCode;
        if (e.Phone is null && r.Phone is not null) e.Phone = r.Phone;
        if (e.Email is null && r.Email is not null) e.Email = r.Email;
        if (e.Address is null && r.Address is not null) e.Address = r.Address;
        if (e.ContactPerson is null && r.ContactPerson is not null) e.ContactPerson = r.ContactPerson;
        if (e.Note is null && r.Note is not null) e.Note = r.Note;
        if (e.PackageCategory is null && r.PackageCategory is not null) e.PackageCategory = r.PackageCategory;
        if (e.OfficeAddress is null && r.OfficeAddress is not null) e.OfficeAddress = r.OfficeAddress;
        if (e.MailingAddress is null && r.MailingAddress is not null) e.MailingAddress = r.MailingAddress;
        if (e.Fax is null && r.Fax is not null) e.Fax = r.Fax;
        if (e.BankAccount is null && r.BankAccount is not null) e.BankAccount = r.BankAccount;
        if (e.SecondaryBankAccount is null && r.SecondaryBankAccount is not null) e.SecondaryBankAccount = r.SecondaryBankAccount;
        if (e.LegalRepresentative is null && r.LegalRepresentative is not null) e.LegalRepresentative = r.LegalRepresentative;
        if (e.LegalRepTitle is null && r.LegalRepTitle is not null) e.LegalRepTitle = r.LegalRepTitle;
        if (e.AuthorizationNote is null && r.AuthorizationNote is not null) e.AuthorizationNote = r.AuthorizationNote;
        if (e.LinkGuq is null && r.LinkGuq is not null) e.LinkGuq = r.LinkGuq;
        if (e.LinkGpkd is null && r.LinkGpkd is not null) e.LinkGpkd = r.LinkGpkd;
        if (e.LinkHsnl is null && r.LinkHsnl is not null) e.LinkHsnl = r.LinkHsnl;
        if (e.ContactTitle is null && r.ContactTitle is not null) e.ContactTitle = r.ContactTitle;
        if (e.ContactPhone is null && r.ContactPhone is not null) e.ContactPhone = r.ContactPhone;
        if (e.MailRecipient is null && r.MailRecipient is not null) e.MailRecipient = r.MailRecipient;
        if (e.ReferralSource is null && r.ReferralSource is not null) e.ReferralSource = r.ReferralSource;
        if (e.OwnerPmh is null && r.OwnerPmh is not null) e.OwnerPmh = r.OwnerPmh;
        if (e.Status is null && r.SupplierStatus is not null) e.Status = r.SupplierStatus;
        if (e.SourceUpdatedAt is null && r.SourceUpdatedAt is not null) e.SourceUpdatedAt = r.SourceUpdatedAt;
        if (e.SourceUpdatedBy is null && r.SourceUpdatedBy is not null) e.SourceUpdatedBy = r.SourceUpdatedBy;
    }

    private static Supplier NewSupplier(SupplierImportRowDto r) => new()
    {
        Code = (r.Code ?? string.Empty).Trim(), // null-safe (S113): ô Mã NCC trống → parser null → "" (nháp R4), tránh NRE-abort-batch; hoa/thường gốc giữ
        Name = r.Name!,
        Type = r.Type,
        TaxCode = r.TaxCode,
        Phone = r.Phone,
        Email = r.Email,
        Address = r.Address,
        ContactPerson = r.ContactPerson,
        Note = r.Note,
        PackageCategory = r.PackageCategory,
        OfficeAddress = r.OfficeAddress,
        MailingAddress = r.MailingAddress,
        Fax = r.Fax,
        BankAccount = r.BankAccount,
        SecondaryBankAccount = r.SecondaryBankAccount,
        LegalRepresentative = r.LegalRepresentative,
        LegalRepTitle = r.LegalRepTitle,
        AuthorizationNote = r.AuthorizationNote,
        LinkGuq = r.LinkGuq,
        LinkGpkd = r.LinkGpkd,
        LinkHsnl = r.LinkHsnl,
        ContactTitle = r.ContactTitle,
        ContactPhone = r.ContactPhone,
        MailRecipient = r.MailRecipient,
        ReferralSource = r.ReferralSource,
        OwnerPmh = r.OwnerPmh,
        Status = r.SupplierStatus,
        SourceUpdatedAt = r.SourceUpdatedAt,
        SourceUpdatedBy = r.SourceUpdatedBy,
    };

    private static Dictionary<string, Supplier> BuildCiIndex(IEnumerable<Supplier> suppliers)
    {
        var dict = new Dictionary<string, Supplier>(StringComparer.OrdinalIgnoreCase);
        foreach (var s in suppliers)
        {
            var key = s.Code?.Trim();
            if (string.IsNullOrEmpty(key)) continue;   // Code="" (draft) KHÔNG index → không dedup theo Code rỗng
            dict[key] = s;   // last-wins; dup CI không xảy ra do filtered-unique index
        }
        return dict;
    }

    // MST index cho dedup MST-primary (R5). NormalizeMst = Trim + bỏ MỌI whitespace nội, CI.
    // Blank MST → KHÔNG index (2 NCC không-MST không coi là trùng nhau).
    private static Dictionary<string, Supplier> BuildMstIndex(IEnumerable<Supplier> suppliers)
    {
        var dict = new Dictionary<string, Supplier>(StringComparer.OrdinalIgnoreCase);
        foreach (var s in suppliers)
        {
            var key = NormalizeMst(s.TaxCode);
            if (key is null) continue;
            dict[key] = s;   // last-wins (TaxCode KHÔNG có unique constraint — dup hiếm, không crash)
        }
        return dict;
    }

    // Chuẩn hóa MST: "0312 251 859" → "0312251859" (khớp dù file ghi có/không dấu cách). null nếu rỗng.
    private static string? NormalizeMst(string? raw)
    {
        if (string.IsNullOrWhiteSpace(raw)) return null;
        var stripped = Regex.Replace(raw, @"\s+", "");
        return stripped.Length == 0 ? null : stripped;
    }

    private static bool IsRowEmpty(SupplierImportRowDto r) =>
        string.IsNullOrWhiteSpace(r.Code) && string.IsNullOrWhiteSpace(r.Name) &&
        string.IsNullOrWhiteSpace(r.PackageCategory) && string.IsNullOrWhiteSpace(r.Address) &&
        string.IsNullOrWhiteSpace(r.OfficeAddress) && string.IsNullOrWhiteSpace(r.Phone) &&
        string.IsNullOrWhiteSpace(r.Fax) && string.IsNullOrWhiteSpace(r.BankAccount) &&
        string.IsNullOrWhiteSpace(r.SecondaryBankAccount) && string.IsNullOrWhiteSpace(r.TaxCode) &&
        string.IsNullOrWhiteSpace(r.LegalRepresentative) && string.IsNullOrWhiteSpace(r.LegalRepTitle) &&
        string.IsNullOrWhiteSpace(r.AuthorizationNote) && string.IsNullOrWhiteSpace(r.LinkGuq) &&
        string.IsNullOrWhiteSpace(r.LinkGpkd) && string.IsNullOrWhiteSpace(r.LinkHsnl) &&
        string.IsNullOrWhiteSpace(r.ContactPerson) && string.IsNullOrWhiteSpace(r.ContactTitle) &&
        string.IsNullOrWhiteSpace(r.ContactPhone) && string.IsNullOrWhiteSpace(r.Email) &&
        string.IsNullOrWhiteSpace(r.MailingAddress) && string.IsNullOrWhiteSpace(r.MailRecipient) &&
        string.IsNullOrWhiteSpace(r.ReferralSource) && string.IsNullOrWhiteSpace(r.OwnerPmh) &&
        string.IsNullOrWhiteSpace(r.Note) && string.IsNullOrWhiteSpace(r.SourceUpdatedBy) &&
        r.SupplierStatus is null && r.SourceUpdatedAt is null;
}
