<?php

namespace App\Services\Attendance;

use App\Models\Attendance;
use App\Models\AttendancePolicy;
use App\Models\Location;
use App\Models\User;
use App\Support\Geo\Haversine;
use Illuminate\Support\Collection;

class AttendancePunchValidator
{
    public const METHOD_WEB = 'web';

    public const METHOD_MOBILE = 'mobile';

    public const METHOD_BIOMETRIC = 'biometric';

    public const TYPE_OFFICE = 'office';

    public const TYPE_REMOTE = 'remote';

    public const TYPE_NORMAL = 'normal';

    public function __construct(
        protected AttendancePolicyResolver $policyResolver
    ) {}

    /**
     * @param  array<string, mixed>  $input
     */
    public function validate(User $user, array $input, string $method, bool $sessionAnchored = false): AttendancePunchDecision
    {
        if (! $user->isWorkforceMember()) {
            throw new \RuntimeException('Attendance is only available for employee accounts.');
        }

        if (! $user->company_id) {
            throw new \RuntimeException('No company assigned.');
        }

        $policy = $this->policyResolver->resolveForUser($user);

        if ($method === self::METHOD_WEB && ! $policy->allowWebCheckin) {
            throw new \RuntimeException('Web check-in is not allowed by your attendance policy.');
        }

        if ($method === self::METHOD_MOBILE && ! $policy->allowMobileCheckin) {
            throw new \RuntimeException('Mobile check-in is not allowed by your attendance policy.');
        }
        $intent = $this->normalizeIntent($input['attendance_type'] ?? null);
        $mocked = $this->isTruthy($input['is_mocked'] ?? false);
        $latitude = $this->nullableFloat($input['latitude'] ?? null);
        $longitude = $this->nullableFloat($input['longitude'] ?? null);
        $accuracy = $this->nullableFloat($input['accuracy'] ?? null);
        $locationLabel = isset($input['location']) && is_string($input['location'])
            ? trim($input['location'])
            : null;
        $remoteReason = isset($input['remote_reason']) && is_string($input['remote_reason'])
            ? trim($input['remote_reason'])
            : null;

        // Hybrid check-in: ignore client type; resolve office vs remote from GPS.
        if ($policy->isHybrid() && ! $sessionAnchored) {
            return $this->validateHybridAuto(
                $user,
                $policy,
                $method,
                $latitude,
                $longitude,
                $accuracy,
                $locationLabel,
                $remoteReason,
                $mocked
            );
        }

        $resolvedType = $this->resolveType($policy, $intent);

        if ($resolvedType === self::TYPE_REMOTE) {
            return $this->remoteDecision(
                $method,
                $policy,
                $latitude,
                $longitude,
                $accuracy,
                $locationLabel,
                $remoteReason
            );
        }

        if ($resolvedType === self::TYPE_NORMAL) {
            return new AttendancePunchDecision(
                attendanceType: self::TYPE_NORMAL,
                method: $method,
                latitude: $latitude,
                longitude: $longitude,
                accuracy: $accuracy,
                locationLabel: $locationLabel !== '' ? $locationLabel : null,
                locationId: null,
                distanceMeters: null,
                officeName: null,
                remoteReason: null,
                remoteApprovalStatus: null,
            );
        }

        if ($mocked) {
            throw new \RuntimeException('Mocked or spoofed location is not allowed for office check-in.');
        }

        return $this->validateOffice(
            $user,
            $policy,
            $method,
            $latitude,
            $longitude,
            $accuracy,
            $locationLabel
        );
    }

    /**
     * Check-out validation anchored to the open session's attendance type.
     *
     * @param  array<string, mixed>  $input
     */
    public function validateCheckOut(User $user, Attendance $attendance, array $input, string $method): AttendancePunchDecision
    {
        $sessionType = $this->normalizeIntent($attendance->attendance_type)
            ?? $this->inferLegacySessionType($attendance, $user);

        $merged = array_merge($input, [
            'attendance_type' => $sessionType,
        ]);

        if ($sessionType === self::TYPE_REMOTE && $attendance->remote_reason !== null && trim((string) $attendance->remote_reason) !== '') {
            $merged['remote_reason'] = $attendance->remote_reason;
        }

        return $this->validate($user, $merged, $method, sessionAnchored: true);
    }

    private function inferLegacySessionType(Attendance $attendance, User $user): string
    {
        if ($attendance->remote_reason !== null && trim((string) $attendance->remote_reason) !== '') {
            return self::TYPE_REMOTE;
        }

        if ($attendance->remote_approval_status !== null && trim((string) $attendance->remote_approval_status) !== '') {
            return self::TYPE_REMOTE;
        }

        if ($attendance->clock_in_method === self::METHOD_BIOMETRIC) {
            return self::TYPE_OFFICE;
        }

        $policy = $this->policyResolver->resolveForUser($user);

        if ($policy->isRemoteOnly()) {
            return self::TYPE_REMOTE;
        }

        if ($policy->isNormal()) {
            return self::TYPE_NORMAL;
        }

        if ($policy->allowsRemote() && $attendance->clock_in_location_id === null) {
            return self::TYPE_REMOTE;
        }

        return self::TYPE_OFFICE;
    }

    private function resolveType(
        ResolvedAttendancePolicy $policy,
        ?string $intent
    ): string {
        if ($policy->isNormal()) {
            if ($intent === self::TYPE_REMOTE) {
                throw new \RuntimeException('Your attendance policy does not allow remote check-in.');
            }

            return self::TYPE_NORMAL;
        }

        if ($policy->isRemoteOnly()) {
            if ($intent === self::TYPE_OFFICE) {
                throw new \RuntimeException('Your attendance policy does not allow office check-in.');
            }

            return self::TYPE_REMOTE;
        }

        if ($policy->isOfficeOnly()) {
            if ($intent === self::TYPE_REMOTE) {
                throw new \RuntimeException('Your attendance policy does not allow remote check-in.');
            }

            return self::TYPE_OFFICE;
        }

        if ($intent === self::TYPE_REMOTE) {
            return self::TYPE_REMOTE;
        }

        return self::TYPE_OFFICE;
    }

    /**
     * Hybrid check-in: GPS decides office vs remote. Client attendance_type is ignored.
     */
    private function validateHybridAuto(
        User $user,
        ResolvedAttendancePolicy $policy,
        string $method,
        ?float $latitude,
        ?float $longitude,
        ?float $accuracy,
        ?string $locationLabel,
        ?string $remoteReason,
        bool $mocked
    ): AttendancePunchDecision {
        if ($mocked) {
            throw new \RuntimeException('Mocked or spoofed location is not allowed for check-in.');
        }

        if ($latitude === null || $longitude === null) {
            throw new \RuntimeException(
                'Location is required for check-in. Allow location access, then try again.'
            );
        }

        if (! $this->isValidCoordinate($latitude, $longitude)) {
            throw new \RuntimeException('Unable to determine your location.');
        }

        if ($policy->gpsAccuracyRequirement !== null) {
            if ($accuracy === null) {
                throw new \RuntimeException('GPS accuracy could not be determined.');
            }
            if ($accuracy > $policy->gpsAccuracyRequirement) {
                throw new \RuntimeException(
                    'GPS accuracy is too low ('.((int) round($accuracy)).'m). Required: '.$policy->gpsAccuracyRequirement.'m.'
                );
            }
        }

        $match = $this->nearestMatchingOffice($policy, $user, $latitude, $longitude);
        if ($match !== null) {
            /** @var Location $office */
            $office = $match['office'];
            $distance = (int) round($match['distance']);
            $label = $locationLabel !== null && $locationLabel !== ''
                ? $locationLabel
                : $office->name;

            return new AttendancePunchDecision(
                attendanceType: self::TYPE_OFFICE,
                method: $method,
                latitude: $latitude,
                longitude: $longitude,
                accuracy: $accuracy,
                locationLabel: $label,
                locationId: (int) $office->id,
                distanceMeters: $distance,
                officeName: $office->name,
                remoteReason: null,
                remoteApprovalStatus: null,
            );
        }

        return $this->remoteDecision(
            $method,
            $policy,
            $latitude,
            $longitude,
            $accuracy,
            $locationLabel,
            $remoteReason
        );
    }

    private function remoteDecision(
        string $method,
        ResolvedAttendancePolicy $policy,
        ?float $latitude,
        ?float $longitude,
        ?float $accuracy,
        ?string $locationLabel,
        ?string $remoteReason
    ): AttendancePunchDecision {
        return new AttendancePunchDecision(
            attendanceType: self::TYPE_REMOTE,
            method: $method,
            latitude: $latitude,
            longitude: $longitude,
            accuracy: $accuracy,
            locationLabel: $locationLabel !== null && $locationLabel !== '' ? $locationLabel : null,
            locationId: null,
            distanceMeters: null,
            officeName: null,
            remoteReason: $remoteReason !== null && $remoteReason !== '' ? $remoteReason : null,
            remoteApprovalStatus: $policy->remoteApprovalRequired ? 'pending' : null,
        );
    }

    private function validateOffice(
        User $user,
        ResolvedAttendancePolicy $policy,
        string $method,
        ?float $latitude,
        ?float $longitude,
        ?float $accuracy,
        ?string $locationLabel
    ): AttendancePunchDecision {
        if ($latitude === null || $longitude === null) {
            if ($policy->requireLocationForOffice) {
                throw new \RuntimeException(
                    'Location is required for office check-in. Allow location access in your browser, then try again.'
                );
            }

            // GPS optional: allow office punch without geofence match.
            return new AttendancePunchDecision(
                attendanceType: self::TYPE_OFFICE,
                method: $method,
                latitude: null,
                longitude: null,
                accuracy: null,
                locationLabel: $locationLabel !== null && $locationLabel !== '' ? $locationLabel : null,
                locationId: null,
                distanceMeters: null,
                officeName: null,
                remoteReason: null,
                remoteApprovalStatus: null,
            );
        }

        if (! $this->isValidCoordinate($latitude, $longitude)) {
            throw new \RuntimeException('Unable to determine your location.');
        }

        if ($policy->gpsAccuracyRequirement !== null) {
            if ($accuracy === null) {
                throw new \RuntimeException('GPS accuracy could not be determined.');
            }
            if ($accuracy > $policy->gpsAccuracyRequirement) {
                throw new \RuntimeException(
                    'GPS accuracy is too low ('.((int) round($accuracy)).'m). Required: '.$policy->gpsAccuracyRequirement.'m.'
                );
            }
        }

        $match = $this->nearestMatchingOffice($policy, $user, $latitude, $longitude);
        if ($match === null) {
            $offices = $this->activeGeofencedOffices((int) $user->company_id);
            if ($offices->isEmpty()) {
                throw new \RuntimeException('No valid office location is configured.');
            }

            $nearest = $this->nearestOffice($offices, $user, $latitude, $longitude, false);
            $message = 'You are outside the permitted office location.';
            if ($nearest !== null) {
                $allowed = (int) ($nearest['office']->radius_meters ?: $policy->defaultRadiusMeters);
                $message .= ' Distance: '.((int) round($nearest['distance'])).'m. Allowed: '.$allowed.'m.';
            }

            throw new \RuntimeException($message);
        }

        /** @var Location $office */
        $office = $match['office'];
        $distance = (int) round($match['distance']);
        $label = $locationLabel !== null && $locationLabel !== ''
            ? $locationLabel
            : $office->name;

        return new AttendancePunchDecision(
            attendanceType: self::TYPE_OFFICE,
            method: $method,
            latitude: $latitude,
            longitude: $longitude,
            accuracy: $accuracy,
            locationLabel: $label,
            locationId: (int) $office->id,
            distanceMeters: $distance,
            officeName: $office->name,
            remoteReason: null,
            remoteApprovalStatus: null,
        );
    }

    /**
     * @return array{office: Location, distance: float}|null
     */
    private function nearestMatchingOffice(
        ResolvedAttendancePolicy $policy,
        ?User $user,
        float $latitude,
        float $longitude
    ): ?array {
        $companyId = (int) ($user?->company_id ?? 0);
        $offices = $this->activeGeofencedOffices($companyId);
        if ($offices->isEmpty()) {
            return null;
        }

        $assignedId = $user?->location_id ? (int) $user->location_id : null;
        $assigned = $assignedId
            ? $offices->first(fn (Location $office) => (int) $office->id === $assignedId)
            : null;

        if ($assigned) {
            $distance = Haversine::distanceMeters(
                $latitude,
                $longitude,
                (float) $assigned->latitude,
                (float) $assigned->longitude
            );
            $radius = (int) ($assigned->radius_meters ?: $policy->defaultRadiusMeters);
            if ($distance <= $radius) {
                return ['office' => $assigned, 'distance' => $distance];
            }
        }

        $best = null;
        foreach ($offices as $office) {
            $distance = Haversine::distanceMeters(
                $latitude,
                $longitude,
                (float) $office->latitude,
                (float) $office->longitude
            );
            $radius = (int) ($office->radius_meters ?: $policy->defaultRadiusMeters);
            if ($distance > $radius) {
                continue;
            }
            if ($best === null || $distance < $best['distance']) {
                $best = ['office' => $office, 'distance' => $distance];
            }
        }

        return $best;
    }

    /**
     * @param  Collection<int, Location>  $offices
     * @return array{office: Location, distance: float}|null
     */
    private function nearestOffice($offices, ?User $user, float $latitude, float $longitude, bool $withinRadiusOnly): ?array
    {
        $policyRadius = $user
            ? $this->policyResolver->resolveForUser($user)->defaultRadiusMeters
            : AttendancePolicy::DEFAULT_RADIUS_METERS;
        $best = null;
        foreach ($offices as $office) {
            $distance = Haversine::distanceMeters(
                $latitude,
                $longitude,
                (float) $office->latitude,
                (float) $office->longitude
            );
            $radius = (int) ($office->radius_meters ?: $policyRadius);
            if ($withinRadiusOnly && $distance > $radius) {
                continue;
            }
            if ($best === null || $distance < $best['distance']) {
                $best = ['office' => $office, 'distance' => $distance];
            }
        }

        return $best;
    }

    /**
     * @return Collection<int, Location>
     */
    public function activeGeofencedOffices(int $companyId)
    {
        return Location::query()
            ->where('company_id', $companyId)
            ->where('is_active', true)
            ->whereNotNull('latitude')
            ->whereNotNull('longitude')
            ->orderBy('name')
            ->get();
    }

    private function normalizeIntent(mixed $intent): ?string
    {
        if (! is_string($intent) || trim($intent) === '') {
            return null;
        }

        $normalized = strtolower(trim($intent));

        return in_array($normalized, [self::TYPE_OFFICE, self::TYPE_REMOTE, self::TYPE_NORMAL], true)
            ? $normalized
            : null;
    }

    private function nullableFloat(mixed $value): ?float
    {
        if ($value === null || $value === '') {
            return null;
        }
        if (! is_numeric($value)) {
            return null;
        }

        return (float) $value;
    }

    private function isValidCoordinate(float $latitude, float $longitude): bool
    {
        return $latitude >= -90 && $latitude <= 90 && $longitude >= -180 && $longitude <= 180;
    }

    private function isTruthy(mixed $value): bool
    {
        return filter_var($value, FILTER_VALIDATE_BOOLEAN) === true;
    }
}
