const db = require('../db/models');
const Attendance_logsDBApi = require('../db/api/attendance_logs');
const processFile = require('../middlewares/upload');
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
const stream = require('stream');

const { Op } = db.Sequelize;

const CLOCK_IN_WINDOW_BEFORE_MINUTES = 60;
const CLOCK_IN_WINDOW_AFTER_MINUTES = 60;
const LATE_PENALTY_PER_MINUTE = 1000;

function createHttpError(message, code = 400) {
  const error = new Error(message);
  error.code = code;
  return error;
}

function normalizeTimezoneOffset(value) {
  const parsed = Number(value);

  if (!Number.isFinite(parsed)) {
    return 0;
  }

  return Math.max(-840, Math.min(840, Math.trunc(parsed)));
}

function isValidDate(value) {
  return value instanceof Date && !Number.isNaN(value.getTime());
}

function parseDecimal(value) {
  if (value === null || value === undefined || value === '') {
    return null;
  }

  const parsed = Number(value);
  return Number.isFinite(parsed) ? parsed : null;
}

function buildLocalDayRange(referenceDate, timezoneOffsetMinutes) {
  const offsetMs = timezoneOffsetMinutes * 60 * 1000;
  const shiftedReference = new Date(referenceDate.getTime() - offsetMs);
  const startShifted = new Date(
    Date.UTC(
      shiftedReference.getUTCFullYear(),
      shiftedReference.getUTCMonth(),
      shiftedReference.getUTCDate(),
      0,
      0,
      0,
      0,
    ),
  );
  const endShifted = new Date(
    Date.UTC(
      shiftedReference.getUTCFullYear(),
      shiftedReference.getUTCMonth(),
      shiftedReference.getUTCDate() + 1,
      0,
      0,
      0,
      0,
    ),
  );

  return {
    start: new Date(startShifted.getTime() + offsetMs),
    end: new Date(endShifted.getTime() + offsetMs),
  };
}

function serializeFile(file) {
  if (!file) {
    return null;
  }

  const plain = file.get ? file.get({ plain: true }) : file;

  return {
    id: plain.id,
    name: plain.name,
    sizeInBytes: plain.sizeInBytes,
    privateUrl: plain.privateUrl,
    publicUrl: plain.publicUrl,
  };
}

function serializeOutlet(outlet) {
  if (!outlet) {
    return null;
  }

  const plain = outlet.get ? outlet.get({ plain: true }) : outlet;

  return {
    id: plain.id,
    code: plain.code ?? null,
    name: plain.name ?? null,
    address: plain.address ?? null,
    gps_lat: parseDecimal(plain.gps_lat),
    gps_lng: parseDecimal(plain.gps_lng),
    gps_radius_m: plain.gps_radius_m ?? null,
    is_active: Boolean(plain.is_active),
  };
}

function serializeEmployee(employee) {
  if (!employee) {
    return null;
  }

  const plain = employee.get ? employee.get({ plain: true }) : employee;

  return {
    id: plain.id,
    employee_code: plain.employee_code ?? null,
    full_name: plain.full_name ?? null,
    phone: plain.phone ?? null,
    userId: plain.userId ?? null,
    outletId: plain.outletId ?? null,
    job_positionId: plain.job_positionId ?? null,
  };
}

function serializeJobPosition(jobPosition) {
  if (!jobPosition) {
    return null;
  }

  const plain = jobPosition.get ? jobPosition.get({ plain: true }) : jobPosition;
  const shiftTimes = parseShiftSchedule(plain.shift_schedule).map((shift) => shift.label);

  return {
    id: plain.id,
    code: plain.code ?? null,
    name: plain.name ?? null,
    payroll_weight: plain.payroll_weight ?? null,
    is_active: Boolean(plain.is_active),
    shift_schedule: plain.shift_schedule ?? null,
    shift_times: shiftTimes,
  };
}

function getShiftedReference(referenceDate, timezoneOffsetMinutes) {
  return new Date(referenceDate.getTime() - timezoneOffsetMinutes * 60 * 1000);
}

function buildLocalDateTime(referenceDate, timezoneOffsetMinutes, hour, minute) {
  const shiftedReference = getShiftedReference(referenceDate, timezoneOffsetMinutes);

  return new Date(
    Date.UTC(
      shiftedReference.getUTCFullYear(),
      shiftedReference.getUTCMonth(),
      shiftedReference.getUTCDate(),
      hour,
      minute,
      0,
      0,
    ) + timezoneOffsetMinutes * 60 * 1000,
  );
}

function formatLocalTime(referenceDate, timezoneOffsetMinutes) {
  if (!referenceDate) {
    return null;
  }

  const shiftedReference = getShiftedReference(referenceDate, timezoneOffsetMinutes);
  return `${String(shiftedReference.getUTCHours()).padStart(2, '0')}:${String(
    shiftedReference.getUTCMinutes(),
  ).padStart(2, '0')}`;
}

function parseShiftSchedule(value) {
  if (typeof value !== 'string' || !value.trim()) {
    return [];
  }

  const normalizedValue = value.replace(/\bdan\b/gi, ',').replace(/\band\b/gi, ',');
  const uniqueShiftMap = new Map();

  normalizedValue
    .split(/[\n,;|]+/)
    .map((token) => token.trim())
    .filter(Boolean)
    .forEach((token) => {
      const normalizedToken = token.replace(/\./g, ':').replace(/\s+/g, '');
      const match = normalizedToken.match(/^(\d{1,2})(?::(\d{1,2}))?$/);

      if (!match) {
        return;
      }

      const hour = Number(match[1]);
      const minute = match[2] === undefined ? 0 : Number(match[2]);

      if (
        !Number.isInteger(hour) ||
        !Number.isInteger(minute) ||
        hour < 0 ||
        hour > 23 ||
        minute < 0 ||
        minute > 59
      ) {
        return;
      }

      const label = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
      uniqueShiftMap.set(label, {
        label,
        hour,
        minute,
        totalMinutes: hour * 60 + minute,
      });
    });

  return Array.from(uniqueShiftMap.values()).sort((left, right) => left.totalMinutes - right.totalMinutes);
}

function buildShiftOccurrence(shift, referenceDate, timezoneOffsetMinutes) {
  const startsAt = buildLocalDateTime(referenceDate, timezoneOffsetMinutes, shift.hour, shift.minute);
  const windowStartAt = new Date(
    startsAt.getTime() - CLOCK_IN_WINDOW_BEFORE_MINUTES * 60 * 1000,
  );
  const windowEndAt = new Date(
    startsAt.getTime() + CLOCK_IN_WINDOW_AFTER_MINUTES * 60 * 1000,
  );
  const diffMs = referenceDate.getTime() - startsAt.getTime();
  const lateMinutes = diffMs > 0 ? Math.ceil(diffMs / 60000) : 0;

  return {
    ...shift,
    startsAt,
    windowStartAt,
    windowEndAt,
    diffMs,
    lateMinutes,
    latePenaltyAmount: lateMinutes * LATE_PENALTY_PER_MINUTE,
    status: lateMinutes > 0 ? 'telat' : 'hadir',
  };
}

function serializeShiftOccurrence(occurrence, timezoneOffsetMinutes, withinWindow = true) {
  if (!occurrence) {
    return null;
  }

  return {
    label: occurrence.label,
    time: occurrence.label,
    startsAt: occurrence.startsAt.toISOString(),
    windowStartAt: occurrence.windowStartAt.toISOString(),
    windowEndAt: occurrence.windowEndAt.toISOString(),
    windowLabel: `${formatLocalTime(occurrence.windowStartAt, timezoneOffsetMinutes)} - ${formatLocalTime(
      occurrence.windowEndAt,
      timezoneOffsetMinutes,
    )}`,
    lateMinutes: occurrence.lateMinutes,
    latePenaltyAmount: occurrence.latePenaltyAmount,
    status: occurrence.status,
    withinWindow,
  };
}

function resolveShiftMatch(shifts, referenceDate, timezoneOffsetMinutes, options = {}) {
  const { allowOutsideWindow = false } = options;
  const occurrences = shifts.map((shift) => buildShiftOccurrence(shift, referenceDate, timezoneOffsetMinutes));
  const sortedByDistance = [...occurrences].sort(
    (left, right) => Math.abs(left.diffMs) - Math.abs(right.diffMs) || left.totalMinutes - right.totalMinutes,
  );
  const withinWindow = sortedByDistance.find(
    (occurrence) =>
      referenceDate.getTime() >= occurrence.windowStartAt.getTime() &&
      referenceDate.getTime() <= occurrence.windowEndAt.getTime(),
  );

  if (withinWindow) {
    return {
      occurrence: withinWindow,
      withinWindow: true,
      occurrences,
    };
  }

  if (allowOutsideWindow && sortedByDistance.length) {
    return {
      occurrence: sortedByDistance[0],
      withinWindow: false,
      occurrences,
    };
  }

  return {
    occurrence: null,
    withinWindow: false,
    occurrences,
  };
}

function resolveNextShiftOccurrence(shifts, referenceDate, timezoneOffsetMinutes) {
  return shifts
    .map((shift) => buildShiftOccurrence(shift, referenceDate, timezoneOffsetMinutes))
    .filter((occurrence) => occurrence.windowStartAt.getTime() > referenceDate.getTime())
    .sort((left, right) => left.windowStartAt.getTime() - right.windowStartAt.getTime())[0] || null;
}

function buildRosterContext(jobPosition, referenceDate, timezoneOffsetMinutes, attendanceLog = null) {
  const serializedJobPosition = serializeJobPosition(jobPosition);
  const shifts = parseShiftSchedule(serializedJobPosition?.shift_schedule);
  const activeShiftMatch = resolveShiftMatch(shifts, referenceDate, timezoneOffsetMinutes);
  const activeShift = serializeShiftOccurrence(
    activeShiftMatch.occurrence,
    timezoneOffsetMinutes,
    activeShiftMatch.withinWindow,
  );
  const nextShiftOccurrence = resolveNextShiftOccurrence(shifts, referenceDate, timezoneOffsetMinutes);
  const nextShift = serializeShiftOccurrence(nextShiftOccurrence, timezoneOffsetMinutes, false);

  let assignedShift = null;

  if (attendanceLog?.check_in_at) {
    const recordedCheckInAt = new Date(attendanceLog.check_in_at);

    if (isValidDate(recordedCheckInAt)) {
      const assignedShiftMatch = resolveShiftMatch(shifts, recordedCheckInAt, timezoneOffsetMinutes, {
        allowOutsideWindow: true,
      });
      assignedShift = serializeShiftOccurrence(
        assignedShiftMatch.occurrence,
        timezoneOffsetMinutes,
        assignedShiftMatch.withinWindow,
      );
    }
  }

  let blockedReason = null;

  if (!shifts.length) {
    blockedReason = 'Job position/divisi ini belum punya jadwal shift. Isi contoh: 07:00, 12:00, 15:00.';
  } else if (!activeShiftMatch.occurrence) {
    const shiftList = shifts.map((shift) => shift.label).join(', ');

    if (nextShiftOccurrence) {
      blockedReason = `Absensi hanya bisa ${CLOCK_IN_WINDOW_BEFORE_MINUTES} menit sebelum sampai ${CLOCK_IN_WINDOW_AFTER_MINUTES} menit sesudah shift dimulai. Window terdekat untuk shift ${nextShiftOccurrence.label}: ${formatLocalTime(nextShiftOccurrence.windowStartAt, timezoneOffsetMinutes)} - ${formatLocalTime(nextShiftOccurrence.windowEndAt, timezoneOffsetMinutes)}.`;
    } else {
      blockedReason = `Semua window absensi untuk hari ini sudah lewat. Jadwal shift hari ini: ${shiftList}.`;
    }
  }

  return {
    latePenaltyPerMinute: LATE_PENALTY_PER_MINUTE,
    windowBeforeMinutes: CLOCK_IN_WINDOW_BEFORE_MINUTES,
    windowAfterMinutes: CLOCK_IN_WINDOW_AFTER_MINUTES,
    shiftScheduleRaw: serializedJobPosition?.shift_schedule ?? null,
    shiftTimes: shifts.map((shift) => shift.label),
    activeShift,
    nextShift,
    assignedShift,
    blockedReason,
  };
}

function getClockInSetupError(employee, roster) {
  if (!employee) {
    return 'Akun ini belum terhubung ke data employee.';
  }

  if (!employee.outlet) {
    return 'Employee ini belum terhubung ke outlet.';
  }

  if (!employee.job_position) {
    return 'Employee ini belum terhubung ke job position/divisi.';
  }

  if (!roster.shiftTimes.length) {
    return 'Job position/divisi ini belum punya jadwal shift.';
  }

  return null;
}

function serializeAttendanceLog(attendanceLog) {
  if (!attendanceLog) {
    return null;
  }

  const plain = attendanceLog.get ? attendanceLog.get({ plain: true }) : attendanceLog;

  return {
    id: plain.id,
    work_date: plain.work_date ? new Date(plain.work_date).toISOString() : null,
    check_in_at: plain.check_in_at ? new Date(plain.check_in_at).toISOString() : null,
    check_out_at: plain.check_out_at ? new Date(plain.check_out_at).toISOString() : null,
    check_in_lat: parseDecimal(plain.check_in_lat),
    check_in_lng: parseDecimal(plain.check_in_lng),
    check_out_lat: parseDecimal(plain.check_out_lat),
    check_out_lng: parseDecimal(plain.check_out_lng),
    status: plain.status ?? null,
    late_minutes: plain.late_minutes ?? null,
    gps_valid: Boolean(plain.gps_valid),
    remarks: plain.remarks ?? null,
    employee: serializeEmployee(plain.employee),
    outlet: serializeOutlet(plain.outlet),
    check_in_photo: Array.isArray(plain.check_in_photo)
      ? plain.check_in_photo.map(serializeFile).filter(Boolean)
      : [],
    check_out_photo: Array.isArray(plain.check_out_photo)
      ? plain.check_out_photo.map(serializeFile).filter(Boolean)
      : [],
  };
}

function attendanceLogInclude() {
  return [
    {
      model: db.employees,
      as: 'employee',
      required: false,
    },
    {
      model: db.outlets,
      as: 'outlet',
      required: false,
    },
    {
      model: db.file,
      as: 'check_in_photo',
      required: false,
    },
    {
      model: db.file,
      as: 'check_out_photo',
      required: false,
    },
  ];
}

async function getEmployeeForCurrentUser(currentUser, transaction) {
  const where = {
    userId: currentUser.id,
  };

  const organizationsId = currentUser?.organizationsId || currentUser?.organizations?.id || null;

  if (!currentUser?.app_role?.globalAccess && organizationsId) {
    where.organizationsId = organizationsId;
  }

  return db.employees.findOne({
    where,
    include: [
      {
        model: db.outlets,
        as: 'outlet',
        required: false,
      },
      {
        model: db.job_positions,
        as: 'job_position',
        required: false,
      },
    ],
    order: [['createdAt', 'ASC']],
    transaction,
  });
}

async function findTodayAttendanceLog(employeeId, referenceDate, timezoneOffsetMinutes, transaction) {
  const { start, end } = buildLocalDayRange(referenceDate, timezoneOffsetMinutes);

  return db.attendance_logs.findOne({
    where: {
      employeeId,
      work_date: {
        [Op.gte]: start,
        [Op.lt]: end,
      },
    },
    include: attendanceLogInclude(),
    order: [
      ['work_date', 'DESC'],
      ['createdAt', 'DESC'],
    ],
    transaction,
  });
}

module.exports = class Attendance_logsService {
  static async create(data, currentUser) {
    const transaction = await db.sequelize.transaction();
    try {
      await Attendance_logsDBApi.create(data, {
        currentUser,
        transaction,
      });

      await transaction.commit();
    } catch (error) {
      await transaction.rollback();
      throw error;
    }
  }

  static async getClockInContext(currentUser, timezoneOffsetMinutesInput) {
    const timezoneOffsetMinutes = normalizeTimezoneOffset(timezoneOffsetMinutesInput);
    const employee = await getEmployeeForCurrentUser(currentUser);
    const todayLog = employee
      ? await findTodayAttendanceLog(employee.id, new Date(), timezoneOffsetMinutes)
      : null;
    const roster = buildRosterContext(employee?.job_position, new Date(), timezoneOffsetMinutes, todayLog);
    const setupError = getClockInSetupError(employee, roster);
    const clockInBlockedReason = todayLog?.check_in_at
      ? 'Anda sudah clock in untuk hari ini.'
      : setupError || roster.blockedReason;

    return {
      employee: serializeEmployee(employee),
      outlet: serializeOutlet(employee?.outlet),
      jobPosition: serializeJobPosition(employee?.job_position),
      todayLog: serializeAttendanceLog(todayLog),
      canClockIn: Boolean(employee?.outlet) && !clockInBlockedReason,
      setupError,
      clockInBlockedReason,
      roster,
      timezoneOffsetMinutes,
    };
  }

  static async clockIn(payload, currentUser) {
    const transaction = await db.sequelize.transaction();

    try {
      const timezoneOffsetMinutes = normalizeTimezoneOffset(payload.timezoneOffsetMinutes);
      const requestedCheckInAt = payload.clientTimestamp
        ? new Date(payload.clientTimestamp)
        : new Date();
      const checkInAt = isValidDate(requestedCheckInAt)
        ? requestedCheckInAt
        : new Date();
      const latitude = parseDecimal(payload.latitude);
      const longitude = parseDecimal(payload.longitude);
      const remarks = typeof payload.remarks === 'string' && payload.remarks.trim()
        ? payload.remarks.trim()
        : null;
      const checkInPhoto = Array.isArray(payload.check_in_photo)
        ? payload.check_in_photo.filter(Boolean)
        : [];

      if (latitude === null || longitude === null) {
        throw createHttpError('GPS wajib diambil sebelum clock in.');
      }

      if (!checkInPhoto.length) {
        throw createHttpError('Foto selfie wajib diunggah sebelum clock in.');
      }

      const employee = await getEmployeeForCurrentUser(currentUser, transaction);
      const todayLog = employee
        ? await findTodayAttendanceLog(employee.id, checkInAt, timezoneOffsetMinutes, transaction)
        : null;
      const roster = buildRosterContext(employee?.job_position, checkInAt, timezoneOffsetMinutes, todayLog);
      const setupError = getClockInSetupError(employee, roster);

      if (setupError) {
        throw createHttpError(setupError);
      }

      if (todayLog?.check_in_at) {
        throw createHttpError('Anda sudah clock in untuk hari ini.');
      }

      if (!roster.activeShift) {
        throw createHttpError(roster.blockedReason || 'Tidak ada shift aktif untuk waktu absensi ini.');
      }

      const data = {
        employee: employee.id,
        outlet: employee.outlet.id,
        organizations:
          employee.organizationsId ||
          currentUser?.organizationsId ||
          currentUser?.organizations?.id ||
          null,
        work_date: checkInAt,
        check_in_at: checkInAt,
        check_in_lat: latitude,
        check_in_lng: longitude,
        status: roster.activeShift.lateMinutes > 0 ? 'telat' : 'hadir',
        late_minutes: roster.activeShift.lateMinutes,
        gps_valid: true,
        remarks,
        check_in_photo: checkInPhoto,
        check_out_photo: Array.isArray(todayLog?.check_out_photo)
          ? todayLog.check_out_photo.map(serializeFile).filter(Boolean)
          : [],
      };

      const attendanceLogRecord = todayLog
        ? await Attendance_logsDBApi.update(todayLog.id, data, {
            currentUser,
            transaction,
          })
        : await Attendance_logsDBApi.create(data, {
            currentUser,
            transaction,
          });

      const freshAttendanceLog = await db.attendance_logs.findByPk(attendanceLogRecord.id, {
        include: attendanceLogInclude(),
        transaction,
      });
      const responseRoster = buildRosterContext(
        employee?.job_position,
        checkInAt,
        timezoneOffsetMinutes,
        freshAttendanceLog,
      );

      await transaction.commit();

      return {
        success: true,
        action: todayLog ? 'updated' : 'created',
        attendanceLog: serializeAttendanceLog(freshAttendanceLog),
        jobPosition: serializeJobPosition(employee?.job_position),
        roster: responseRoster,
        timezoneOffsetMinutes,
      };
    } catch (error) {
      await transaction.rollback();
      throw error;
    }
  }

  static async bulkImport(req, res) {
    const transaction = await db.sequelize.transaction();

    try {
      await processFile(req, res);
      const bufferStream = new stream.PassThrough();
      const results = [];

      await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8'));

      await new Promise((resolve, reject) => {
        bufferStream
          .pipe(csv())
          .on('data', (data) => results.push(data))
          .on('end', async () => {
            console.log('CSV results', results);
            resolve();
          })
          .on('error', (error) => reject(error));
      });

      await Attendance_logsDBApi.bulkImport(results, {
        transaction,
        ignoreDuplicates: true,
        validate: true,
        currentUser: req.currentUser,
      });

      await transaction.commit();
    } catch (error) {
      await transaction.rollback();
      throw error;
    }
  }

  static async update(data, id, currentUser) {
    const transaction = await db.sequelize.transaction();
    try {
      const attendance_logs = await Attendance_logsDBApi.findBy(
        { id },
        { transaction },
      );

      if (!attendance_logs) {
        throw new ValidationError('attendance_logsNotFound');
      }

      const updatedAttendance_logs = await Attendance_logsDBApi.update(
        id,
        data,
        {
          currentUser,
          transaction,
        },
      );

      await transaction.commit();
      return updatedAttendance_logs;
    } catch (error) {
      await transaction.rollback();
      throw error;
    }
  }

  static async deleteByIds(ids, currentUser) {
    const transaction = await db.sequelize.transaction();

    try {
      await Attendance_logsDBApi.deleteByIds(ids, {
        currentUser,
        transaction,
      });

      await transaction.commit();
    } catch (error) {
      await transaction.rollback();
      throw error;
    }
  }

  static async remove(id, currentUser) {
    const transaction = await db.sequelize.transaction();

    try {
      await Attendance_logsDBApi.remove(id, {
        currentUser,
        transaction,
      });

      await transaction.commit();
    } catch (error) {
      await transaction.rollback();
      throw error;
    }
  }
};
