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

namespace ROLAC.API.Services;

public class ExpenseService : IExpenseService
{
    private readonly AppDbContext _db;
    private readonly IHttpContextAccessor _http;
    private readonly IFileStorage _storage;
    private readonly IAuditLogger _audit;

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

    // The JWT carries the user id in the "sub" claim (NameClaimType="sub", MapInboundClaims=false),
    // so ClaimTypes.NameIdentifier is absent at runtime. Check NameIdentifier first (unit tests set it),
    // then fall back to "sub" (real tokens). Required for the self-ownership guard to work in production.
    private string CurrentUserId =>
        _http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
        ?? _http.HttpContext?.User.FindFirstValue("sub")
        ?? "system";

    public async Task<PagedResult<ExpenseListItemDto>> GetPagedAsync(
        int page, int pageSize, string? search, int? ministryId,
        int? categoryGroupId, string? status, DateOnly? from, DateOnly? to,
        int? subCategoryId = null, string? statuses = null)
    {
        var query = _db.Expenses.AsNoTracking().AsQueryable();
        if (ministryId.HasValue)      query = query.Where(e => e.MinistryId == ministryId.Value);
        // Category filters now match against any line of the expense.
        if (categoryGroupId.HasValue) query = query.Where(e => e.Lines.Any(l => l.CategoryGroupId == categoryGroupId.Value));
        if (subCategoryId.HasValue)   query = query.Where(e => e.Lines.Any(l => l.SubCategoryId == subCategoryId.Value));
        // `statuses` (comma-separated) takes precedence over single `status`; lets the dashboard
        // request the Paid+Approved set in one call.
        if (!string.IsNullOrWhiteSpace(statuses))
        {
            var set = statuses.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
            query = query.Where(e => set.Contains(e.Status));
        }
        else if (!string.IsNullOrWhiteSpace(status))
        {
            query = query.Where(e => e.Status == status);
        }
        if (from.HasValue) query = query.Where(e => e.ExpenseDate >= from.Value);
        if (to.HasValue)   query = query.Where(e => e.ExpenseDate <= to.Value);
        if (!string.IsNullOrWhiteSpace(search))
        {
            var s = search.Trim().ToLower(); var term = search.Trim();
            query = query.Where(e =>
                e.Description.ToLower().Contains(s) ||
                (e.VendorName != null && e.VendorName.ToLower().Contains(s)) ||
                (e.CheckNumber != null && e.CheckNumber.ToLower().Contains(s)) ||
                (e.Member != null && (
                    (e.Member.FirstName_en + " " + e.Member.LastName_en).ToLower().Contains(s) ||
                    (e.Member.FirstName_zh != null && e.Member.FirstName_zh.Contains(term)) ||
                    (e.Member.LastName_zh  != null && e.Member.LastName_zh.Contains(term)))));
        }
        return await ProjectPagedAsync(query, page, pageSize);
    }

    public async Task<PagedResult<ExpenseListItemDto>> GetMineAsync(string userId, string? status, int page, int pageSize)
    {
        var query = _db.Expenses.AsNoTracking().Where(e => e.SubmittedBy == userId);
        if (!string.IsNullOrWhiteSpace(status)) query = query.Where(e => e.Status == status);
        return await ProjectPagedAsync(query, page, pageSize);
    }

    private async Task<PagedResult<ExpenseListItemDto>> ProjectPagedAsync(IQueryable<Expense> query, int page, int pageSize)
    {
        var total = await query.CountAsync();
        var rows = await query
            .OrderByDescending(e => e.ExpenseDate).ThenByDescending(e => e.Id)
            .Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();

        var minNames = await _db.Ministries.AsNoTracking().ToDictionaryAsync(m => m.Id, m => $"{m.Name_en} / {m.Name_zh}");
        var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => $"{g.Name_en} / {g.Name_zh}");
        var memberIds = rows.Where(r => r.MemberId != null).Select(r => r.MemberId!.Value).ToHashSet();
        var memberNames = await _db.Members.AsNoTracking().Where(m => memberIds.Contains(m.Id))
            .Select(m => new { m.Id, m.FirstName_en, m.LastName_en, m.NickName })
            .ToDictionaryAsync(
                m => m.Id,
                m => new MemberPayeeName($"{m.FirstName_en} {m.LastName_en}",
                                         BuildNickPayeeName(m.NickName, m.FirstName_en, m.LastName_en)));
        var reviewerNames = await ResolveUserNamesAsync(rows.Select(r => r.ReviewedBy));

        // Line count + first line's category, per expense on this page.
        var expenseIds = rows.Select(r => r.Id).ToList();
        var lineRows = await _db.ExpenseLines.AsNoTracking()
            .Where(l => expenseIds.Contains(l.ExpenseId))
            .OrderBy(l => l.Id)
            .Select(l => new { l.ExpenseId, l.CategoryGroupId })
            .ToListAsync();
        var linesByExpense = lineRows.GroupBy(l => l.ExpenseId)
            .ToDictionary(g => g.Key, g => g.ToList());

        var items = rows.Select(e =>
        {
            linesByExpense.TryGetValue(e.Id, out var ls);
            var firstGroupId = ls is { Count: > 0 } ? ls[0].CategoryGroupId : 0;
            return new ExpenseListItemDto
            {
                Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description,
                MinistryId = e.MinistryId, MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""),
                LineCount = ls?.Count ?? 0,
                PrimaryCategoryName = grpNames.GetValueOrDefault(firstGroupId, ""),
                VendorName = e.VendorName, MemberId = e.MemberId,
                MemberName = e.MemberId != null ? memberNames.GetValueOrDefault(e.MemberId.Value)?.Legal : null,
                MemberNickName = e.MemberId != null ? memberNames.GetValueOrDefault(e.MemberId.Value)?.Nick : null,
                ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
                HasReceipt = e.ReceiptBlobPath != null,
                CheckNumber = e.CheckNumber,
                ReviewedByName = e.ReviewedBy != null ? reviewerNames.GetValueOrDefault(e.ReviewedBy) : null,
                ReviewedAt = e.ReviewedAt,
                ReviewNotes = e.ReviewNotes,
                PayeeId = e.PayeeId,
            };
        }).ToList();

        return new PagedResult<ExpenseListItemDto> { Items = items, TotalCount = total, Page = page, PageSize = pageSize };
    }

    // Resolve actor user ids (AppUser.Id, stored in ReviewedBy/SubmittedBy/PaidBy) to a display name:
    // the linked Member's full name when present, otherwise the account email.
    private async Task<Dictionary<string, string>> ResolveUserNamesAsync(IEnumerable<string?> userIds)
    {
        var ids = userIds.Where(id => !string.IsNullOrEmpty(id)).Select(id => id!).Distinct().ToList();
        if (ids.Count == 0) return new Dictionary<string, string>();

        var users = await _db.Users.AsNoTracking()
            .Where(u => ids.Contains(u.Id))
            .Select(u => new { u.Id, u.Email, u.MemberId })
            .ToListAsync();

        var memberIds = users.Where(u => u.MemberId != null).Select(u => u.MemberId!.Value).ToHashSet();
        var memberNames = await _db.Members.AsNoTracking()
            .Where(m => memberIds.Contains(m.Id))
            .ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}".Trim());

        return users.ToDictionary(
            u => u.Id,
            u => u.MemberId != null && memberNames.TryGetValue(u.MemberId.Value, out var name) && name.Length > 0
                ? name
                : (u.Email ?? u.Id));
    }

    // Member payee names carried to the frontend: the legal name (printed on the check) and an
    // optional friendly "NickName LastName" line shown above it.
    private sealed record MemberPayeeName(string Legal, string? Nick);

    // Build the friendly "NickName LastName" payee line, or null when the member has no distinct
    // nickname (mirrors the frontend memberDisplayName rule: a nickname equal to the first name is not shown).
    private static string? BuildNickPayeeName(string? nickName, string firstNameEn, string lastNameEn)
    {
        bool hasDistinctNickName = !string.IsNullOrWhiteSpace(nickName) && nickName != firstNameEn;
        if (!hasDistinctNickName)
        {
            return null;
        }
        return $"{nickName} {lastNameEn}";
    }

    public async Task<ExpenseDto?> GetByIdAsync(int id)
    {
        var e = await _db.Expenses.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
        if (e is null) return null;
        var minName = await _db.Ministries.Where(m => m.Id == e.MinistryId).Select(m => m.Name_en).FirstOrDefaultAsync() ?? "";
        string? memberName     = null;
        string? memberNickName = null;
        if (e.MemberId != null)
        {
            var member = await _db.Members.AsNoTracking()
                .Where(m => m.Id == e.MemberId)
                .Select(m => new { m.FirstName_en, m.LastName_en, m.NickName })
                .FirstOrDefaultAsync();
            if (member != null)
            {
                memberName     = $"{member.FirstName_en} {member.LastName_en}";
                memberNickName = BuildNickPayeeName(member.NickName, member.FirstName_en, member.LastName_en);
            }
        }

        var reviewerName = e.ReviewedBy != null
            ? (await ResolveUserNamesAsync(new[] { e.ReviewedBy })).GetValueOrDefault(e.ReviewedBy)
            : null;

        var lines = await _db.ExpenseLines.AsNoTracking().Where(l => l.ExpenseId == id).OrderBy(l => l.Id).ToListAsync();
        var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => g.Name_en);
        var subNames = await _db.ExpenseSubCategories.AsNoTracking().ToDictionaryAsync(s => s.Id, s => s.Name_en);
        var lineDtos = lines.Select(l => new ExpenseLineItemDto
        {
            Id = l.Id, CategoryGroupId = l.CategoryGroupId, CategoryGroupName = grpNames.GetValueOrDefault(l.CategoryGroupId, ""),
            SubCategoryId = l.SubCategoryId, SubCategoryName = subNames.GetValueOrDefault(l.SubCategoryId, ""),
            FunctionalClass = l.FunctionalClass, Amount = l.Amount, Description = l.Description,
        }).ToList();

        return new ExpenseDto
        {
            Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description,
            MinistryId = e.MinistryId, MinistryName = minName,
            LineCount = lineDtos.Count,
            PrimaryCategoryName = lineDtos.Count > 0 ? lineDtos[0].CategoryGroupName : "",
            VendorName = e.VendorName, MemberId = e.MemberId, MemberName = memberName, MemberNickName = memberNickName,
            ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), HasReceipt = e.ReceiptBlobPath != null,
            CheckNumber = e.CheckNumber, Notes = e.Notes, ReviewNotes = e.ReviewNotes,
            ReviewedByName = reviewerName, ReviewedAt = e.ReviewedAt,
            SubmittedBy = e.SubmittedBy, SubmittedAt = e.SubmittedAt, PaidAt = e.PaidAt,
            PayeeId = e.PayeeId,
            Lines = lineDtos,
        };
    }

    // Lines are the source of truth: ≥1 line, each with a category/subcategory and a positive amount.
    private static void ValidateLines(List<ExpenseLineInput> lines)
    {
        if (lines is null || lines.Count == 0)
            throw new InvalidOperationException("An expense must have at least one line.");
        foreach (var l in lines)
        {
            if (l.CategoryGroupId <= 0 || l.SubCategoryId <= 0)
                throw new InvalidOperationException("Each expense line needs a category group and subcategory.");
            if (l.Amount <= 0)
                throw new InvalidOperationException("Each expense line amount must be greater than zero.");
            if (l.FunctionalClass is not null && !FunctionalClasses.All.Contains(l.FunctionalClass))
                throw new InvalidOperationException($"Invalid functional class '{l.FunctionalClass}'.");
        }
    }

    private static List<ExpenseLine> BuildLines(List<ExpenseLineInput> inputs) =>
        inputs.Select(l => new ExpenseLine
        {
            CategoryGroupId = l.CategoryGroupId, SubCategoryId = l.SubCategoryId,
            FunctionalClass = l.FunctionalClass, Amount = l.Amount, Description = l.Description,
        }).ToList();

    public async Task<int> CreateAsync(CreateExpenseRequest r, bool isFinance)
    {
        ValidateLines(r.Lines);
        var e = new Expense
        {
            MinistryId = r.MinistryId,
            Type = r.Type, Amount = r.Lines.Sum(l => l.Amount), Description = r.Description, VendorName = r.VendorName,
            CheckNumber = r.CheckNumber, ExpenseDate = r.ExpenseDate, Notes = r.Notes,
            Lines = BuildLines(r.Lines),
        };

        if (r.Type == "VendorPayment")
        {
            if (!isFinance) throw new InvalidOperationException("Only finance can create vendor payments.");
            // Enters the approval queue: PendingApproval -> Approve -> Pay (issue check).
            e.Status = "PendingApproval";
            e.SubmittedBy = CurrentUserId; e.SubmittedAt = DateTimeOffset.UtcNow;
            e.MemberId = null;
        }
        else // StaffReimbursement
        {
            // Distinguish the two flows by whether a member was explicitly picked, NOT by role:
            //  - On-behalf: finance picks a member (Expenses page) -> straight into the approval queue.
            //  - Self-service: no member picked ("My Reimbursements") -> link to the caller's own member
            //    so the Payee shows their legal name, and keep it a Draft until they explicitly Submit.
            // A finance/super_admin user files their own reimbursements through the self-service flow too,
            // so keying off the role alone would leave their entries with a null member (empty Payee).
            var isOnBehalf = isFinance && r.MemberId.HasValue;
            e.Status = isOnBehalf ? "PendingApproval" : "Draft";
            e.SubmittedBy = CurrentUserId;
            if (isOnBehalf) e.SubmittedAt = DateTimeOffset.UtcNow;
            e.MemberId = isOnBehalf ? r.MemberId : await CallerMemberIdAsync();
            e.VendorName = null;
        }

        e.PayeeId = r.PayeeId;
        _db.Expenses.Add(e);
        await _db.SaveChangesAsync();
        return e.Id;
    }

    private async Task<int?> CallerMemberIdAsync()
    {
        var uid = CurrentUserId;
        return await _db.Users.Where(u => u.Id == uid).Select(u => u.MemberId).FirstOrDefaultAsync();
    }

    public async Task UpdateAsync(int id, UpdateExpenseRequest r, bool isFinance)
    {
        ValidateLines(r.Lines);
        // FirstOrDefaultAsync (not FindAsync) so the soft-delete query filter applies.
        var e = await _db.Expenses.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id)
            ?? throw new KeyNotFoundException($"Expense {id} not found.");
        if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval" || e.Status == "Rejected")))
            throw new InvalidOperationException("You can only edit your own draft, pending, or rejected reimbursements.");

        e.MinistryId = r.MinistryId; e.Description = r.Description; e.CheckNumber = r.CheckNumber;
        e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes; e.PayeeId = r.PayeeId;
        if (e.Type == "VendorPayment") e.VendorName = r.VendorName;

        // Replace the line set wholesale (lines are owned by the header), recompute the total.
        _db.ExpenseLines.RemoveRange(e.Lines);
        e.Lines = BuildLines(r.Lines);
        e.Amount = r.Lines.Sum(l => l.Amount);
        await _db.SaveChangesAsync();
    }

    public async Task DeleteAsync(int id, bool isFinance)
    {
        var e = await _db.Expenses.FirstOrDefaultAsync(x => x.Id == id)
            ?? throw new KeyNotFoundException($"Expense {id} not found.");
        if (!isFinance && !(e.SubmittedBy == CurrentUserId && e.Status == "Draft"))
            throw new InvalidOperationException("You can only delete your own draft reimbursements.");
        e.IsDeleted = true; e.DeletedAt = DateTimeOffset.UtcNow; e.DeletedBy = CurrentUserId;
        await _db.SaveChangesAsync();
    }

    // FirstOrDefaultAsync (not FindAsync) so the soft-delete query filter applies.
    private async Task<Expense> RequireAsync(int id) =>
        await _db.Expenses.FirstOrDefaultAsync(x => x.Id == id)
            ?? throw new KeyNotFoundException($"Expense {id} not found.");

    public async Task SubmitAsync(int id)
    {
        var e = await RequireAsync(id);
        if (e.SubmittedBy != CurrentUserId) throw new InvalidOperationException("Only the submitter can submit this reimbursement.");
        // Draft (first submit) or Rejected (re-submit after fixing the flagged issue, e.g. a clearer receipt).
        if (e.Status != "Draft" && e.Status != "Rejected") throw new InvalidOperationException($"Cannot submit from status '{e.Status}'.");
        e.Status = "PendingApproval"; e.SubmittedAt = DateTimeOffset.UtcNow;
        // Clear the prior review so the expense returns to a clean pending state.
        e.ReviewedBy = null; e.ReviewedAt = null; e.ReviewNotes = null;
        await _db.SaveChangesAsync();
    }

    public async Task ApproveAsync(int id)
    {
        var e = await RequireAsync(id);
        if (e.Status != "PendingApproval") throw new InvalidOperationException($"Cannot approve from status '{e.Status}'.");
        e.Status = "Approved"; e.ReviewedBy = CurrentUserId; e.ReviewedAt = DateTimeOffset.UtcNow;
        await _db.SaveChangesAsync();

        _audit.Write(
            AuditActions.ExpenseApproved, AuditCategories.Business, LogLevelEnum.Information,
            entityName: nameof(Expense), entityId: e.Id.ToString(),
            summary: $"Expense #{e.Id} approved: {e.Description} — {e.Amount:C}");
    }

    public async Task RejectAsync(int id, string? reviewNotes)
    {
        var e = await RequireAsync(id);
        if (e.Status != "PendingApproval") throw new InvalidOperationException($"Cannot reject from status '{e.Status}'.");
        e.Status = "Rejected"; e.ReviewedBy = CurrentUserId; e.ReviewedAt = DateTimeOffset.UtcNow; e.ReviewNotes = reviewNotes;
        await _db.SaveChangesAsync();

        _audit.Write(
            AuditActions.ExpenseRejected, AuditCategories.Business, LogLevelEnum.Information,
            entityName: nameof(Expense), entityId: e.Id.ToString(),
            summary: $"Expense #{e.Id} rejected: {e.Description} — {reviewNotes}");
    }

    public async Task PayAsync(int id, string? checkNumber, DateOnly? paidAt)
    {
        var e = await RequireAsync(id);
        if (e.Status != "Approved") throw new InvalidOperationException($"Cannot mark paid from status '{e.Status}'.");
        e.Status = "Paid";
        if (!string.IsNullOrWhiteSpace(checkNumber)) e.CheckNumber = checkNumber;
        e.PaidBy = CurrentUserId;
        e.PaidAt = paidAt.HasValue
            ? new DateTimeOffset(paidAt.Value.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero)
            : DateTimeOffset.UtcNow;
        await _db.SaveChangesAsync();
    }

    public async Task SaveReceiptAsync(int id, Stream content, string fileName, bool isFinance)
    {
        var e = await RequireAsync(id);
        if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval" || e.Status == "Rejected")))
            throw new InvalidOperationException("You can only attach receipts to your own draft, pending, or rejected reimbursements.");

        var safe = Path.GetFileName(fileName).Replace(' ', '_');
        var path = $"finance/receipts/{e.ExpenseDate.Year}/{e.ExpenseDate.Month}/{e.Id}-{safe}";
        if (e.ReceiptBlobPath != null && e.ReceiptBlobPath != path)
            await _storage.DeleteAsync(e.ReceiptBlobPath);
        var saved = await _storage.SaveAsync(content, path);
        e.ReceiptBlobPath = saved;
        await _db.SaveChangesAsync();
    }

    public async Task<(Stream stream, string contentType)?> OpenReceiptAsync(int id, bool isFinance)
    {
        var e = await RequireAsync(id);
        if (!isFinance && e.SubmittedBy != CurrentUserId)
            throw new InvalidOperationException("Not authorized to view this receipt.");
        if (e.ReceiptBlobPath is null) return null;
        var stream = await _storage.OpenReadAsync(e.ReceiptBlobPath);
        if (stream is null) return null;
        var ext = Path.GetExtension(e.ReceiptBlobPath).ToLowerInvariant();
        var contentType = ext switch
        {
            ".png" => "image/png", ".webp" => "image/webp", ".pdf" => "application/pdf",
            _ => "image/jpeg",
        };
        return (stream, contentType);
    }
}
