<?php

namespace classes;

use DateInterval;
use DatePeriod;
use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use Exception;
use helpers\xlvask_usage_log;

class department_outside_hours_statistics_service
{
    public const SOURCE_ORDERS = 'orders';
    public const SOURCE_XLVASK = 'xlvask';
    public const SOURCE_SELFSERVE = 'selfserve';

    /**
     * Higher number means higher precedence when multiple records map to the same physical wash.
     * @var array<string,int>
     */
    private array $source_priority = [
        self::SOURCE_ORDERS => 1,
        self::SOURCE_XLVASK => 2,
        self::SOURCE_SELFSERVE => 3,
    ];

    private DateTimeZone $timezone;

    public function __construct(?DateTimeZone $timezone = null)
    {
        $this->timezone = $timezone ?? new DateTimeZone('Europe/Copenhagen');
    }

    /**
     * @param array<int|string>|int|string $department_ids
     * @return array{
     *   department_ids:array<int>,
     *   date:string,
     *   date_to:string,
     *   total:int,
     *   by_source:array{orders:int,xlvask:int,selfserve:int},
     *   has_missing_opening_hours:bool,
     *   missing_department_ids:array<int>
     * }
     * @throws Exception
     */
    public function getSummary(string $date, array|int|string $department_ids, ?string $date_to = null): array
    {
        $normalized_department_ids = $this->normalizeDepartmentIds($department_ids);
        $resolved_date_to = $date_to ?? $date;

        if ($normalized_department_ids === []) {
            return $this->emptySummary($date, $resolved_date_to, []);
        }

        return $this->summarizeCandidates(
            $this->fetchCandidates($date, $resolved_date_to, $normalized_department_ids),
            $this->fetchOpeningHoursByDepartmentId($normalized_department_ids),
            $normalized_department_ids,
            $date,
            $resolved_date_to
        );
    }

    /**
     * @param array<int|string>|int|string $department_ids
     * @return array{
     *   department_ids:array<int>,
     *   date:string,
     *   date_to:string,
     *   points:array<int,array{
     *     date:string,
     *     total:int,
     *     by_source:array{orders:int,xlvask:int,selfserve:int},
     *     has_missing_opening_hours:bool,
     *     missing_department_ids:array<int>
     *   }>,
     *   has_missing_opening_hours:bool,
     *   missing_department_ids:array<int>
     * }
     * @throws Exception
     */
    public function getTrend(string $date, string $date_to, array|int|string $department_ids): array
    {
        $normalized_department_ids = $this->normalizeDepartmentIds($department_ids);
        if ($normalized_department_ids === []) {
            return [
                'department_ids' => [],
                'date' => $date,
                'date_to' => $date_to,
                'points' => [],
                'has_missing_opening_hours' => false,
                'missing_department_ids' => [],
            ];
        }

        return $this->buildTrendFromCandidates(
            $this->fetchCandidates($date, $date_to, $normalized_department_ids),
            $this->fetchOpeningHoursByDepartmentId($normalized_department_ids),
            $normalized_department_ids,
            $date,
            $date_to
        );
    }

    /**
     * @param array{
     *   total?:int,
     *   by_source?:array{orders?:int,xlvask?:int,selfserve?:int},
     *   has_missing_opening_hours?:bool,
     *   missing_department_ids?:array<int|string>
     * } $summary
     * @return array<string,mixed>
     */
    public function toOverviewMetric(array $summary): array
    {
        return [
            'state' => 'ready',
            'value' => (int)($summary['total'] ?? 0),
            'out_of' => null,
            'message' => null,
            'by_source' => [
                self::SOURCE_ORDERS => (int)($summary['by_source'][self::SOURCE_ORDERS] ?? 0),
                self::SOURCE_XLVASK => (int)($summary['by_source'][self::SOURCE_XLVASK] ?? 0),
                self::SOURCE_SELFSERVE => (int)($summary['by_source'][self::SOURCE_SELFSERVE] ?? 0),
            ],
            'has_missing_opening_hours' => (bool)($summary['has_missing_opening_hours'] ?? false),
            'missing_department_ids' => array_values(array_map('intval', $summary['missing_department_ids'] ?? [])),
        ];
    }

    /**
     * @param array<int,array<string,mixed>> $candidates
     * @param array<int,array<string,mixed>> $opening_hours_by_department_id
     * @param array<int|string>|int|string $department_ids
     * @return array{
     *   department_ids:array<int>,
     *   date:string,
     *   date_to:string,
     *   total:int,
     *   by_source:array{orders:int,xlvask:int,selfserve:int},
     *   has_missing_opening_hours:bool,
     *   missing_department_ids:array<int>
     * }
     * @throws Exception
     */
    public function summarizeCandidates(
        array $candidates,
        array $opening_hours_by_department_id,
        array|int|string $department_ids,
        string $date,
        string $date_to
    ): array {
        $normalized_department_ids = $this->normalizeDepartmentIds($department_ids);
        $summary = $this->emptySummary($date, $date_to, $normalized_department_ids);
        $missing_diagnostics = $this->buildMissingOpeningHoursDiagnostics($normalized_department_ids, $opening_hours_by_department_id, $date, $date_to);
        $missing_lookup = $this->missingDepartmentLookupByDay($missing_diagnostics['by_day']);

        foreach ($this->deduplicateCandidates($candidates) as $candidate) {
            $classification = $this->classifyCandidateAgainstOpeningHours(
                $candidate,
                $opening_hours_by_department_id,
                $missing_lookup,
                $date,
                $date_to
            );
            if (($classification['counted'] ?? false) !== true) {
                continue;
            }

            $source = (string)$candidate['source'];
            $summary['total']++;
            $summary['by_source'][$source] = (int)($summary['by_source'][$source] ?? 0) + 1;
        }

        $summary['has_missing_opening_hours'] = (bool)$missing_diagnostics['has_missing_opening_hours'];
        $summary['missing_department_ids'] = $missing_diagnostics['missing_department_ids'];

        return $summary;
    }

    /**
     * @param array<int,array<string,mixed>> $candidates
     * @param array<int,array<string,mixed>> $opening_hours_by_department_id
     * @param array<int|string>|int|string $department_ids
     * @return array{
     *   department_ids:array<int>,
     *   date:string,
     *   date_to:string,
     *   points:array<int,array{
     *     date:string,
     *     total:int,
     *     by_source:array{orders:int,xlvask:int,selfserve:int},
     *     has_missing_opening_hours:bool,
     *     missing_department_ids:array<int>
     *   }>,
     *   has_missing_opening_hours:bool,
     *   missing_department_ids:array<int>
     * }
     * @throws Exception
     */
    public function buildTrendFromCandidates(
        array $candidates,
        array $opening_hours_by_department_id,
        array|int|string $department_ids,
        string $date,
        string $date_to
    ): array {
        $normalized_department_ids = $this->normalizeDepartmentIds($department_ids);
        $missing_diagnostics = $this->buildMissingOpeningHoursDiagnostics($normalized_department_ids, $opening_hours_by_department_id, $date, $date_to);
        $missing_lookup = $this->missingDepartmentLookupByDay($missing_diagnostics['by_day']);

        $points = [];
        foreach ($this->dateRange($date, $date_to) as $current_date) {
            $point_date = $current_date->format('Y-m-d');
            $points[$point_date] = [
                'date' => $point_date,
                'total' => 0,
                'by_source' => $this->emptyBySource(),
                'has_missing_opening_hours' => isset($missing_lookup[$point_date]),
                'missing_department_ids' => array_values(array_map(
                    'intval',
                    array_keys($missing_lookup[$point_date] ?? [])
                )),
            ];
        }

        foreach ($this->deduplicateCandidates($candidates) as $candidate) {
            $classification = $this->classifyCandidateAgainstOpeningHours(
                $candidate,
                $opening_hours_by_department_id,
                $missing_lookup,
                $date,
                $date_to
            );
            if (($classification['counted'] ?? false) !== true) {
                continue;
            }

            $point_date = (string)$classification['candidate_date'];
            $source = (string)$candidate['source'];
            if (!isset($points[$point_date])) {
                $points[$point_date] = [
                    'date' => $point_date,
                    'total' => 0,
                    'by_source' => $this->emptyBySource(),
                    'has_missing_opening_hours' => false,
                    'missing_department_ids' => [],
                ];
            }

            $points[$point_date]['total']++;
            $points[$point_date]['by_source'][$source] = (int)($points[$point_date]['by_source'][$source] ?? 0) + 1;
        }

        return [
            'department_ids' => $normalized_department_ids,
            'date' => $date,
            'date_to' => $date_to,
            'points' => array_values($points),
            'has_missing_opening_hours' => (bool)$missing_diagnostics['has_missing_opening_hours'],
            'missing_department_ids' => $missing_diagnostics['missing_department_ids'],
        ];
    }

    /**
     * @param array<int,array<string,mixed>> $candidates
     * @return array<int,array<string,mixed>>
     */
    public function deduplicateCandidates(array $candidates): array
    {
        $deduplicated = [];
        foreach ($candidates as $candidate) {
            $dedupe_key = trim((string)($candidate['dedupe_key'] ?? ''));
            if ($dedupe_key === '') {
                continue;
            }

            if (!isset($deduplicated[$dedupe_key])) {
                $deduplicated[$dedupe_key] = $candidate;
                continue;
            }

            if ($this->shouldReplaceDeduplicatedCandidate($deduplicated[$dedupe_key], $candidate)) {
                $deduplicated[$dedupe_key] = $candidate;
            }
        }

        uasort($deduplicated, function (array $left, array $right): int {
            $left_start = trim((string)($left['start_at'] ?? ''));
            $right_start = trim((string)($right['start_at'] ?? ''));
            return strcmp($left_start, $right_start);
        });

        return array_values($deduplicated);
    }

    /**
     * @param array<string,mixed> $candidate
     * @param array<int,array<string,mixed>> $opening_hours_by_department_id
     * @param array<string,array<int,bool>>|null $missing_lookup_by_day
     * @return array{
     *   counted: bool,
     *   reason: string,
     *   candidate_date: ?string,
     *   department_id: int
     * }
     */
    public function classifyCandidateAgainstOpeningHours(
        array $candidate,
        array $opening_hours_by_department_id,
        ?array $missing_lookup_by_day,
        string $date,
        string $date_to
    ): array {
        $department_id = (int)($candidate['department_id'] ?? 0);
        $timestamp = $this->parseTimestamp($candidate['start_at'] ?? null);
        if ($timestamp === null || $department_id < 1) {
            return [
                'counted' => false,
                'reason' => 'invalid_candidate',
                'candidate_date' => null,
                'department_id' => $department_id,
            ];
        }

        $candidate_date = $timestamp->format('Y-m-d');
        if ($candidate_date < $date || $candidate_date > $date_to) {
            return [
                'counted' => false,
                'reason' => 'outside_selected_range',
                'candidate_date' => $candidate_date,
                'department_id' => $department_id,
            ];
        }

        if (isset($missing_lookup_by_day[$candidate_date][$department_id])) {
            return [
                'counted' => false,
                'reason' => 'missing_opening_hours',
                'candidate_date' => $candidate_date,
                'department_id' => $department_id,
            ];
        }

        $outside_opening_hours = $this->isOutsideOpeningHours(
            $timestamp,
            $opening_hours_by_department_id[$department_id] ?? null
        );

        if ($outside_opening_hours !== true) {
            return [
                'counted' => false,
                'reason' => 'inside_opening_hours',
                'candidate_date' => $candidate_date,
                'department_id' => $department_id,
            ];
        }

        return [
            'counted' => true,
            'reason' => 'outside_opening_hours',
            'candidate_date' => $candidate_date,
            'department_id' => $department_id,
        ];
    }

    /**
     * @param array<int|string>|int|string $department_ids
     * @param array<int,array<string,mixed>> $opening_hours_by_department_id
     * @return array{
     *   by_day:array<string,array<int>>,
     *   has_missing_opening_hours:bool,
     *   missing_department_ids:array<int>
     * }
     * @throws Exception
     */
    public function buildMissingOpeningHoursDiagnostics(
        array|int|string $department_ids,
        array $opening_hours_by_department_id,
        string $date,
        string $date_to
    ): array {
        $normalized_department_ids = $this->normalizeDepartmentIds($department_ids);
        $missing_by_day = [];
        $missing_department_ids = [];

        foreach ($this->dateRange($date, $date_to) as $current_date) {
            $weekday = strtolower($current_date->format('l'));
            $point_date = $current_date->format('Y-m-d');

            foreach ($normalized_department_ids as $department_id) {
                $opening_hours = $opening_hours_by_department_id[$department_id] ?? null;
                if ($this->hasOpeningHoursForWeekday($opening_hours, $weekday)) {
                    continue;
                }

                $missing_by_day[$point_date][] = (int)$department_id;
                $missing_department_ids[$department_id] = (int)$department_id;
            }
        }

        foreach ($missing_by_day as $point_date => $department_list) {
            $unique_ids = array_values(array_unique(array_map('intval', $department_list)));
            sort($unique_ids);
            $missing_by_day[$point_date] = $unique_ids;
        }

        $missing_department_ids = array_values($missing_department_ids);
        sort($missing_department_ids);

        return [
            'by_day' => $missing_by_day,
            'has_missing_opening_hours' => $missing_department_ids !== [],
            'missing_department_ids' => $missing_department_ids,
        ];
    }

    /**
     * @param array<int|string>|int|string $department_ids
     * @return array<int>
     */
    private function normalizeDepartmentIds(array|int|string $department_ids): array
    {
        $queue = is_array($department_ids) ? $department_ids : [$department_ids];
        $normalized = [];

        while ($queue !== []) {
            $value = array_shift($queue);
            if (is_array($value)) {
                foreach ($value as $nested) {
                    $queue[] = $nested;
                }
                continue;
            }

            if (is_string($value) && str_contains($value, ',')) {
                foreach (explode(',', $value) as $segment) {
                    $queue[] = trim($segment);
                }
                continue;
            }

            $department_id = (int)$value;
            if ($department_id > 0) {
                $normalized[$department_id] = $department_id;
            }
        }

        return array_values($normalized);
    }

    /**
     * @param array<int> $department_ids
     * @return array<int,array<string,mixed>>
     * @throws Exception
     */
    private function fetchCandidates(string $date, string $date_to, array $department_ids): array
    {
        $order_candidates = $this->fetchOrderCandidatesInRange($date, $date_to, $department_ids);
        $selfserve_candidates = $this->fetchSelfserveCandidatesInRange($date, $date_to, $department_ids);
        $xlvask_candidates = $this->fetchXlvaskCandidatesInRange($date, $date_to, $department_ids);

        $order_ids_for_selfserve = array_values(array_unique(array_filter(array_merge(
            array_map(static fn(array $candidate): int => (int)($candidate['entity_id'] ?? 0), $order_candidates),
            array_map(static fn(array $candidate): int => (int)($candidate['linked_order_id'] ?? 0), $xlvask_candidates)
        ))));

        $wash_ids_for_xlvask = array_values(array_unique(array_filter(array_map(
            static fn(array $candidate): string => trim((string)($candidate['wash_id'] ?? '')),
            $order_candidates
        ))));

        return array_merge(
            $order_candidates,
            $selfserve_candidates,
            $xlvask_candidates,
            $this->fetchSelfserveCandidatesByOrderIds($order_ids_for_selfserve),
            $this->fetchXlvaskCandidatesByWashIds($wash_ids_for_xlvask, $department_ids)
        );
    }

    /**
     * @param array<int> $department_ids
     * @return array<int,array<string,mixed>>
     * @throws Exception
     */
    private function fetchOrderCandidatesInRange(string $date, string $date_to, array $department_ids): array
    {
        global $db;

        if ($department_ids === []) {
            return [];
        }

        [$date_start, $date_end] = $this->resolveSqlDateRange($date, $date_to);
        $department_ids_sql = implode(',', array_map('intval', $department_ids));
        $escaped_start = $db->escape_string($date_start);
        $escaped_end = $db->escape_string($date_end);

        $sql = "SELECT DISTINCT o.id, o.department_id, o.created_at, o.wash_id
                FROM orders o
                JOIN order_items oi ON oi.order_id = o.id
                JOIN products p ON p.id = oi.product_id
                WHERE o.department_id IN ($department_ids_sql)
                  AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end'
                  AND o.deleted_at IS NULL
                  AND oi.deleted_at IS NULL
                  AND p.is_wash = 1";

        $result = $db->query($sql);
        if (!is_object($result) || $result->num_rows === 0) {
            return [];
        }

        $candidates = [];
        while ($row = $result->fetch_assoc()) {
            $order_id = (int)($row['id'] ?? 0);
            $department_id = (int)($row['department_id'] ?? 0);
            $start_at = trim((string)($row['created_at'] ?? ''));
            if ($order_id < 1 || $department_id < 1 || $start_at === '') {
                continue;
            }

            $candidates[] = [
                'source' => self::SOURCE_ORDERS,
                'dedupe_key' => 'order:' . $order_id,
                'department_id' => $department_id,
                'start_at' => $start_at,
                'entity_id' => $order_id,
                'wash_id' => trim((string)($row['wash_id'] ?? '')),
            ];
        }

        return $candidates;
    }

    /**
     * @param array<int> $department_ids
     * @return array<int,array<string,mixed>>
     * @throws Exception
     */
    private function fetchSelfserveCandidatesInRange(string $date, string $date_to, array $department_ids): array
    {
        global $db;

        if ($department_ids === []) {
            return [];
        }

        [$date_start, $date_end] = $this->resolveSqlDateRange($date, $date_to);
        $department_ids_sql = implode(',', array_map('intval', $department_ids));
        $escaped_start = $db->escape_string($date_start);
        $escaped_end = $db->escape_string($date_end);

        $sql = "SELECT id, department_id, order_id, wash_started_at, machine_start_triggered_at
                FROM selfserve_wash_sessions
                WHERE department_id IN ($department_ids_sql)
                  AND deleted_at IS NULL
                  AND COALESCE(wash_started_at, machine_start_triggered_at) IS NOT NULL
                  AND COALESCE(wash_started_at, machine_start_triggered_at) BETWEEN '$escaped_start' AND '$escaped_end'";

        $result = $db->query($sql);
        if (!is_object($result) || $result->num_rows === 0) {
            return [];
        }

        return $this->mapSelfserveRowsToCandidates($db->fetch_all($result));
    }

    /**
     * @param array<int> $order_ids
     * @return array<int,array<string,mixed>>
     */
    private function fetchSelfserveCandidatesByOrderIds(array $order_ids): array
    {
        global $db;

        $normalized_order_ids = array_values(array_unique(array_filter(array_map('intval', $order_ids))));
        if ($normalized_order_ids === []) {
            return [];
        }

        $order_ids_sql = implode(',', $normalized_order_ids);
        $sql = "SELECT id, department_id, order_id, wash_started_at, machine_start_triggered_at
                FROM selfserve_wash_sessions
                WHERE order_id IN ($order_ids_sql)
                  AND deleted_at IS NULL
                  AND COALESCE(wash_started_at, machine_start_triggered_at) IS NOT NULL";

        $result = $db->query($sql);
        if (!is_object($result) || $result->num_rows === 0) {
            return [];
        }

        return $this->mapSelfserveRowsToCandidates($db->fetch_all($result));
    }

    /**
     * @param array<int,array<string,mixed>> $rows
     * @return array<int,array<string,mixed>>
     */
    private function mapSelfserveRowsToCandidates(array $rows): array
    {
        $candidates = [];
        foreach ($rows as $row) {
            $session_id = (int)($row['id'] ?? 0);
            $department_id = (int)($row['department_id'] ?? 0);
            $order_id = (int)($row['order_id'] ?? 0);
            $start_at = trim((string)($row['wash_started_at'] ?? ''));
            if ($start_at === '') {
                $start_at = trim((string)($row['machine_start_triggered_at'] ?? ''));
            }

            if ($session_id < 1 || $department_id < 1 || $start_at === '') {
                continue;
            }

            $candidates[] = [
                'source' => self::SOURCE_SELFSERVE,
                'dedupe_key' => $order_id > 0 ? 'order:' . $order_id : 'selfserve:' . $session_id,
                'department_id' => $department_id,
                'start_at' => $start_at,
                'entity_id' => $session_id,
                'linked_order_id' => $order_id > 0 ? $order_id : null,
            ];
        }

        return $candidates;
    }

    /**
     * @param array<int> $department_ids
     * @return array<int,array<string,mixed>>
     * @throws Exception
     */
    private function fetchXlvaskCandidatesInRange(string $date, string $date_to, array $department_ids): array
    {
        global $db;

        if ($department_ids === []) {
            return [];
        }

        [$iso_start, $iso_end] = $this->resolveXlvaskDateRange($date, $date_to);
        $escaped_start = $db->escape_string($iso_start);
        $escaped_end = $db->escape_string($iso_end);

        $sql = "SELECT x.WashId, x.CustomerId, x.Customer, x.Hall, x.StartTime, x.FinishStatus,
                       o.id AS order_id, o.department_id AS order_department_id
                FROM xlvask_usage_logs x
                LEFT JOIN orders o
                  ON o.wash_id = x.WashId
                 AND o.deleted_at IS NULL
                WHERE x.StartTime BETWEEN '$escaped_start' AND '$escaped_end'";

        $result = $db->query($sql);
        if (!is_object($result) || $result->num_rows === 0) {
            return [];
        }

        return $this->mapXlvaskRowsToCandidates($db->fetch_all($result), $department_ids);
    }

    /**
     * @param array<int|string> $wash_ids
     * @param array<int> $department_ids
     * @return array<int,array<string,mixed>>
     * @throws Exception
     */
    private function fetchXlvaskCandidatesByWashIds(array $wash_ids, array $department_ids): array
    {
        global $db;

        $normalized_wash_ids = array_values(array_filter(array_map(
            static fn(mixed $value): string => trim((string)$value),
            $wash_ids
        )));

        if ($normalized_wash_ids === []) {
            return [];
        }

        $escaped_wash_ids = array_map(static fn(string $wash_id): string => "'" . $db->escape_string($wash_id) . "'", $normalized_wash_ids);
        $wash_ids_sql = implode(',', $escaped_wash_ids);

        $sql = "SELECT x.WashId, x.CustomerId, x.Customer, x.Hall, x.StartTime, x.FinishStatus,
                       o.id AS order_id, o.department_id AS order_department_id
                FROM xlvask_usage_logs x
                LEFT JOIN orders o
                  ON o.wash_id = x.WashId
                 AND o.deleted_at IS NULL
                WHERE x.WashId IN ($wash_ids_sql)";

        $result = $db->query($sql);
        if (!is_object($result) || $result->num_rows === 0) {
            return [];
        }

        return $this->mapXlvaskRowsToCandidates($db->fetch_all($result), $department_ids);
    }

    /**
     * @param array<int,array<string,mixed>> $rows
     * @param array<int> $department_ids
     * @return array<int,array<string,mixed>>
     * @throws Exception
     */
    private function mapXlvaskRowsToCandidates(array $rows, array $department_ids): array
    {
        $selected_departments = $this->fetchDepartmentsByIds($department_ids);
        $department_name_lookup = [];
        foreach ($selected_departments as $department) {
            $department_id = (int)($department['id'] ?? 0);
            $department_name = (string)($department['name'] ?? '');
            if ($department_id < 1 || trim($department_name) === '') {
                continue;
            }

            $department_name_lookup[$this->normalizeDepartmentName($department_name)] = $department_id;
        }

        $allowed_department_lookup = [];
        foreach ($department_ids as $department_id) {
            $allowed_department_lookup[(int)$department_id] = true;
        }

        $candidates = [];
        foreach ($rows as $row) {
            $wash_id = trim((string)($row['WashId'] ?? ''));
            $start_at = $this->normalizeTimestamp($row['StartTime'] ?? null);
            $order_id = (int)($row['order_id'] ?? 0);
            $department_id = (int)($row['order_department_id'] ?? 0);

            if ($wash_id === '' || $start_at === null || !$this->isBillableCompletedXlvaskRow($row)) {
                continue;
            }

            if ($department_id < 1) {
                $department_id = $this->resolveDepartmentIdFromHall((string)($row['Hall'] ?? ''), $department_name_lookup);
            }

            if ($department_id < 1 || !isset($allowed_department_lookup[$department_id])) {
                continue;
            }

            $candidates[] = [
                'source' => self::SOURCE_XLVASK,
                'dedupe_key' => $order_id > 0 ? 'order:' . $order_id : 'xlvask:' . $wash_id,
                'department_id' => $department_id,
                'start_at' => $start_at,
                'entity_id' => $wash_id,
                'linked_order_id' => $order_id > 0 ? $order_id : null,
            ];
        }

        return $candidates;
    }

    /**
     * @param array<string,mixed> $row
     */
    private function isBillableCompletedXlvaskRow(array $row): bool
    {
        $finish_status = trim((string)($row['FinishStatus'] ?? ''));
        $customer = (string)($row['Customer'] ?? '');
        $customer_id = trim((string)($row['CustomerId'] ?? ''));

        return $finish_status === '1'
            && $customer_id !== ''
            && $customer_id !== '0'
            && !in_array($customer, xlvask_usage_log::$default_customers, true);
    }

    /**
     * @param array<int> $department_ids
     * @return array<int,array<string,mixed>>
     */
    private function fetchOpeningHoursByDepartmentId(array $department_ids): array
    {
        global $db;

        if ($department_ids === []) {
            return [];
        }

        $department_ids_sql = implode(',', array_map('intval', $department_ids));
        $sql = "SELECT * FROM department_time_bookings_opening_hours WHERE department IN ($department_ids_sql)";
        $result = $db->query($sql);
        if (!is_object($result) || $result->num_rows === 0) {
            return [];
        }

        $rows = [];
        while ($row = $result->fetch_assoc()) {
            $rows[(int)($row['department'] ?? 0)] = $row;
        }

        return $rows;
    }

    /**
     * @param array<int> $department_ids
     * @return array<int,array{id:int,name:string}>
     */
    private function fetchDepartmentsByIds(array $department_ids): array
    {
        global $db;

        if ($department_ids === []) {
            return [];
        }

        $department_ids_sql = implode(',', array_map('intval', $department_ids));
        $sql = "SELECT id, name FROM departments WHERE id IN ($department_ids_sql)";
        $result = $db->query($sql);
        if (!is_object($result) || $result->num_rows === 0) {
            return [];
        }

        return array_map(static function (array $row): array {
            return [
                'id' => (int)($row['id'] ?? 0),
                'name' => (string)($row['name'] ?? ''),
            ];
        }, $db->fetch_all($result));
    }

    /**
     * @param array<string,mixed>|null $opening_hours
     */
    private function hasOpeningHoursForWeekday(?array $opening_hours, string $weekday): bool
    {
        if (!is_array($opening_hours)) {
            return false;
        }

        $opening_start = $opening_hours[$weekday . '_start'] ?? null;
        $opening_end = $opening_hours[$weekday . '_end'] ?? null;

        return is_string($opening_start)
            && trim($opening_start) !== ''
            && is_string($opening_end)
            && trim($opening_end) !== '';
    }

    /**
     * @param array<string,mixed>|null $opening_hours
     */
    private function isOutsideOpeningHours(DateTimeInterface $timestamp, ?array $opening_hours): ?bool
    {
        $weekday = strtolower($timestamp->format('l'));
        if (!$this->hasOpeningHoursForWeekday($opening_hours, $weekday)) {
            return null;
        }

        $opening_start = trim((string)$opening_hours[$weekday . '_start']);
        $opening_end = trim((string)$opening_hours[$weekday . '_end']);
        $wash_time = $timestamp->format('H:i');
        $opening_start_time = date('H:i', strtotime($opening_start));
        $opening_end_time = date('H:i', strtotime($opening_end));

        return !($wash_time >= $opening_start_time && $wash_time <= $opening_end_time);
    }

    private function shouldReplaceDeduplicatedCandidate(array $existing, array $candidate): bool
    {
        $existing_priority = $this->source_priority[(string)($existing['source'] ?? '')] ?? 0;
        $candidate_priority = $this->source_priority[(string)($candidate['source'] ?? '')] ?? 0;

        if ($candidate_priority !== $existing_priority) {
            return $candidate_priority > $existing_priority;
        }

        $existing_timestamp = $this->parseTimestamp($existing['start_at'] ?? null);
        $candidate_timestamp = $this->parseTimestamp($candidate['start_at'] ?? null);

        if ($existing_timestamp === null && $candidate_timestamp !== null) {
            return true;
        }

        if ($existing_timestamp !== null && $candidate_timestamp !== null) {
            return $candidate_timestamp < $existing_timestamp;
        }

        return false;
    }

    /**
     * @param array<string,array<int>> $by_day
     * @return array<string,array<int,bool>>
     */
    private function missingDepartmentLookupByDay(array $by_day): array
    {
        $lookup = [];
        foreach ($by_day as $point_date => $department_ids) {
            foreach ($department_ids as $department_id) {
                $lookup[$point_date][(int)$department_id] = true;
            }
        }

        return $lookup;
    }

    /**
     * @return array{orders:int,xlvask:int,selfserve:int}
     */
    private function emptyBySource(): array
    {
        return [
            self::SOURCE_ORDERS => 0,
            self::SOURCE_XLVASK => 0,
            self::SOURCE_SELFSERVE => 0,
        ];
    }

    /**
     * @param array<int> $department_ids
     * @return array{
     *   department_ids:array<int>,
     *   date:string,
     *   date_to:string,
     *   total:int,
     *   by_source:array{orders:int,xlvask:int,selfserve:int},
     *   has_missing_opening_hours:bool,
     *   missing_department_ids:array<int>
     * }
     */
    private function emptySummary(string $date, string $date_to, array $department_ids): array
    {
        return [
            'department_ids' => array_values(array_map('intval', $department_ids)),
            'date' => $date,
            'date_to' => $date_to,
            'total' => 0,
            'by_source' => $this->emptyBySource(),
            'has_missing_opening_hours' => false,
            'missing_department_ids' => [],
        ];
    }

    /**
     * @return array{0:string,1:string}
     * @throws Exception
     */
    private function resolveSqlDateRange(string $date, string $date_to): array
    {
        $start = $this->parseDate($date)->setTime(0, 0, 0);
        $end = $this->parseDate($date_to)->setTime(23, 59, 59);

        return [
            $start->format('Y-m-d H:i:s'),
            $end->format('Y-m-d H:i:s'),
        ];
    }

    /**
     * @return array{0:string,1:string}
     * @throws Exception
     */
    private function resolveXlvaskDateRange(string $date, string $date_to): array
    {
        $start = $this->parseDate($date)->setTime(0, 0, 0, 0);
        $end = $this->parseDate($date_to)->setTime(23, 59, 59, 999000);

        return [
            $start->format('Y-m-d\TH:i:s.v'),
            $end->format('Y-m-d\TH:i:s.v'),
        ];
    }

    /**
     * @return DatePeriod<DateTimeImmutable>
     * @throws Exception
     */
    private function dateRange(string $date, string $date_to): DatePeriod
    {
        $start = $this->parseDate($date)->setTime(0, 0, 0);
        $end = $this->parseDate($date_to)->setTime(0, 0, 0)->add(new DateInterval('P1D'));
        return new DatePeriod($start, new DateInterval('P1D'), $end);
    }

    /**
     * @throws Exception
     */
    private function parseDate(string $date): DateTimeImmutable
    {
        $parsed = DateTimeImmutable::createFromFormat('Y-m-d', $date, $this->timezone);
        if ($parsed === false) {
            throw new Exception('Invalid date: ' . $date);
        }

        return $parsed;
    }

    private function parseTimestamp(mixed $value): ?DateTimeImmutable
    {
        $normalized = $this->normalizeTimestamp($value);
        if ($normalized === null) {
            return null;
        }

        try {
            return new DateTimeImmutable($normalized, $this->timezone);
        } catch (Exception) {
            return null;
        }
    }

    private function normalizeTimestamp(mixed $value): ?string
    {
        if (!is_string($value) && !is_numeric($value)) {
            return null;
        }

        $candidate = trim((string)$value);
        if ($candidate === '') {
            return null;
        }

        try {
            return (new DateTimeImmutable($candidate, $this->timezone))->setTimezone($this->timezone)->format('Y-m-d H:i:s');
        } catch (Exception) {
            return null;
        }
    }

    private function normalizeDepartmentName(string $name): string
    {
        $normalized = mb_strtolower(trim($name), 'UTF-8');
        $normalized = str_replace(
            ['æ', 'ø', 'å', 'ä', 'ö', 'ü'],
            ['ae', 'oe', 'aa', 'ae', 'oe', 'ue'],
            $normalized
        );
        $normalized = preg_replace('/[^a-z0-9]+/u', '', $normalized) ?? '';
        return $normalized;
    }

    /**
     * Hall names are typically stored as DepartmentName_Lane.
     */
    private function resolveDepartmentIdFromHall(string $hall, array $department_name_lookup): int
    {
        $hall = trim($hall);
        if ($hall === '') {
            return 0;
        }

        $department_name = explode('_', $hall)[0] ?? '';
        $normalized_name = $this->normalizeDepartmentName($department_name);

        return (int)($department_name_lookup[$normalized_name] ?? 0);
    }
}
