using System.Globalization;
using System.Text;
using ClosedXML.Excel;
using CsvHelper;
using CsvHelper.Configuration;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;

namespace ERPCore.Services.Hrm;

/// <summary>
/// Attendance upload/validate/confirm pipeline (FR-HR-ATT, docs/12-BACKEND-HRM.md §B.3.4).
/// Status flow is exactly Draft → Validated → Confirmed → UsedInPayroll; template and
/// parser share <see cref="ColumnNames"/> so they can never silently drift apart.
/// </summary>
public sealed class AttendanceUploadService : IAttendanceUploadService
{
    /// <summary>Employee Code | Date | Check In | Check Out — shared by the parser and the template generator.</summary>
    public static readonly string[] ColumnNames = { "Employee Code", "Date", "Check In", "Check Out" };

    private readonly IRepository<AttendanceUploadBatch> _batches;
    private readonly IRepository<AttendanceRecord> _records;
    private readonly IRepository<Employee> _employees;
    private readonly IRepository<WorkShift> _workShifts;
    private readonly INumberSequenceService _numberSequence;
    private readonly ILeaveRequestService _leaveRequests;
    private readonly IAttendanceComputationService _computation;
    private readonly IUnitOfWork _uow;

    public AttendanceUploadService(
        IRepository<AttendanceUploadBatch> batches, IRepository<AttendanceRecord> records,
        IRepository<Employee> employees, IRepository<WorkShift> workShifts,
        INumberSequenceService numberSequence, ILeaveRequestService leaveRequests,
        IAttendanceComputationService computation, IUnitOfWork uow)
    {
        _batches = batches;
        _records = records;
        _employees = employees;
        _workShifts = workShifts;
        _numberSequence = numberSequence;
        _leaveRequests = leaveRequests;
        _computation = computation;
        _uow = uow;
    }

    public async Task<PagedResponse<AttendanceUploadBatchDto>> ListBatchesAsync(
        PageQuery query, AttendanceBatchStatus? status, int? periodYear, int? periodMonth, CancellationToken ct = default)
    {
        var q = _batches.Query().AsNoTracking();
        if (status is not null) q = q.Where(b => b.Status == status);
        if (periodYear is not null) q = q.Where(b => b.PeriodStart.Year == periodYear);
        if (periodMonth is not null) q = q.Where(b => b.PeriodStart.Month == periodMonth);

        var total = await q.CountAsync(ct);
        var rows = await q.OrderByDescending(b => b.UploadedAt)
            .Skip(query.Skip).Take(query.PageSize)
            .Select(b => Map(b))
            .ToListAsync(ct);

        return PagedResponse<AttendanceUploadBatchDto>.Create(rows, query.Page, query.PageSize, total);
    }

    public async Task<AttendanceUploadBatchDto?> GetBatchAsync(int batchId, CancellationToken ct = default)
    {
        var batch = await _batches.Query().AsNoTracking().FirstOrDefaultAsync(b => b.AttendanceUploadBatchId == batchId, ct);
        return batch is null ? null : Map(batch);
    }

    public async Task<AttendanceUploadBatchDto> UploadAsync(
        Stream fileContent, string fileName, DateTime periodStart, DateTime periodEnd, int actorUserId, CancellationToken ct = default)
    {
        if (periodEnd < periodStart)
            throw new DomainException(ErrorCodes.Validation, "Period end cannot be before period start.", 422);

        var extension = Path.GetExtension(fileName).ToLowerInvariant();
        var sourceType = extension switch
        {
            ".xlsx" => AttendanceSourceType.Excel,
            ".csv" => AttendanceSourceType.Csv,
            _ => throw new DomainException(ErrorCodes.FileTypeNotAllowed, $"Unsupported attendance file type '{extension}'.", 422)
        };

        var rows = extension == ".xlsx" ? ParseExcel(fileContent) : ParseCsv(fileContent);

        var docNo = await _numberSequence.NextAsync("ATT", ct);
        var batch = new AttendanceUploadBatch
        {
            DocNo = docNo,
            PeriodStart = periodStart.Date,
            PeriodEnd = periodEnd.Date,
            SourceType = sourceType,
            OriginalFileName = fileName,
            UploadedBy = actorUserId,
            UploadedAt = DateTime.UtcNow,
            Status = AttendanceBatchStatus.Draft
        };
        await _batches.AddAsync(batch, ct);
        await _uow.SaveChangesAsync(ct); // flush to get batch.AttendanceUploadBatchId

        var employeesByCode = await _employees.Query().AsNoTracking()
            .Where(e => e.Status == EmployeeStatus.Active)
            .ToDictionaryAsync(e => e.EmployeeCode, StringComparer.OrdinalIgnoreCase, ct);
        var shifts = await _workShifts.Query().AsNoTracking().ToDictionaryAsync(s => s.WorkShiftId, ct);

        var seenInBatch = new Dictionary<(int EmployeeId, DateTime Date), AttendanceRecord>();
        var created = new List<AttendanceRecord>();

        foreach (var row in rows)
        {
            var record = new AttendanceRecord { AttendanceUploadBatchId = batch.AttendanceUploadBatchId };

            if (!employeesByCode.TryGetValue(row.EmployeeCode, out var employee))
            {
                record.RowValidationStatus = RowValidationStatus.EmployeeNotFound;
                record.AttendanceDate = row.Date ?? periodStart;
                record.WorkShiftId = shifts.Values.FirstOrDefault()?.WorkShiftId ?? 0;
                created.Add(record);
                continue;
            }

            record.EmployeeId = employee.EmployeeId;
            record.WorkShiftId = employee.WorkShiftId;

            if (row.Date is null)
            {
                record.RowValidationStatus = RowValidationStatus.InvalidDateTime;
                record.AttendanceDate = periodStart;
                created.Add(record);
                continue;
            }
            record.AttendanceDate = row.Date.Value;

            if (row.HasCheckInText && row.CheckIn is null || row.HasCheckOutText && row.CheckOut is null)
            {
                record.RowValidationStatus = RowValidationStatus.InvalidDateTime;
                created.Add(record);
                continue;
            }

            record.CheckIn = row.CheckIn;
            record.CheckOut = row.CheckOut;

            if (row.CheckIn is not null && row.CheckOut is null)
            {
                record.RowValidationStatus = RowValidationStatus.Error; // forgot to punch out
                created.Add(record);
                continue;
            }

            var key = (record.EmployeeId, record.AttendanceDate);
            if (seenInBatch.TryGetValue(key, out var firstOccurrence))
            {
                record.RowValidationStatus = RowValidationStatus.DuplicateWithinBatch;
                record.DuplicateOfAttendanceRecordId = null; // linked by (EmployeeId, Date) until both are persisted
                firstOccurrence.RowValidationStatus = RowValidationStatus.DuplicateWithinBatch;
                created.Add(record);
                continue;
            }

            var alreadyConfirmed = await _records.Query().AsNoTracking()
                .Include(r => r.AttendanceUploadBatch)
                .AnyAsync(r => r.EmployeeId == record.EmployeeId && r.AttendanceDate == record.AttendanceDate
                    && r.AttendanceUploadBatch != null
                    && (r.AttendanceUploadBatch.Status == AttendanceBatchStatus.Confirmed || r.AttendanceUploadBatch.Status == AttendanceBatchStatus.UsedInPayroll), ct);
            if (alreadyConfirmed)
            {
                record.RowValidationStatus = RowValidationStatus.DuplicateConfirmed;
                created.Add(record);
                continue;
            }

            seenInBatch[key] = record;
            record.RowValidationStatus = RowValidationStatus.Valid;
            created.Add(record);
        }

        foreach (var record in created)
        {
            if (record.RowValidationStatus == RowValidationStatus.Valid && shifts.TryGetValue(record.WorkShiftId, out var shift))
            {
                var leave = await _leaveRequests.FindApprovedLeaveCoveringAsync(record.EmployeeId, record.AttendanceDate, ct);
                var dayIndex = ((int)record.AttendanceDate.DayOfWeek + 6) % 7; // Monday=0..Sunday=6
                var isWeekOff = (shift.WorkingDaysMask & (1 << dayIndex)) == 0;
                _computation.Compute(record, shift, leave is not null, isHoliday: false, isWeekOff: isWeekOff);
            }
            await _records.AddAsync(record, ct);
        }

        batch.RowCountTotal = created.Count;
        batch.RowCountDuplicate = created.Count(r => r.RowValidationStatus is RowValidationStatus.DuplicateWithinBatch or RowValidationStatus.DuplicateConfirmed);
        batch.RowCountError = created.Count(r => r.RowValidationStatus is RowValidationStatus.EmployeeNotFound or RowValidationStatus.InvalidDateTime or RowValidationStatus.Error);

        await _uow.SaveChangesAsync(ct);

        return Map(batch);
    }

    public async Task<List<AttendanceRecordDto>> ListRecordsAsync(int batchId, RowValidationStatus? status, CancellationToken ct = default)
    {
        var q = _records.Query().AsNoTracking().Include(r => r.Employee)
            .Where(r => r.AttendanceUploadBatchId == batchId);
        if (status is not null) q = q.Where(r => r.RowValidationStatus == status);

        return await q.OrderBy(r => r.Employee!.FullName).ThenBy(r => r.AttendanceDate)
            .Select(r => Map(r))
            .ToListAsync(ct);
    }

    public async Task<AttendanceRecordDto> UpdateRecordAsync(
        int batchId, int recordId, UpdateAttendanceRecordRequest request, int actorUserId, CancellationToken ct = default)
    {
        var batch = await _batches.GetByIdAsync(batchId, ct)
            ?? throw new NotFoundException($"Attendance batch {batchId} was not found.");
        if (batch.Status is AttendanceBatchStatus.Confirmed or AttendanceBatchStatus.UsedInPayroll)
            throw new DomainException(ErrorCodes.AttendanceBatchLocked, "This attendance batch is locked and cannot be edited.", 409);

        var record = await _records.Query().Include(r => r.Employee).Include(r => r.WorkShift)
            .FirstOrDefaultAsync(r => r.AttendanceRecordId == recordId && r.AttendanceUploadBatchId == batchId, ct)
            ?? throw new NotFoundException($"Attendance record {recordId} was not found in batch {batchId}.");

        if (request.CheckIn is not null) record.CheckIn = request.CheckIn;
        if (request.CheckOut is not null) record.CheckOut = request.CheckOut;
        if (request.Notes is not null) record.Notes = request.Notes.Trim();

        if (record.WorkShift is not null && record.RowValidationStatus == RowValidationStatus.Valid)
        {
            var leave = await _leaveRequests.FindApprovedLeaveCoveringAsync(record.EmployeeId, record.AttendanceDate, ct);
            var dayIndex = ((int)record.AttendanceDate.DayOfWeek + 6) % 7;
            var isWeekOff = (record.WorkShift.WorkingDaysMask & (1 << dayIndex)) == 0;
            _computation.Compute(record, record.WorkShift, leave is not null, isHoliday: false, isWeekOff: isWeekOff);
        }
        if (request.AttendanceStatus is not null) record.AttendanceStatus = request.AttendanceStatus.Value;

        record.IsManualOverride = true;
        record.EditedBy = actorUserId;
        record.EditedAt = DateTime.UtcNow;

        await _uow.SaveChangesAsync(ct);
        return Map(record);
    }

    public async Task ResolveDuplicateAsync(int batchId, ResolveDuplicateRequest request, CancellationToken ct = default)
    {
        var batch = await _batches.GetByIdAsync(batchId, ct)
            ?? throw new NotFoundException($"Attendance batch {batchId} was not found.");
        if (batch.Status is AttendanceBatchStatus.Confirmed or AttendanceBatchStatus.UsedInPayroll)
            throw new DomainException(ErrorCodes.AttendanceBatchLocked, "This attendance batch is locked and cannot be edited.", 409);

        var record = await _records.GetByIdAsync(request.RecordId, ct)
            ?? throw new NotFoundException($"Attendance record {request.RecordId} was not found.");

        switch (request.Action)
        {
            case "discard":
                _records.Remove(record);
                break;
            case "keep":
                record.RowValidationStatus = RowValidationStatus.Valid;
                break;
            case "supersede":
                // Authorized cross-batch override: accept this row as the new source of truth.
                record.RowValidationStatus = RowValidationStatus.Valid;
                break;
        }

        await _uow.SaveChangesAsync(ct);
    }

    public async Task<AttendanceUploadBatchDto> ValidateAsync(int batchId, CancellationToken ct = default)
    {
        var batch = await _batches.GetByIdAsync(batchId, ct)
            ?? throw new NotFoundException($"Attendance batch {batchId} was not found.");
        if (batch.Status != AttendanceBatchStatus.Draft)
            throw new DomainException(ErrorCodes.Conflict, "Only a Draft batch can be validated.", 409);

        var unresolved = await _records.Query()
            .CountAsync(r => r.AttendanceUploadBatchId == batchId && r.RowValidationStatus != RowValidationStatus.Valid, ct);
        if (unresolved > 0)
            throw new DomainException(ErrorCodes.AttendanceDuplicateUnresolved,
                $"{unresolved} record(s) have unresolved errors/duplicates.", 422);

        batch.Status = AttendanceBatchStatus.Validated;
        await _uow.SaveChangesAsync(ct);
        return Map(batch);
    }

    public async Task<AttendanceUploadBatchDto> ConfirmAsync(int batchId, int actorUserId, CancellationToken ct = default)
    {
        var batch = await _batches.GetByIdAsync(batchId, ct)
            ?? throw new NotFoundException($"Attendance batch {batchId} was not found.");
        if (batch.Status != AttendanceBatchStatus.Validated)
            throw new DomainException(ErrorCodes.Conflict, "Only a Validated batch can be confirmed.", 409);

        batch.Status = AttendanceBatchStatus.Confirmed;
        batch.ConfirmedBy = actorUserId;
        batch.ConfirmedAt = DateTime.UtcNow;
        await _uow.SaveChangesAsync(ct);
        return Map(batch);
    }

    public async Task<AttendanceUploadBatchDto> UnlockAsync(int batchId, string reason, int actorUserId, CancellationToken ct = default)
    {
        var batch = await _batches.GetByIdAsync(batchId, ct)
            ?? throw new NotFoundException($"Attendance batch {batchId} was not found.");
        if (batch.Status == AttendanceBatchStatus.UsedInPayroll)
            throw new DomainException(ErrorCodes.AttendanceBatchLocked,
                "This batch has already been used in payroll; unlock/regenerate the payroll run first.", 409);
        if (batch.Status != AttendanceBatchStatus.Confirmed)
            throw new DomainException(ErrorCodes.Conflict, "Only a Confirmed batch can be unlocked.", 409);

        batch.Status = AttendanceBatchStatus.Validated;
        await _uow.SaveChangesAsync(ct);
        return Map(batch);
    }

    public (byte[] Content, string ContentType, string FileName) GenerateTemplate(bool asCsv)
    {
        if (asCsv)
        {
            var csv = string.Join(",", ColumnNames) + "\r\n" + "EMP001,2026-07-01,08:00,17:00\r\n";
            return (Encoding.UTF8.GetBytes(csv), "text/csv", "attendance-template.csv");
        }

        using var workbook = new XLWorkbook();
        var sheet = workbook.Worksheets.Add("Attendance");
        for (var i = 0; i < ColumnNames.Length; i++) sheet.Cell(1, i + 1).Value = ColumnNames[i];
        sheet.Cell(2, 1).Value = "EMP001";
        sheet.Cell(2, 2).Value = "2026-07-01";
        sheet.Cell(2, 3).Value = "08:00";
        sheet.Cell(2, 4).Value = "17:00";

        using var stream = new MemoryStream();
        workbook.SaveAs(stream);
        return (stream.ToArray(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "attendance-template.xlsx");
    }

    private sealed record ParsedRow(string EmployeeCode, DateTime? Date, TimeSpan? CheckIn, TimeSpan? CheckOut, bool HasCheckInText, bool HasCheckOutText);

    private static List<ParsedRow> ParseExcel(Stream content)
    {
        using var workbook = new XLWorkbook(content);
        var sheet = workbook.Worksheets.First();
        var rows = new List<ParsedRow>();

        var lastRow = sheet.LastRowUsed()?.RowNumber() ?? 1;
        for (var r = 2; r <= lastRow; r++)
        {
            var employeeCode = sheet.Cell(r, 1).GetString().Trim();
            if (string.IsNullOrWhiteSpace(employeeCode)) continue;

            var dateText = sheet.Cell(r, 2).GetString().Trim();
            var checkInText = sheet.Cell(r, 3).GetString().Trim();
            var checkOutText = sheet.Cell(r, 4).GetString().Trim();

            rows.Add(new ParsedRow(
                employeeCode,
                DateTime.TryParse(dateText, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d) ? d.Date : null,
                TimeSpan.TryParse(checkInText, CultureInfo.InvariantCulture, out var ci) ? ci : null,
                TimeSpan.TryParse(checkOutText, CultureInfo.InvariantCulture, out var co) ? co : null,
                !string.IsNullOrWhiteSpace(checkInText), !string.IsNullOrWhiteSpace(checkOutText)));
        }
        return rows;
    }

    private static List<ParsedRow> ParseCsv(Stream content)
    {
        using var reader = new StreamReader(content);
        using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) { HeaderValidated = null, MissingFieldFound = null });
        csv.Read();
        csv.ReadHeader();

        var rows = new List<ParsedRow>();
        while (csv.Read())
        {
            var employeeCode = csv.GetField("Employee Code")?.Trim() ?? string.Empty;
            if (string.IsNullOrWhiteSpace(employeeCode)) continue;

            var dateText = csv.GetField("Date")?.Trim() ?? string.Empty;
            var checkInText = csv.GetField("Check In")?.Trim() ?? string.Empty;
            var checkOutText = csv.GetField("Check Out")?.Trim() ?? string.Empty;

            rows.Add(new ParsedRow(
                employeeCode,
                DateTime.TryParse(dateText, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d) ? d.Date : null,
                TimeSpan.TryParse(checkInText, CultureInfo.InvariantCulture, out var ci) ? ci : null,
                TimeSpan.TryParse(checkOutText, CultureInfo.InvariantCulture, out var co) ? co : null,
                !string.IsNullOrWhiteSpace(checkInText), !string.IsNullOrWhiteSpace(checkOutText)));
        }
        return rows;
    }

    private static AttendanceUploadBatchDto Map(AttendanceUploadBatch b) => new(
        b.AttendanceUploadBatchId, b.DocNo, b.PeriodStart, b.PeriodEnd, b.SourceType, b.OriginalFileName,
        b.UploadedBy, b.UploadedAt, b.Status, b.ConfirmedBy, b.ConfirmedAt, b.RowCountTotal, b.RowCountDuplicate, b.RowCountError);

    private static AttendanceRecordDto Map(AttendanceRecord r) => new(
        r.AttendanceRecordId, r.AttendanceUploadBatchId, r.EmployeeId, r.Employee?.EmployeeCode, r.Employee?.FullName,
        r.AttendanceDate, r.CheckIn, r.CheckOut, r.WorkingMinutes, r.LateMinutes, r.EarlyLeaveMinutes, r.OvertimeMinutes,
        r.AttendanceStatus, r.RowValidationStatus, r.DuplicateOfAttendanceRecordId, r.Notes);
}
