<?php

namespace Tests\Feature;

use App\Models\Attendance;
use App\Models\AttendancePunch;
use App\Models\BiometricDevice;
use App\Models\Company;
use App\Models\PlanFeature;
use App\Models\SubscriptionPlan;
use App\Models\User;
use App\Models\WorkDuration;
use App\Services\TenantRoleService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Tests\TestCase;

class ZktecoAttendanceWebhookTest extends TestCase
{
    use RefreshDatabase;

    private function companyWithPlan(?bool $withBiometricFeature = null): Company
    {
        $plan = SubscriptionPlan::query()->create([
            'name' => 'Test Plan '.Str::random(6),
            'base_price_monthly' => 0,
            'active' => true,
            'modules' => ['attendance', 'employees', 'dashboard'],
        ]);

        if ($withBiometricFeature === true) {
            PlanFeature::query()->create([
                'plan_id' => $plan->id,
                'feature_key' => 'attendance_biometric',
                'feature_value' => '1',
            ]);
        } elseif ($withBiometricFeature === false) {
            PlanFeature::query()->create([
                'plan_id' => $plan->id,
                'feature_key' => 'some_other_feature',
                'feature_value' => '1',
            ]);
        }

        return Company::query()->create([
            'name' => 'Bio Co '.Str::random(4),
            'short_name' => 'B'.Str::upper(Str::random(3)),
            'email' => 'bio-'.Str::lower(Str::random(8)).'@example.com',
            'status' => 'active',
            'subscription_plan_id' => $plan->id,
            'timezone' => 'Asia/Kolkata',
        ]);
    }

    /**
     * @return array{company: Company, device: BiometricDevice, password: string, employee: User, sn: string, empCode: string}
     */
    private function seedDeviceAndEmployee(?bool $withBiometricFeature = null, string $punchMode = BiometricDevice::PUNCH_MODE_BOTH): array
    {
        $company = $this->companyWithPlan($withBiometricFeature);
        TenantRoleService::syncAllDefaultRolesForCompany((int) $company->id);

        $password = 'test-device-secret-password-123';
        $sn = 'SN-'.Str::upper(Str::random(10));
        $empCode = 'E'.Str::upper(Str::random(6));

        $device = BiometricDevice::withoutGlobalScopes()->create([
            'company_id' => $company->id,
            'terminal_sn' => $sn,
            'terminal_alias' => 'Gate',
            'api_username' => 'dev_'.Str::lower(Str::random(10)),
            'api_password_hash' => Hash::make($password),
            'is_active' => true,
            'punch_mode' => $punchMode,
        ]);

        $employee = User::factory()->create([
            'company_id' => $company->id,
            'user_type' => User::USER_TYPE_STAFF,
            'status' => User::STATUS_ACTIVE,
            'employee_number' => $empCode,
            'allow_login' => true,
        ]);

        return [
            'company' => $company,
            'device' => $device,
            'password' => $password,
            'employee' => $employee,
            'sn' => $sn,
            'empCode' => $empCode,
        ];
    }

    private function postPunches(string $username, string $password, array $payload)
    {
        return $this->withHeaders([
            'Authorization' => 'Basic '.base64_encode($username.':'.$password),
            'Accept' => 'application/json',
            'Content-Type' => 'application/json',
        ])->postJson('/api/integrations/zkteco/attendance', $payload);
    }

    public function test_unauthenticated_request_is_rejected(): void
    {
        $this->seedDeviceAndEmployee();

        $response = $this->postJson('/api/integrations/zkteco/attendance', [[
            'emp_code' => 'EMP1001',
            'punch_datetime' => '2026-07-24 09:00:00',
            'punch_state' => 'Check In',
            'terminal_sn' => 'SN-TEST-001',
        ]]);

        $response->assertStatus(401);
    }

    public function test_sn_mismatch_is_rejected_from_batch(): void
    {
        $ctx = $this->seedDeviceAndEmployee();

        $response = $this->postPunches($ctx['device']->api_username, $ctx['password'], [[
            'emp_code' => $ctx['empCode'],
            'punch_datetime' => '2026-07-24 09:00:00',
            'punch_state' => 'Check In',
            'terminal_sn' => 'OTHER-SN',
        ]]);

        $response->assertStatus(202)
            ->assertJson([
                'accepted' => 0,
                'rejected' => 1,
            ]);

        $this->assertSame(
            0,
            AttendancePunch::withoutGlobalScopes()->where('company_id', $ctx['company']->id)->count()
        );
    }

    public function test_check_in_and_out_create_work_duration_session(): void
    {
        $ctx = $this->seedDeviceAndEmployee();

        $in = $this->postPunches($ctx['device']->api_username, $ctx['password'], [[
            'emp_code' => $ctx['empCode'],
            'punch_datetime' => '2026-07-24 09:00:00',
            'punch_state' => 'Check In',
            'terminal_sn' => $ctx['sn'],
        ]]);
        $in->assertStatus(202)->assertJson(['accepted' => 1]);

        $out = $this->postPunches($ctx['device']->api_username, $ctx['password'], [[
            'emp_code' => $ctx['empCode'],
            'punch_datetime' => '2026-07-24 18:00:00',
            'punch_state' => 'Check Out',
            'terminal_sn' => $ctx['sn'],
        ]]);
        $out->assertStatus(202)->assertJson(['accepted' => 1]);

        $attendance = Attendance::withoutGlobalScopes()
            ->where('company_id', $ctx['company']->id)
            ->where('user_id', $ctx['employee']->id)
            ->whereDate('date', '2026-07-24')
            ->first();

        $this->assertNotNull($attendance);
        $this->assertNotNull($attendance->clock_in_date_time);
        $this->assertNotNull($attendance->clock_out_date_time);
        $this->assertSame('biometric', $attendance->clock_in_method);
        $this->assertSame('biometric', $attendance->clock_out_method);

        $session = WorkDuration::withoutGlobalScopes()
            ->where('attendance_id', $attendance->id)
            ->first();

        $this->assertNotNull($session);
        $this->assertSame('09:00:00', substr((string) $session->start_time, 0, 8));
        $this->assertSame('18:00:00', substr((string) $session->end_time, 0, 8));
        $this->assertSame('closed', $session->status);

        $this->assertSame(
            2,
            AttendancePunch::withoutGlobalScopes()
                ->where('company_id', $ctx['company']->id)
                ->where('status', 'applied')
                ->count()
        );
    }

    public function test_duplicate_punch_is_idempotent(): void
    {
        $ctx = $this->seedDeviceAndEmployee();
        $payload = [[
            'emp_code' => $ctx['empCode'],
            'punch_datetime' => '2026-07-24 09:15:00',
            'punch_state' => 'Check In',
            'terminal_sn' => $ctx['sn'],
        ]];

        $this->postPunches($ctx['device']->api_username, $ctx['password'], $payload)
            ->assertStatus(202)
            ->assertJson(['accepted' => 1, 'duplicates' => 0]);

        $this->postPunches($ctx['device']->api_username, $ctx['password'], $payload)
            ->assertStatus(202)
            ->assertJson(['accepted' => 0, 'duplicates' => 1]);

        $this->assertSame(
            1,
            AttendancePunch::withoutGlobalScopes()->where('company_id', $ctx['company']->id)->count()
        );
        $this->assertSame(
            1,
            WorkDuration::withoutGlobalScopes()->where('company_id', $ctx['company']->id)->count()
        );
    }

    public function test_unmatched_emp_code_marks_error(): void
    {
        $ctx = $this->seedDeviceAndEmployee();

        $this->postPunches($ctx['device']->api_username, $ctx['password'], [[
            'emp_code' => 'UNKNOWN',
            'punch_datetime' => '2026-07-24 09:00:00',
            'punch_state' => 'Check In',
            'terminal_sn' => $ctx['sn'],
        ]])->assertStatus(202)->assertJson(['accepted' => 1]);

        $punch = AttendancePunch::withoutGlobalScopes()
            ->where('company_id', $ctx['company']->id)
            ->first();
        $this->assertSame(AttendancePunch::STATUS_ERROR, $punch->status);
        $this->assertNull($punch->user_id);
        $this->assertSame(
            0,
            Attendance::withoutGlobalScopes()->where('company_id', $ctx['company']->id)->count()
        );
    }

    public function test_cross_tenant_emp_code_does_not_match(): void
    {
        $ctxA = $this->seedDeviceAndEmployee();

        $companyB = Company::query()->create([
            'name' => 'Other Co',
            'short_name' => 'OTH',
            'email' => 'other-'.Str::lower(Str::random(6)).'@example.com',
            'status' => 'active',
            'timezone' => 'Asia/Kolkata',
        ]);
        User::factory()->create([
            'company_id' => $companyB->id,
            'user_type' => User::USER_TYPE_STAFF,
            'status' => User::STATUS_ACTIVE,
            'employee_number' => $ctxA['empCode'],
        ]);

        $this->postPunches($ctxA['device']->api_username, $ctxA['password'], [[
            'emp_code' => $ctxA['empCode'],
            'punch_datetime' => '2026-07-24 10:00:00',
            'punch_state' => 'Check In',
            'terminal_sn' => $ctxA['sn'],
        ]])->assertStatus(202);

        $attendance = Attendance::withoutGlobalScopes()
            ->where('user_id', $ctxA['employee']->id)
            ->first();
        $this->assertNotNull($attendance);
        $this->assertSame((int) $ctxA['company']->id, (int) $attendance->company_id);

        $this->assertSame(
            0,
            Attendance::withoutGlobalScopes()->where('company_id', $companyB->id)->count()
        );
    }

    public function test_plan_feature_flags_do_not_block_biometric_access(): void
    {
        $ctx = $this->seedDeviceAndEmployee(false);

        $this->postPunches($ctx['device']->api_username, $ctx['password'], [[
            'emp_code' => $ctx['empCode'],
            'punch_datetime' => '2026-07-24 09:00:00',
            'punch_state' => 'Check In',
            'terminal_sn' => $ctx['sn'],
        ]])->assertStatus(202);
    }

    public function test_inactive_device_is_unauthorized(): void
    {
        $ctx = $this->seedDeviceAndEmployee();
        $ctx['device']->update(['is_active' => false]);

        $this->postPunches($ctx['device']->api_username, $ctx['password'], [[
            'emp_code' => $ctx['empCode'],
            'punch_datetime' => '2026-07-24 09:00:00',
            'punch_state' => 'Check In',
            'terminal_sn' => $ctx['sn'],
        ]])->assertStatus(401);
    }

    public function test_login_mode_forces_check_in_even_when_state_is_check_out(): void
    {
        $ctx = $this->seedDeviceAndEmployee(null, BiometricDevice::PUNCH_MODE_LOGIN);

        $this->postPunches($ctx['device']->api_username, $ctx['password'], [[
            'emp_code' => $ctx['empCode'],
            'punch_datetime' => '2026-08-06 09:00:00',
            'punch_state' => 'Check Out',
            'terminal_sn' => $ctx['sn'],
        ]])->assertStatus(202)->assertJson(['accepted' => 1]);

        $punch = AttendancePunch::withoutGlobalScopes()
            ->where('company_id', $ctx['company']->id)
            ->first();
        $this->assertSame(AttendancePunch::STATUS_APPLIED, $punch->status);

        $session = WorkDuration::withoutGlobalScopes()
            ->where('company_id', $ctx['company']->id)
            ->first();
        $this->assertNotNull($session);
        $this->assertNull($session->end_time);
        $this->assertSame('open', $session->status);
    }

    public function test_logout_mode_forces_check_out_even_when_state_is_check_in(): void
    {
        $ctx = $this->seedDeviceAndEmployee(null, BiometricDevice::PUNCH_MODE_LOGIN);

        $this->postPunches($ctx['device']->api_username, $ctx['password'], [[
            'emp_code' => $ctx['empCode'],
            'punch_datetime' => '2026-08-06 09:00:00',
            'punch_state' => 'Check In',
            'terminal_sn' => $ctx['sn'],
        ]])->assertJson(['accepted' => 1]);

        $ctx['device']->update(['punch_mode' => BiometricDevice::PUNCH_MODE_LOGOUT]);

        $this->postPunches($ctx['device']->api_username, $ctx['password'], [[
            'emp_code' => $ctx['empCode'],
            'punch_datetime' => '2026-08-06 18:00:00',
            'punch_state' => 'Check In',
            'terminal_sn' => $ctx['sn'],
        ]])->assertStatus(202)->assertJson(['accepted' => 1]);

        $session = WorkDuration::withoutGlobalScopes()
            ->where('company_id', $ctx['company']->id)
            ->first();
        $this->assertNotNull($session);
        $this->assertSame('18:00:00', substr((string) $session->end_time, 0, 8));
        $this->assertSame('closed', $session->status);
    }

    public function test_both_mode_auto_toggles_when_punch_state_empty(): void
    {
        $ctx = $this->seedDeviceAndEmployee(null, BiometricDevice::PUNCH_MODE_BOTH);

        $this->postPunches($ctx['device']->api_username, $ctx['password'], [[
            'emp_code' => $ctx['empCode'],
            'punch_datetime' => '2026-08-06 09:30:00',
            'punch_state' => '',
            'terminal_sn' => $ctx['sn'],
        ]])->assertStatus(202)->assertJson(['accepted' => 1]);

        $this->postPunches($ctx['device']->api_username, $ctx['password'], [[
            'emp_code' => $ctx['empCode'],
            'punch_datetime' => '2026-08-06 17:30:00',
            'punch_state' => '',
            'terminal_sn' => $ctx['sn'],
        ]])->assertStatus(202)->assertJson(['accepted' => 1]);

        $att = Attendance::withoutGlobalScopes()
            ->where('company_id', $ctx['company']->id)
            ->where('user_id', $ctx['employee']->id)
            ->first();
        $this->assertNotNull($att);
        $this->assertSame('09:30:00', substr((string) $att->clock_in_time, 0, 8));
        $this->assertSame('17:30:00', substr((string) $att->clock_out_time, 0, 8));

        $types = AttendancePunch::withoutGlobalScopes()
            ->where('company_id', $ctx['company']->id)
            ->orderBy('punch_datetime')
            ->pluck('processed_type')
            ->all();
        $this->assertSame([
            AttendancePunch::TYPE_FIRST_IN,
            AttendancePunch::TYPE_LAST_OUT,
        ], $types);
    }

    public function test_both_mode_toggles_when_device_always_sends_check_in(): void
    {
        $ctx = $this->seedDeviceAndEmployee(null, BiometricDevice::PUNCH_MODE_BOTH);

        $this->postPunches($ctx['device']->api_username, $ctx['password'], [[
            'emp_code' => $ctx['empCode'],
            'punch_datetime' => '2026-08-06 09:00:00',
            'punch_state' => 'Check In',
            'terminal_sn' => $ctx['sn'],
        ]])->assertStatus(202)->assertJson(['accepted' => 1]);

        $this->postPunches($ctx['device']->api_username, $ctx['password'], [[
            'emp_code' => $ctx['empCode'],
            'punch_datetime' => '2026-08-06 18:00:00',
            'punch_state' => 'Check In',
            'terminal_sn' => $ctx['sn'],
        ]])->assertStatus(202)->assertJson(['accepted' => 1]);

        $att = Attendance::withoutGlobalScopes()
            ->where('company_id', $ctx['company']->id)
            ->where('user_id', $ctx['employee']->id)
            ->first();
        $this->assertNotNull($att);
        $this->assertSame('09:00:00', substr((string) $att->clock_in_time, 0, 8));
        $this->assertSame('18:00:00', substr((string) $att->clock_out_time, 0, 8));
    }

    public function test_both_mode_honours_explicit_check_in_then_check_out(): void
    {
        $ctx = $this->seedDeviceAndEmployee(null, BiometricDevice::PUNCH_MODE_BOTH);

        $this->postPunches($ctx['device']->api_username, $ctx['password'], [[
            'emp_code' => $ctx['empCode'],
            'punch_datetime' => '2026-08-06 10:00:00',
            'punch_state' => 'Check In',
            'terminal_sn' => $ctx['sn'],
        ]])->assertStatus(202)->assertJson(['accepted' => 1]);

        $this->postPunches($ctx['device']->api_username, $ctx['password'], [[
            'emp_code' => $ctx['empCode'],
            'punch_datetime' => '2026-08-06 19:00:00',
            'punch_state' => 'Check Out',
            'terminal_sn' => $ctx['sn'],
        ]])->assertStatus(202)->assertJson(['accepted' => 1]);

        $att = Attendance::withoutGlobalScopes()
            ->where('company_id', $ctx['company']->id)
            ->where('user_id', $ctx['employee']->id)
            ->first();
        $this->assertNotNull($att);
        $this->assertSame('10:00:00', substr((string) $att->clock_in_time, 0, 8));
        $this->assertSame('19:00:00', substr((string) $att->clock_out_time, 0, 8));
    }

    public function test_numeric_emp_code_matches_with_leading_zeros(): void
    {
        $ctx = $this->seedDeviceAndEmployee();
        $ctx['employee']->update(['employee_number' => '42']);

        $this->postPunches($ctx['device']->api_username, $ctx['password'], [[
            'emp_code' => '0042',
            'punch_datetime' => '2026-08-06 09:00:00',
            'punch_state' => 'Check In',
            'terminal_sn' => $ctx['sn'],
        ]])->assertStatus(202)->assertJson(['accepted' => 1]);

        $punch = AttendancePunch::withoutGlobalScopes()
            ->where('company_id', $ctx['company']->id)
            ->latest('id')
            ->first();
        $this->assertNotNull($punch);
        $this->assertSame(AttendancePunch::STATUS_APPLIED, $punch->status);
        $this->assertSame($ctx['employee']->id, (int) $punch->user_id);

        $this->assertDatabaseHas('attendances', [
            'company_id' => $ctx['company']->id,
            'user_id' => $ctx['employee']->id,
            'clock_in_method' => 'biometric',
        ]);
    }

    public function test_out_of_order_batch_applies_chronologically_for_both_mode(): void
    {
        $ctx = $this->seedDeviceAndEmployee(null, BiometricDevice::PUNCH_MODE_BOTH);

        // Device middleware often posts Check Out before Check In in the same batch.
        $this->postPunches($ctx['device']->api_username, $ctx['password'], [
            [
                'emp_code' => $ctx['empCode'],
                'punch_datetime' => '2026-08-06 18:00:00',
                'punch_state' => 'Check Out',
                'terminal_sn' => $ctx['sn'],
            ],
            [
                'emp_code' => $ctx['empCode'],
                'punch_datetime' => '2026-08-06 09:00:00',
                'punch_state' => 'Check In',
                'terminal_sn' => $ctx['sn'],
            ],
        ])->assertStatus(202)->assertJson(['accepted' => 2]);

        $att = Attendance::withoutGlobalScopes()
            ->where('company_id', $ctx['company']->id)
            ->where('user_id', $ctx['employee']->id)
            ->first();
        $this->assertNotNull($att);
        $this->assertSame('09:00:00', substr((string) $att->clock_in_time, 0, 8));
        $this->assertSame('18:00:00', substr((string) $att->clock_out_time, 0, 8));

        $this->assertSame(
            2,
            AttendancePunch::withoutGlobalScopes()
                ->where('company_id', $ctx['company']->id)
                ->where('status', AttendancePunch::STATUS_APPLIED)
                ->count()
        );
    }

    public function test_monthly_sync_reprocesses_ignored_punches_in_order(): void
    {
        $ctx = $this->seedDeviceAndEmployee(null, BiometricDevice::PUNCH_MODE_BOTH);

        AttendancePunch::withoutGlobalScopes()->create([
            'company_id' => $ctx['company']->id,
            'biometric_device_id' => $ctx['device']->id,
            'emp_code' => $ctx['empCode'],
            'user_id' => $ctx['employee']->id,
            'punch_datetime' => '2026-08-06 18:00:00',
            'punch_date' => '2026-08-06',
            'attendance_date' => '2026-08-06',
            'punch_time' => '18:00:00',
            'punch_state' => 'Check Out',
            'terminal_sn' => $ctx['sn'],
            'status' => AttendancePunch::STATUS_IGNORED,
            'error_message' => 'No open session to close.',
            'processed_at' => now(),
        ]);

        $this->postPunches($ctx['device']->api_username, $ctx['password'], [[
            'emp_code' => $ctx['empCode'],
            'punch_datetime' => '2026-08-06 09:00:00',
            'punch_state' => 'Check In',
            'terminal_sn' => $ctx['sn'],
        ]])->assertJson(['accepted' => 1]);

        $summary = app(\App\Services\Biometric\DeviceMonthlyPunchSyncService::class)
            ->syncMonth($ctx['device'], \Carbon\Carbon::parse('2026-08-01'));

        $this->assertSame(2, $summary['total']);
        $this->assertSame(2, $summary['applied']);

        $att = Attendance::withoutGlobalScopes()
            ->where('company_id', $ctx['company']->id)
            ->where('user_id', $ctx['employee']->id)
            ->whereDate('date', '2026-08-06')
            ->first();
        $this->assertNotNull($att);
        $this->assertSame('09:00:00', substr((string) $att->clock_in_time, 0, 8));
        $this->assertSame('18:00:00', substr((string) $att->clock_out_time, 0, 8));
    }

    public function test_both_mode_ignores_stale_open_sessions_from_past_months(): void
    {
        $ctx = $this->seedDeviceAndEmployee(null, BiometricDevice::PUNCH_MODE_BOTH);

        $oldAttendance = Attendance::withoutGlobalScopes()->create([
            'company_id' => $ctx['company']->id,
            'user_id' => $ctx['employee']->id,
            'date' => '2026-05-01',
            'status' => 'present',
            'clock_in_date_time' => '2026-05-01 09:00:00',
            'clock_in_time' => '09:00:00',
        ]);

        WorkDuration::withoutGlobalScopes()->create([
            'company_id' => $ctx['company']->id,
            'attendance_id' => $oldAttendance->id,
            'start_time' => '09:00:00',
            'end_time' => null,
            'status' => 'open',
            'notes' => 'stale-web',
        ]);

        $this->postPunches($ctx['device']->api_username, $ctx['password'], [[
            'emp_code' => $ctx['empCode'],
            'punch_datetime' => '2026-09-07 10:00:00',
            'punch_state' => 'Check In',
            'terminal_sn' => $ctx['sn'],
        ]])->assertStatus(202)->assertJson(['accepted' => 1]);

        $punch = AttendancePunch::withoutGlobalScopes()
            ->where('company_id', $ctx['company']->id)
            ->latest('id')
            ->first();
        $this->assertSame(AttendancePunch::STATUS_APPLIED, $punch->status);

        $today = Attendance::withoutGlobalScopes()
            ->where('company_id', $ctx['company']->id)
            ->where('user_id', $ctx['employee']->id)
            ->whereDate('date', '2026-09-07')
            ->first();
        $this->assertNotNull($today);
        $this->assertSame('biometric', $today->clock_in_method);
        $this->assertNull($today->clock_out_date_time);

        $oldAttendance->refresh();
        $this->assertNull($oldAttendance->clock_out_date_time);

        $staleStillOpen = WorkDuration::withoutGlobalScopes()
            ->where('attendance_id', $oldAttendance->id)
            ->whereNull('end_time')
            ->exists();
        $this->assertTrue($staleStillOpen);
    }

    public function test_stale_open_session_cleaner_closes_old_opens(): void
    {
        $ctx = $this->seedDeviceAndEmployee();

        $oldAttendance = Attendance::withoutGlobalScopes()->create([
            'company_id' => $ctx['company']->id,
            'user_id' => $ctx['employee']->id,
            'date' => '2026-04-01',
            'status' => 'present',
        ]);

        $wd = WorkDuration::withoutGlobalScopes()->create([
            'company_id' => $ctx['company']->id,
            'attendance_id' => $oldAttendance->id,
            'start_time' => '10:00:00',
            'end_time' => null,
            'status' => 'open',
        ]);

        $summary = app(\App\Services\Biometric\StaleOpenSessionCleaner::class)->closeStale(
            companyId: (int) $ctx['company']->id,
            maxAgeHours: 20,
            beforeDate: null,
            dryRun: false
        );

        $this->assertGreaterThanOrEqual(1, $summary['closed']);
        $wd->refresh();
        $this->assertNotNull($wd->end_time);
        $this->assertSame('closed', $wd->status);
        $this->assertStringContainsString('abandoned:stale_open', (string) $wd->notes);
    }
}
