using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.DTOs.Shared;
using ROLAC.API.Entities;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Disbursement;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Storage;

namespace ROLAC.API.Services;

public class DisbursementService : IDisbursementService
{
    private readonly AppDbContext _db;
    private readonly IHttpContextAccessor _http;
    private readonly IFileStorage _storage;
    private readonly ICheckPrintService _print;
    private readonly IAuditLogger _audit;

    public DisbursementService(AppDbContext db, IHttpContextAccessor http,
        IFileStorage storage, ICheckPrintService print, IAuditLogger audit)
    { _db = db; _http = http; _storage = storage; _print = print; _audit = audit; }

    // The JWT carries the user id in the "sub" claim (NameClaimType="sub"); NameIdentifier
    // is absent at runtime. Check NameIdentifier first (tests), then "sub" (real tokens).
    private string CurrentUserId =>
        _http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
        ?? _http.HttpContext?.User.FindFirstValue("sub")
        ?? "system";

    // ── Worklist: approved-unpaid expenses grouped by payee ──────────────────────
    public async Task<List<PayeeGroupDto>> GetApprovedUnpaidGroupedAsync()
    {
        var rows = await _db.Expenses.AsNoTracking().Where(e => e.Status == "Approved").ToListAsync();

        var minNames = await _db.Ministries.AsNoTracking().ToDictionaryAsync(m => m.Id, m => m.Name_en);
        var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => g.Name_en);
        var memberIds = rows.Where(r => r.MemberId != null).Select(r => r.MemberId!.Value).ToHashSet();
        var members = await _db.Members.AsNoTracking().Where(m => memberIds.Contains(m.Id)).ToDictionaryAsync(m => m.Id);

        // Category label per expense: the single line's category, or "Multiple" when it spans several.
        var expenseIds = rows.Select(r => r.Id).ToList();
        var lineGroups = await _db.ExpenseLines.AsNoTracking()
            .Where(l => expenseIds.Contains(l.ExpenseId))
            .OrderBy(l => l.Id)
            .Select(l => new { l.ExpenseId, l.CategoryGroupId })
            .ToListAsync();
        var categoryByExpense = lineGroups.GroupBy(l => l.ExpenseId).ToDictionary(
            g => g.Key,
            g => g.Select(l => l.CategoryGroupId).Distinct().Count() > 1
                ? "Multiple / 多類別"
                : grpNames.GetValueOrDefault(g.First().CategoryGroupId, ""));

        var groups = new Dictionary<string, PayeeGroupDto>();
        foreach (var e in rows)
        {
            PayeeGroupDto g;
            if (e.Type == "VendorPayment")
            {
                var vname = (e.VendorName ?? "").Trim();
                var vkey  = vname.ToLowerInvariant();
                var key   = "V:" + vkey;
                if (!groups.TryGetValue(key, out g!))
                {
                    g = new PayeeGroupDto { PayeeType = "Vendor", VendorKey = vkey, PayeeName = vname };
                    groups[key] = g;
                }
            }
            else
            {
                var mid = e.MemberId ?? 0;
                var key = "M:" + mid;
                if (!groups.TryGetValue(key, out g!))
                {
                    members.TryGetValue(mid, out var mem);
                    g = new PayeeGroupDto
                    {
                        PayeeType = "Member", MemberId = e.MemberId,
                        PayeeName = mem != null ? $"{mem.FirstName_en} {mem.LastName_en}" : "(Unknown member)",
                        Address = mem?.Address, City = mem?.City, State = mem?.State, Zip = mem?.ZipCode,
                    };
                    groups[key] = g;
                }
            }

            g.Lines.Add(new ExpenseLineDto
            {
                ExpenseId = e.Id, ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
                Description = e.Description, Amount = e.Amount,
                MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""),
                CategoryName = categoryByExpense.GetValueOrDefault(e.Id, ""),
            });
            g.TotalAmount += e.Amount;
        }

        return groups.Values.OrderBy(g => g.PayeeName).ToList();
    }

    // ── Issue checks (one per payee group) ──────────────────────────────────────
    public async Task<IssueChecksResultDto> IssueChecksAsync(IssueChecksRequest r)
    {
        var result = new IssueChecksResultDto();
        await using var tx = await _db.Database.BeginTransactionAsync();

        var profile = await _db.ChurchProfiles.OrderBy(x => x.Id).FirstOrDefaultAsync();
        if (profile is null)
        {
            profile = new ChurchProfile { Name = "Church", NextCheckNumber = 1001 };
            _db.ChurchProfiles.Add(profile);
        }

        foreach (var p in r.Payees)
        {
            var expenses = await _db.Expenses.Where(e => p.ExpenseIds.Contains(e.Id)).ToListAsync();
            if (expenses.Count != p.ExpenseIds.Distinct().Count())
                throw new KeyNotFoundException("One or more selected expenses no longer exist.");
            foreach (var e in expenses)
                if (e.Status != "Approved")
                    throw new InvalidOperationException($"Expense {e.Id} is not Approved (status '{e.Status}').");

            // Guard against double-payment: none of these may already sit on an issued check.
            var alreadyLinked = await (
                from l in _db.CheckLines
                join c in _db.Checks on l.CheckId equals c.Id
                where p.ExpenseIds.Contains(l.ExpenseId) && c.Status == "Issued"
                select l.Id).AnyAsync();
            if (alreadyLinked)
                throw new InvalidOperationException("One or more selected expenses are already on an issued check.");

            string checkNumber;
            if (!string.IsNullOrWhiteSpace(p.CheckNumberOverride))
                checkNumber = p.CheckNumberOverride.Trim();
            else
            {
                checkNumber = profile.NextCheckNumber.ToString();
                profile.NextCheckNumber++;
            }

            var amount = expenses.Sum(e => e.Amount);
            var paidAt = new DateTimeOffset(r.CheckDate.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero);
            var check = new Check
            {
                CheckNumber = checkNumber, CheckDate = r.CheckDate, Amount = amount,
                PayeeType = p.PayeeType, MemberId = p.PayeeType == "Member" ? p.MemberId : null,
                PayeeName = p.PayeeName, PayeeAddress = p.Address, PayeeCity = p.City,
                PayeeState = p.State, PayeeZip = p.Zip,
                Status = "Issued", Memo = p.Memo, IssuedBy = CurrentUserId, IssuedAt = DateTimeOffset.UtcNow,
            };
            foreach (var e in expenses)
            {
                check.Lines.Add(new CheckLine { ExpenseId = e.Id, Amount = e.Amount, Description = e.Description });
                e.Status = "Paid"; e.CheckNumber = checkNumber; e.PaidBy = CurrentUserId; e.PaidAt = paidAt;
            }
            _db.Checks.Add(check);

            try
            {
                await _db.SaveChangesAsync(); // assigns check.Id and consumes the number
            }
            catch (DbUpdateConcurrencyException)
            {
                throw new InvalidOperationException("Check numbering changed concurrently. Please retry.");
            }
            catch (DbUpdateException)
            {
                // The unique index on CheckNumber rejected a duplicate (e.g. an overridden number
                // that already exists, including a previously voided check that kept its number).
                throw new InvalidOperationException(
                    $"Check number '{checkNumber}' is already in use. Choose a different number.");
            }

            result.Created.Add(new IssuedCheckDto
            { CheckId = check.Id, CheckNumber = checkNumber, PayeeName = p.PayeeName, Amount = amount });

            _audit.Write(
                AuditActions.CheckIssued, AuditCategories.Business, LogLevelEnum.Information,
                entityName: nameof(Check), entityId: check.Id.ToString(),
                summary: $"Check #{checkNumber} issued to {p.PayeeName} — {amount:C}");
        }

        await tx.CommitAsync();
        return result;
    }

    // ── Check register ──────────────────────────────────────────────────────────
    public async Task<PagedResult<CheckListItemDto>> GetRegisterAsync(
        int page, int pageSize, string? status, string? search, DateOnly? from, DateOnly? to)
    {
        var q = _db.Checks.AsNoTracking().AsQueryable();
        if (!string.IsNullOrWhiteSpace(status)) q = q.Where(c => c.Status == status);
        if (from.HasValue) q = q.Where(c => c.CheckDate >= from.Value);
        if (to.HasValue)   q = q.Where(c => c.CheckDate <= to.Value);
        if (!string.IsNullOrWhiteSpace(search))
        {
            var s = search.Trim().ToLower();
            q = q.Where(c => c.CheckNumber.ToLower().Contains(s) || c.PayeeName.ToLower().Contains(s));
        }

        var total = await q.CountAsync();
        var rows = await q.OrderByDescending(c => c.IssuedAt).ThenByDescending(c => c.Id)
            .Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();

        var ids = rows.Select(c => c.Id).ToList();
        var counts = await _db.CheckLines.AsNoTracking().Where(l => ids.Contains(l.CheckId))
            .GroupBy(l => l.CheckId).Select(g => new { g.Key, C = g.Count() })
            .ToDictionaryAsync(x => x.Key, x => x.C);

        var items = rows.Select(c => ToListItem(c, counts.GetValueOrDefault(c.Id))).ToList();
        return new PagedResult<CheckListItemDto> { Items = items, TotalCount = total, Page = page, PageSize = pageSize };
    }

    public async Task<CheckDetailDto?> GetByIdAsync(int id)
    {
        var c = await _db.Checks.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
        if (c is null) return null;
        var lines = await _db.CheckLines.AsNoTracking().Where(l => l.CheckId == id).OrderBy(l => l.Id).ToListAsync();

        var dto = new CheckDetailDto
        {
            MemberId = c.MemberId, Address = c.PayeeAddress, City = c.PayeeCity, State = c.PayeeState,
            Zip = c.PayeeZip, Memo = c.Memo, VoidReason = c.VoidReason, VoidedAt = c.VoidedAt,
            IssuedAt = c.IssuedAt,
            Lines = lines.Select(l => new CheckLineDto
            { ExpenseId = l.ExpenseId, Description = l.Description, Amount = l.Amount }).ToList(),
        };
        CopyListFields(c, dto, lines.Count);
        return dto;
    }

    // ── Void (revert expenses to Approved) ──────────────────────────────────────
    public async Task VoidAsync(int id, string? reason)
    {
        await using var tx = await _db.Database.BeginTransactionAsync();
        var c = await _db.Checks.FirstOrDefaultAsync(x => x.Id == id)
            ?? throw new KeyNotFoundException($"Check {id} not found.");
        if (c.Status != "Issued") throw new InvalidOperationException($"Cannot void a check with status '{c.Status}'.");

        c.Status = "Voided"; c.VoidReason = reason; c.VoidedAt = DateTimeOffset.UtcNow; c.VoidedBy = CurrentUserId;

        var lines = await _db.CheckLines.Where(l => l.CheckId == id).ToListAsync();
        var expIds = lines.Select(l => l.ExpenseId).ToList();
        var exps = await _db.Expenses.Where(e => expIds.Contains(e.Id)).ToListAsync();
        foreach (var e in exps)
        {
            e.Status = "Approved"; e.CheckNumber = null; e.PaidAt = null; e.PaidBy = null;
        }
        await _db.SaveChangesAsync();
        await tx.CommitAsync();

        _audit.Write(
            AuditActions.CheckVoided, AuditCategories.Business, LogLevelEnum.Warning,
            entityName: nameof(Check), entityId: c.Id.ToString(),
            summary: $"Check #{c.CheckNumber} voided ({reason})");
    }

    // ── Receipt e-signature ─────────────────────────────────────────────────────
    public async Task AcknowledgeReceiptAsync(int id, Stream signature, string fileName, string signedName)
    {
        var c = await _db.Checks.FirstOrDefaultAsync(x => x.Id == id)
            ?? throw new KeyNotFoundException($"Check {id} not found.");
        if (c.Status != "Issued") throw new InvalidOperationException("Cannot sign a voided check.");
        if (c.ReceiptSignedAt is not null) throw new InvalidOperationException("This check has already been signed.");

        var ext = Path.GetExtension(fileName);
        if (string.IsNullOrWhiteSpace(ext)) ext = ".png";
        var path = $"finance/check-signatures/{c.CheckDate.Year}/{c.CheckDate.Month}/{c.Id}{ext}";
        var saved = await _storage.SaveAsync(signature, path);

        c.ReceiptSignatureBlobPath = saved;
        c.ReceiptSignedName = signedName;
        c.ReceiptSignedAt = DateTimeOffset.UtcNow;
        c.ReceiptCapturedBy = CurrentUserId;
        await _db.SaveChangesAsync();
    }

    public async Task<(Stream stream, string contentType)?> OpenSignatureAsync(int id)
    {
        var c = await _db.Checks.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id)
            ?? throw new KeyNotFoundException($"Check {id} not found.");
        if (c.ReceiptSignatureBlobPath is null) return null;
        var stream = await _storage.OpenReadAsync(c.ReceiptSignatureBlobPath);
        if (stream is null) return null;
        return (stream, SignatureContentType(c.ReceiptSignatureBlobPath));
    }

    // ── Render PDF ──────────────────────────────────────────────────────────────
    public async Task<(Stream stream, string contentType, string fileName)?> RenderPdfAsync(int id)
    {
        var c = await _db.Checks.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
        if (c is null) return null;

        var lines = await _db.CheckLines.AsNoTracking().Where(l => l.CheckId == id).OrderBy(l => l.Id).ToListAsync();
        var expIds = lines.Select(l => l.ExpenseId).ToList();
        var exps = await _db.Expenses.AsNoTracking().IgnoreQueryFilters()
            .Where(e => expIds.Contains(e.Id)).ToDictionaryAsync(e => e.Id);
        foreach (var l in lines) l.Expense = exps.GetValueOrDefault(l.ExpenseId);

        var profile = await _db.ChurchProfiles.AsNoTracking().OrderBy(x => x.Id).FirstOrDefaultAsync()
            ?? new ChurchProfile { Name = "Church" };

        var model = new CheckPrintModel
        {
            Issuer = profile, Check = c, Lines = lines, AmountInWords = AmountToWords.Convert(c.Amount),
        };
        var stream = await _print.RenderPdfAsync(model);
        return (stream, "application/pdf", $"check-{c.CheckNumber}.pdf");
    }

    // ── Render signed receipt PDF ────────────────────────────────────────────────
    public async Task<(Stream stream, string contentType, string fileName)?> RenderReceiptPdfAsync(int id)
    {
        var check = await _db.Checks.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
        if (check is null)
        {
            return null;
        }
        // A receipt only exists once the payee has signed.
        if (check.ReceiptSignedAt is null || check.ReceiptSignatureBlobPath is null)
        {
            return null;
        }

        var lines = await _db.CheckLines.AsNoTracking()
            .Where(line => line.CheckId == id).OrderBy(line => line.Id).ToListAsync();
        var expenseIds = lines.Select(line => line.ExpenseId).ToList();
        var expenses = await _db.Expenses.AsNoTracking().IgnoreQueryFilters()
            .Where(expense => expenseIds.Contains(expense.Id)).ToDictionaryAsync(expense => expense.Id);
        foreach (var line in lines)
        {
            line.Expense = expenses.GetValueOrDefault(line.ExpenseId);
        }

        var profile = await _db.ChurchProfiles.AsNoTracking().OrderBy(x => x.Id).FirstOrDefaultAsync()
            ?? new ChurchProfile { Name = "Church" };

        byte[]? signatureBytes = null;
        var signatureStream = await _storage.OpenReadAsync(check.ReceiptSignatureBlobPath);
        if (signatureStream is not null)
        {
            await using (signatureStream)
            {
                using var buffer = new MemoryStream();
                await signatureStream.CopyToAsync(buffer);
                signatureBytes = buffer.ToArray();
            }
        }

        var model = new CheckPrintModel
        {
            Issuer = profile,
            Check = check,
            Lines = lines,
            AmountInWords = AmountToWords.Convert(check.Amount),
            SignatureImage = signatureBytes,
            SignatureContentType = SignatureContentType(check.ReceiptSignatureBlobPath),
        };
        var stream = await _print.RenderReceiptPdfAsync(model);
        return (stream, "application/pdf", $"receipt-{check.CheckNumber}.pdf");
    }

    private static string SignatureContentType(string path)
    {
        var ext = Path.GetExtension(path).ToLowerInvariant();
        return ext switch
        {
            ".jpg" or ".jpeg" => "image/jpeg",
            ".webp" => "image/webp",
            _ => "image/png",
        };
    }

    // ── helpers ─────────────────────────────────────────────────────────────────
    private static CheckListItemDto ToListItem(Check c, int lineCount)
    {
        var dto = new CheckListItemDto();
        CopyListFields(c, dto, lineCount);
        return dto;
    }

    private static void CopyListFields(Check c, CheckListItemDto dto, int lineCount)
    {
        dto.Id = c.Id; dto.CheckNumber = c.CheckNumber; dto.CheckDate = c.CheckDate.ToString("yyyy-MM-dd");
        dto.Amount = c.Amount; dto.PayeeType = c.PayeeType; dto.PayeeName = c.PayeeName; dto.Status = c.Status;
        dto.LineCount = lineCount; dto.Signed = c.ReceiptSignedAt is not null;
        dto.ReceiptSignedName = c.ReceiptSignedName; dto.ReceiptSignedAt = c.ReceiptSignedAt;
    }
}
