<?php

namespace classes;

use DateTime;
use DateTimeZone;
use Exception;

final class workfeed_shift_time_resolver
{
    private const MAX_UNAPPROVED_EXTENSION_SECONDS = 6 * 3600;
    private const MAX_UNAPPROVED_SHIFT_SPAN_SECONDS = 24 * 3600;

    /**
     * @return array{actualStart:DateTime,scheduledEnd:DateTime,actualEnd:DateTime,hasApproval:bool}|null
     */
    public static function resolveShiftTiming(mixed $record_value): ?array
    {
        $record = self::normalizeRecord($record_value);
        if ($record === []) {
            return null;
        }

        $has_approval = self::hasShiftApproval($record);
        $update_time = self::parseDateTimeValue($record['updateTime'] ?? null);
        $can_use_saved_bounds = $has_approval || $update_time !== null;

        $actual_start = self::firstDateTimeFromPaths($record, [
            'checkIn.time',
            'checkIn',
            'actualStart',
            'actualStartTime',
            'clockIn',
            'clockInTime',
            'approval.originalStart',
        ]);
        if ($actual_start === null && $can_use_saved_bounds) {
            $actual_start = self::firstDateTimeFromPaths($record, [
                'start',
                'startTime',
                'from',
            ]);
        }

        $scheduled_end = self::firstDateTimeFromPaths($record, [
            'approval.originalEnd',
            'end',
            'endTime',
            'to',
        ]);
        $actual_only_end = self::firstDateTimeFromPaths($record, [
            'checkOut.time',
            'checkOut',
            'actualEnd',
            'actualEndTime',
            'clockOut',
            'clockOutTime',
        ]);
        $check_in_punch = self::firstDateTimeFromPaths($record, [
            'checkIn.time',
            'checkIn',
        ]);
        $check_out_punch = self::firstDateTimeFromPaths($record, [
            'checkOut.time',
            'checkOut',
        ]);
        $saved_actual_end = $actual_only_end;
        if ($saved_actual_end === null && $can_use_saved_bounds) {
            $saved_actual_end = self::firstDateTimeFromPaths($record, [
                'end',
                'endTime',
                'to',
            ]);
        }

        if ($saved_actual_end === null && $check_in_punch !== null && $check_out_punch === null) {
            $saved_actual_end = new DateTime();
        }

        if ($actual_start === null || $scheduled_end === null || $saved_actual_end === null) {
            return null;
        }

        $actual_end = self::resolveActualEnd($record, $actual_start, $scheduled_end, $saved_actual_end, $actual_only_end);
        if ($actual_end->getTimestamp() <= $actual_start->getTimestamp()) {
            return null;
        }

        return [
            'actualStart' => $actual_start,
            'scheduledEnd' => $scheduled_end,
            'actualEnd' => $actual_end,
            'hasApproval' => $has_approval,
        ];
    }

    public static function calculateOvertimeHoursInRange(mixed $record_value, DateTime $range_start, DateTime $range_end_exclusive): float
    {
        $timing = self::resolveShiftTiming($record_value);
        if ($timing === null) {
            return 0.0;
        }

        $scheduled_end_ts = $timing['scheduledEnd']->getTimestamp();
        $actual_end_ts = $timing['actualEnd']->getTimestamp();
        if ($actual_end_ts <= $scheduled_end_ts) {
            return 0.0;
        }

        $overtime_start_ts = max($scheduled_end_ts, $range_start->getTimestamp());
        $overtime_end_ts = min($actual_end_ts, $range_end_exclusive->getTimestamp());
        if ($overtime_end_ts <= $overtime_start_ts) {
            return 0.0;
        }

        return round(($overtime_end_ts - $overtime_start_ts) / 3600, 2);
    }

    /**
     * @return array<string,mixed>
     */
    private static function normalizeRecord(mixed $record): array
    {
        if (is_array($record)) {
            return $record;
        }
        if (is_object($record)) {
            return get_object_vars($record);
        }

        return [];
    }

    private static function getNestedRecordValue(array $record, string $path): mixed
    {
        $segments = explode('.', $path);
        $current = $record;

        foreach ($segments as $segment) {
            if (is_array($current)) {
                if (!array_key_exists($segment, $current)) {
                    return null;
                }
                $current = $current[$segment];
                continue;
            }

            if (is_object($current)) {
                if (!property_exists($current, $segment)) {
                    return null;
                }
                $current = $current->$segment;
                continue;
            }

            return null;
        }

        return $current;
    }

    /**
     * @param array<int,string> $paths
     */
    private static function firstDateTimeFromPaths(array $record, array $paths): ?DateTime
    {
        foreach ($paths as $path) {
            $parsed = self::parseDateTimeValue(self::getNestedRecordValue($record, $path));
            if ($parsed !== null) {
                return $parsed;
            }
        }

        return null;
    }

    private static function parseDateTimeValue(mixed $value): ?DateTime
    {
        if (is_string($value)) {
            $normalized = trim($value);
            if ($normalized === '') {
                return null;
            }

            try {
                return new DateTime($normalized);
            } catch (Exception) {
                if (!is_numeric($normalized)) {
                    return null;
                }
                $value = (float)$normalized;
            }
        }

        if (is_int($value) || is_float($value)) {
            if (!is_finite((float)$value)) {
                return null;
            }

            $timestamp = (float)$value;
            if ($timestamp > 9999999999) {
                $timestamp /= 1000;
            }

            try {
                $date = new DateTime('@' . (string)(int)round($timestamp));
                $date->setTimezone(new DateTimeZone('UTC'));
                return $date;
            } catch (Exception) {
                return null;
            }
        }

        $record = self::normalizeRecord($value);
        foreach (['seconds', '_seconds', 'epochSeconds', 'timestamp'] as $key) {
            if (!array_key_exists($key, $record)) {
                continue;
            }

            $parsed = self::parseDateTimeValue($record[$key]);
            if ($parsed !== null) {
                return $parsed;
            }
        }

        return null;
    }

    private static function hasShiftApproval(array $record): bool
    {
        if (!array_key_exists('approval', $record)) {
            return false;
        }

        $approval = $record['approval'];
        if ($approval === null) {
            return false;
        }
        if (is_array($approval)) {
            return $approval !== [];
        }
        if (is_object($approval)) {
            return get_object_vars($approval) !== [];
        }

        return true;
    }

    private static function resolveActualEnd(
        array $record,
        DateTime $actual_start,
        DateTime $scheduled_end,
        DateTime $saved_actual_end,
        ?DateTime $actual_only_end
    ): DateTime {
        if (self::hasShiftApproval($record) || $actual_only_end !== null) {
            return $saved_actual_end;
        }

        $update_time = self::parseDateTimeValue($record['updateTime'] ?? null);
        if ($update_time === null) {
            return $saved_actual_end;
        }

        $shift_start_ts = $actual_start->getTimestamp();
        $scheduled_end_ts = $scheduled_end->getTimestamp();
        $saved_actual_end_ts = $saved_actual_end->getTimestamp();
        $update_ts = $update_time->getTimestamp();
        $fallback_base_ts = max($saved_actual_end_ts, $scheduled_end_ts);

        if ($update_ts <= $fallback_base_ts) {
            return $saved_actual_end;
        }

        // Only use updateTime as an overtime hint when no explicit actual end was saved.
        if (($update_ts - $scheduled_end_ts) > self::MAX_UNAPPROVED_EXTENSION_SECONDS) {
            return $saved_actual_end;
        }
        if (($update_ts - $shift_start_ts) > self::MAX_UNAPPROVED_SHIFT_SPAN_SECONDS) {
            return $saved_actual_end;
        }

        return $update_time;
    }
}
