<?php

namespace App\Services\Biometric;

use App\Models\Attendance;
use App\Models\AttendancePunch;
use App\Models\BiometricDevice;
use App\Models\Company;
use App\Models\User;
use App\Models\WorkDuration;
use App\Services\Attendance\AttendanceCalendarDayResolver;
use App\Services\CompanyResolver;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;

class PunchAttendanceApplicator
{
    public function __construct(
        protected CompanyResolver $companyResolver,
        protected AttendanceCalendarDayResolver $calendarDayResolver
    ) {}

    public function apply(AttendancePunch $punch): AttendancePunch
    {
        $punch = AttendancePunch::withoutGlobalScopes()->findOrFail($punch->id);

        if ($punch->status === AttendancePunch::STATUS_APPLIED) {
            return $punch;
        }

        $company = Company::withoutGlobalScopes()->find($punch->company_id);
        if (! $company) {
            return $this->mark($punch, AttendancePunch::STATUS_ERROR, 'Company not found.');
        }

        $featureKey = (string) config('zkteco.plan_feature_key', 'attendance_biometric');
        if (! $company->hasPlanFeature($featureKey)) {
            return $this->mark($punch, AttendancePunch::STATUS_ERROR, 'Biometric attendance is not included in the company plan.');
        }

        $this->companyResolver->setCompany($company);

        $tz = $company->timezone ?: config('app.timezone', 'Asia/Kolkata');
        // Punch times are stored as company wall-clock; do not re-shift via app timezone.
        $rawDateTime = $punch->getAttributes()['punch_datetime']
            ?? ($punch->punch_datetime instanceof \DateTimeInterface
                ? $punch->punch_datetime->format('Y-m-d H:i:s')
                : (string) $punch->punch_datetime);
        $punchAt = Carbon::parse($rawDateTime, $tz);

        $user = $this->resolveEmployee($company, (string) $punch->emp_code);

        if (! $user) {
            return $this->mark(
                $punch,
                AttendancePunch::STATUS_ERROR,
                'No active employee found for emp_code (must match Employee ID).',
                null,
                null,
                null
            );
        }

        $device = BiometricDevice::withoutGlobalScopes()->find($punch->biometric_device_id);
        if (! $device) {
            return $this->mark(
                $punch,
                AttendancePunch::STATUS_ERROR,
                'Biometric device not found.',
                $user->id
            );
        }

        // punch_mode=both is handled by BiometricFirstInLastOutService.
        if (($device->punch_mode ?: BiometricDevice::PUNCH_MODE_BOTH) === BiometricDevice::PUNCH_MODE_BOTH) {
            return app(BiometricFirstInLastOutService::class)->processPunch($punch);
        }

        $action = $this->resolveActionForDevice(
            $device,
            (string) $punch->punch_state,
            $user,
            $company,
            $punchAt,
            $tz
        );
        if ($action === null) {
            return $this->mark(
                $punch,
                AttendancePunch::STATUS_IGNORED,
                'Unknown punch_state.',
                $user->id
            );
        }

        return DB::transaction(function () use ($punch, $company, $user, $punchAt, $action, $tz) {
            if ($action === 'in') {
                return $this->applyCheckIn($punch, $company, $user, $punchAt, $tz);
            }

            return $this->applyCheckOut($punch, $company, $user, $punchAt, $tz);
        });
    }

    protected function applyCheckIn(
        AttendancePunch $punch,
        Company $company,
        User $user,
        Carbon $punchAt,
        string $tz
    ): AttendancePunch {
        $date = $punchAt->toDateString();

        $openSession = $this->findRelevantOpenSession($company, $user, $punchAt, $tz);

        if ($openSession) {
            return $this->mark(
                $punch,
                AttendancePunch::STATUS_IGNORED,
                'Active session already open; duplicate check-in ignored.',
                $user->id,
                $openSession->attendance_id,
                $openSession->id
            );
        }

        $attendance = Attendance::query()->firstOrCreate(
            [
                'company_id' => $company->id,
                'user_id' => $user->id,
                'date' => $date,
            ],
            [
                'status' => 'present',
            ]
        );

        $session = WorkDuration::create([
            'company_id' => $company->id,
            'attendance_id' => $attendance->id,
            'start_time' => $punchAt->format('H:i:s'),
            'status' => 'open',
            'notes' => 'zkteco:'.$punch->id,
        ]);

        if (! $attendance->clock_in_date_time) {
            $isLate = $this->calendarDayResolver->isLateArrivalForUser($user, $punchAt);
            $attendance->update([
                'clock_in_date_time' => $punchAt->format('Y-m-d H:i:s'),
                'clock_in_time' => $punchAt->format('H:i:s'),
                'clock_in_latitude' => $punch->latitude,
                'clock_in_longitude' => $punch->longitude,
                'clock_in_location' => $punch->gps_location,
                'clock_in_method' => 'biometric',
                'attendance_type' => $attendance->attendance_type ?: 'office',
                'status' => 'present',
                'is_late' => $isLate,
            ]);
        }

        return $this->mark(
            $punch,
            AttendancePunch::STATUS_APPLIED,
            null,
            $user->id,
            $attendance->id,
            $session->id
        );
    }

    protected function applyCheckOut(
        AttendancePunch $punch,
        Company $company,
        User $user,
        Carbon $punchAt,
        string $tz
    ): AttendancePunch {
        $openSession = $this->findRelevantOpenSession($company, $user, $punchAt, $tz);

        if (! $openSession) {
            return $this->mark(
                $punch,
                AttendancePunch::STATUS_IGNORED,
                'No open session to close for this punch day/window.',
                $user->id
            );
        }

        $attendance = Attendance::query()->find($openSession->attendance_id);
        if (! $attendance) {
            return $this->mark(
                $punch,
                AttendancePunch::STATUS_ERROR,
                'Attendance row missing for open session.',
                $user->id
            );
        }

        $attendanceDate = $attendance->date instanceof Carbon
            ? $attendance->date->toDateString()
            : Carbon::parse($attendance->date)->toDateString();

        $sessionStart = Carbon::parse($attendanceDate.' '.substr((string) $openSession->start_time, 0, 8), $tz);
        if ($punchAt->lt($sessionStart)) {
            // Cross-midnight safety: treat punch as after start on next calendar span.
            $sessionStart = $sessionStart->copy();
        }

        $durationSeconds = max(0, $sessionStart->diffInSeconds($punchAt));

        $openSession->update([
            'end_time' => $punchAt->format('H:i:s'),
            'duration' => $durationSeconds,
            'status' => 'closed',
        ]);

        $totalDuration = (int) WorkDuration::query()
            ->where('company_id', $company->id)
            ->where('attendance_id', $attendance->id)
            ->sum('duration');

        $attendance->update([
            'clock_out_date_time' => $punchAt->format('Y-m-d H:i:s'),
            'clock_out_time' => $punchAt->format('H:i:s'),
            'clock_out_latitude' => $punch->latitude,
            'clock_out_longitude' => $punch->longitude,
            'clock_out_location' => $punch->gps_location,
            'clock_out_method' => 'biometric',
            'total_duration' => $totalDuration,
            'status' => 'present',
        ]);

        return $this->mark(
            $punch,
            AttendancePunch::STATUS_APPLIED,
            null,
            $user->id,
            $attendance->id,
            $openSession->id
        );
    }

    /**
     * Map device emp_code to an active staff employee.
     * Exact match first; if both sides are purely numeric, also try normalized forms (leading zeros).
     */
    protected function resolveEmployee(Company $company, string $empCode): ?User
    {
        $empCode = trim($empCode);
        if ($empCode === '') {
            return null;
        }

        $base = User::query()
            ->where('company_id', $company->id)
            ->where('user_type', User::USER_TYPE_STAFF)
            ->where('status', User::STATUS_ACTIVE);

        $exact = (clone $base)->where('employee_number', $empCode)->first();
        if ($exact) {
            return $exact;
        }

        if (! ctype_digit($empCode)) {
            return null;
        }

        $normalizedCode = ltrim($empCode, '0');
        if ($normalizedCode === '') {
            $normalizedCode = '0';
        }

        $candidates = (clone $base)
            ->whereNotNull('employee_number')
            ->where('employee_number', '!=', '')
            ->get(['id', 'employee_number']);

        $matches = $candidates->filter(function (User $user) use ($normalizedCode) {
            $number = trim((string) $user->employee_number);
            if ($number === '' || ! ctype_digit($number)) {
                return false;
            }
            $normalizedNumber = ltrim($number, '0');
            if ($normalizedNumber === '') {
                $normalizedNumber = '0';
            }

            return $normalizedNumber === $normalizedCode;
        });

        if ($matches->count() === 1) {
            return User::query()->find($matches->first()->id);
        }

        return null;
    }

    /**
     * Resolve in/out from device punch_mode, then punch_state with same-day session-aware Both logic.
     */
    protected function resolveActionForDevice(
        BiometricDevice $device,
        string $punchState,
        User $user,
        Company $company,
        Carbon $punchAt,
        string $tz
    ): ?string {
        $mode = $device->punch_mode ?: BiometricDevice::PUNCH_MODE_BOTH;

        if ($mode === BiometricDevice::PUNCH_MODE_LOGIN) {
            return 'in';
        }

        if ($mode === BiometricDevice::PUNCH_MODE_LOGOUT) {
            return 'out';
        }

        $hasOpen = $this->findRelevantOpenSession($company, $user, $punchAt, $tz) !== null;
        $mapped = $this->resolveAction($punchState);

        // Both: flip when mapped state conflicts with current same-day/window session
        // (ZK often always sends Check In).
        if ($mapped === 'in' && $hasOpen) {
            return 'out';
        }
        if ($mapped === 'out' && ! $hasOpen) {
            return 'in';
        }
        if ($mapped !== null) {
            return $mapped;
        }

        return $hasOpen ? 'out' : 'in';
    }

    /**
     * Open session relevant to this punch: attendance date within the rolling window
     * ending at punch time (default 20h). Ignores lifelong stale opens from past months.
     */
    protected function findRelevantOpenSession(
        Company $company,
        User $user,
        Carbon $punchAt,
        string $tz
    ): ?WorkDuration {
        $maxAgeHours = max(1, (int) config('zkteco.open_session_max_age_hours', 20));
        $earliest = $punchAt->copy()->subHours($maxAgeHours);

        $candidateDates = [];
        for ($cursor = $earliest->copy()->startOfDay(); $cursor->lte($punchAt->copy()->startOfDay()); $cursor->addDay()) {
            $candidateDates[] = $cursor->toDateString();
        }

        $sessions = WorkDuration::query()
            ->where('company_id', $company->id)
            ->whereNull('end_time')
            ->whereHas('attendance', function ($q) use ($user, $company, $candidateDates) {
                $q->where('company_id', $company->id)
                    ->where('user_id', $user->id)
                    ->whereIn('date', $candidateDates);
            })
            ->with(['attendance' => function ($q) {
                $q->withoutGlobalScopes();
            }])
            ->orderByDesc('id')
            ->get();

        foreach ($sessions as $session) {
            $attendance = $session->attendance;
            if (! $attendance) {
                continue;
            }

            $attendanceDate = $attendance->date instanceof Carbon
                ? $attendance->date->toDateString()
                : Carbon::parse($attendance->date)->toDateString();

            $sessionStart = Carbon::parse(
                $attendanceDate.' '.substr((string) $session->start_time, 0, 8),
                $tz
            );

            if ($sessionStart->gt($punchAt)) {
                continue;
            }

            if ($sessionStart->lt($earliest)) {
                continue;
            }

            return $session;
        }

        return null;
    }

    protected function resolveAction(string $punchState): ?string
    {
        $map = config('zkteco.punch_state_map', []);
        $key = strtolower(trim($punchState));

        if ($key === '') {
            return null;
        }

        return $map[$key] ?? null;
    }

    protected function mark(
        AttendancePunch $punch,
        string $status,
        ?string $errorMessage = null,
        ?int $userId = null,
        ?int $attendanceId = null,
        ?int $workDurationId = null
    ): AttendancePunch {
        $punch->forceFill([
            'status' => $status,
            'error_message' => $errorMessage,
            'user_id' => $userId ?? $punch->user_id,
            'attendance_id' => $attendanceId ?? $punch->attendance_id,
            'work_duration_id' => $workDurationId ?? $punch->work_duration_id,
            'processed_at' => now(),
        ])->save();

        return $punch->fresh();
    }
}
