<?php

declare(strict_types=1);

namespace CliqThemes\StoreLocator\Frontend;

defined('ABSPATH') || exit;

use CliqThemes\StoreLocator\Support\StorePayloadFormatter;

/**
 * Store-locator DOMAIN token formats for the PHP (SSR) renderer — the exact
 * counterpart of resources/shared/tokenFormats.js for the JS renderer.
 *
 * The renderer ({@see DesignRenderer}) resolves only the generic content formats
 * (text / media / gallery / button / html) and then applies the
 * `cliq_sl_token_value` filter — the PHP twin of the JS `cliqui.token_value`
 * hook — so everything store-shaped (address, opening hours, distance, category)
 * is supplied here as a filter callback rather than hardcoded in the renderer.
 *
 * Keeping the domain formats behind the same filter in both languages means a
 * new format is added in one conceptual place per runtime (this class + its JS
 * twin), and {@see \CliqThemes\StoreLocator\Tests\Integration\TokenFormatParityIntegrationTest}
 * asserts the two stay in lockstep.
 */
final class TokenFormats
{
    private static bool $registered = false;

    /** Domain formats this host resolves (kept in sync with tokenFormats.js). */
    public const DOMAIN_FORMATS = ['address', 'schedule', 'open_status', 'distance', 'rating', 'category'];

    /** Idempotently register the domain formats on the renderer's filter. */
    public static function register(): void
    {
        if (self::$registered) {
            return;
        }
        self::$registered = true;
        add_filter('cliq_sl_token_value', [self::class, 'resolve'], 10, 2);
    }

    /**
     * Resolve a domain token value. Generic formats fall through unchanged.
     *
     * @param mixed                $value generic value computed by the renderer
     * @param array<string, mixed> $ctx   { token, record, meta, format, raw, tokens }
     *
     * @return mixed
     */
    public static function resolve($value, array $ctx)
    {
        $format = (string) ($ctx['format'] ?? '');
        $store = is_array($ctx['record'] ?? null) ? $ctx['record'] : [];
        $raw = $ctx['raw'] ?? null;
        $item = is_array($ctx['item'] ?? null) ? $ctx['item'] : [];

        switch ($format) {
            case 'address':
                return self::address($store);
            case 'schedule':
                return self::schedule($raw, $item);
            case 'open_status':
                return ['type' => 'open_status', 'open' => self::openState($store)];
            case 'distance':
                return isset($store['distance']) && $store['distance'] !== null
                    ? (round(((float) $store['distance']) * 10) / 10) . ' ' . (string) ($store['distanceUnit'] ?? 'mi')
                    : (is_scalar($raw) ? (string) $raw : '');
            case 'rating':
                $r = $store['rating'] ?? (is_scalar($raw) ? $raw : null);
                if ($r === null || $r === '' || $r === false) {
                    return '';
                }
                $count = $store['rating_count'] ?? null;

                return '★ ' . $r . ($count ? ' (' . $count . ')' : '');
            case 'category':
                $cats = $store['categories'] ?? null;
                if (is_array($cats) && $cats !== []) {
                    $first = $cats[0];
                    return is_array($first) ? (string) ($first['name'] ?? '—') : (string) $first;
                }
                return is_scalar($raw) ? (string) $raw : '—';
        }

        return $value;
    }

    /** @param array<string, mixed> $store */
    private static function address(array $store): string
    {
        $loc = is_array($store['location'] ?? null)
            ? $store['location']
            : (is_array($store['locations'][0] ?? null) ? $store['locations'][0] : []);
        $line = $loc['address_line1'] ?? $loc['address'] ?? $store['address'] ?? '';
        $tail = array_filter([
            $loc['city'] ?? $store['city'] ?? null,
            $loc['state'] ?? null,
            $loc['postal_code'] ?? null,
        ]);
        $full = array_filter([$line, implode(', ', $tail)]);

        return $full !== [] ? implode(', ', $full) : (string) ($store['city'] ?? '—');
    }

    /**
     * Render an "Opening Hours" (type 'hours' field) value as a readable weekly
     * schedule. Accepts the structured object (decoded array or JSON string) OR a
     * plain string. Returns an html-typed token value; mirrors tokenFormats.js.
     *
     * @param mixed $raw
     *
     * @return mixed
     */
    private static function schedule($raw, array $item = [])
    {
        $data = $raw;
        if (is_string($data)) {
            $s = trim($data);
            if ($s === '') {
                return '';
            }
            if ($s[0] === '{' || $s[0] === '[') {
                $decoded = json_decode($s, true);
                if (is_array($decoded)) {
                    $data = $decoded;
                } else {
                    return $s; // not JSON after all — plain string schedule
                }
            } else {
                return $s; // plain string schedule (e.g. "Mon–Fri 09:00 – 18:00")
            }
        }
        if (! is_array($data)) {
            return is_scalar($raw) ? (string) $raw : '';
        }

        // Public store payloads carry relational hours as rows
        // ({day,open,close,closed}); custom Hours fields use the keyed
        // mon..sun shape. Normalize the former so both render identically.
        if (array_is_list($data)) {
            $keys = [0 => 'sun', 1 => 'mon', 2 => 'tue', 3 => 'wed', 4 => 'thu', 5 => 'fri', 6 => 'sat'];
            $normalised = [];
            foreach ($data as $row) {
                if (! is_array($row)) {
                    continue;
                }
                $key = $keys[(int) ($row['day'] ?? -1)] ?? null;
                if ($key === null) {
                    continue;
                }
                if (! isset($normalised[$key])) {
                    $normalised[$key] = ['closed' => true, 'open24' => false, 'slots' => []];
                }
                if (! empty($row['closed'])) {
                    continue;
                }
                $normalised[$key]['closed'] = false;
                $normalised[$key]['slots'][] = ['from' => $row['open'] ?? '', 'to' => $row['close'] ?? ''];
            }
            $data = $normalised;
        }

        $days = ($item['scheduleDayFormat'] ?? 'short') === 'long'
            ? [
                'mon' => __('Monday', 'cliq-store-locator'),
                'tue' => __('Tuesday', 'cliq-store-locator'),
                'wed' => __('Wednesday', 'cliq-store-locator'),
                'thu' => __('Thursday', 'cliq-store-locator'),
                'fri' => __('Friday', 'cliq-store-locator'),
                'sat' => __('Saturday', 'cliq-store-locator'),
                'sun' => __('Sunday', 'cliq-store-locator'),
            ]
            : [
                'mon' => __('Mon', 'cliq-store-locator'),
                'tue' => __('Tue', 'cliq-store-locator'),
                'wed' => __('Wed', 'cliq-store-locator'),
                'thu' => __('Thu', 'cliq-store-locator'),
                'fri' => __('Fri', 'cliq-store-locator'),
                'sat' => __('Sat', 'cliq-store-locator'),
                'sun' => __('Sun', 'cliq-store-locator'),
            ];
        $closedText = trim((string) ($item['scheduleClosedText'] ?? '')) ?: __('Closed', 'cliq-store-locator');
        $open24Text = trim((string) ($item['scheduleOpen24Text'] ?? '')) ?: __('Open 24 hours', 'cliq-store-locator');
        $timeFormat = ($item['scheduleTimeFormat'] ?? '12') === '24' ? '24' : '12';
        $dayVals = [];
        foreach ($days as $key => $label) {
            $day = is_array($data[$key] ?? null) ? $data[$key] : null;
            $closed = ! $day || ! empty($day['closed']);
            if ($closed) {
                $val = $closedText;
            } elseif (! empty($day['open24'])) {
                $val = $open24Text;
            } else {
                $slots = is_array($day['slots'] ?? null) ? $day['slots'] : [];
                $parts = [];
                foreach ($slots as $slot) {
                    $from = self::formatTime((string) ($slot['from'] ?? ''), $timeFormat);
                    $to = self::formatTime((string) ($slot['to'] ?? ''), $timeFormat);
                    if ($from !== '' && $to !== '') {
                        $parts[] = $from . ' – ' . $to;
                    }
                }
                $val = $parts !== [] ? implode(', ', $parts) : $closedText;
                $closed = $parts === [];
            }
            if (empty($item['scheduleHideClosed']) || ! $closed) {
                $dayVals[] = ['label' => $label, 'val' => $val];
            }
        }

        // Collapse consecutive days that share the same hours into a range row:
        // 1 day → "Mon", 2 → "Sat & Sun", 3+ → "Mon–Fri".
        $groups = [];
        foreach ($dayVals as $d) {
            $n = count($groups);
            if (($item['scheduleLayout'] ?? 'grouped') !== 'daily' && $n > 0 && $groups[$n - 1]['val'] === $d['val']) {
                $groups[$n - 1]['days'][] = $d['label'];
            } else {
                $groups[] = ['val' => $d['val'], 'days' => [$d['label']]];
            }
        }

        $rows = '';
        foreach ($groups as $group) {
            $dayLabels = $group['days'];
            $count = count($dayLabels);
            if ($count === 1) {
                $label = $dayLabels[0];
            } elseif ($count === 2) {
                $label = $dayLabels[0] . ' & ' . $dayLabels[1];
            } else {
                $label = $dayLabels[0] . '–' . $dayLabels[$count - 1];
            }
            $rows .= '<div class="cliq-hours__row"><span class="cliq-hours__day">' . esc_html($label)
                . '</span><span class="cliq-hours__val">' . esc_html($group['val']) . '</span></div>';
        }

        return ['type' => 'html', 'html' => '<div class="cliq-hours">' . $rows . '</div>'];
    }

    /** "09:00" → "9:00 AM"; passes anything unparseable through. */
    private static function formatTime(string $hm, string $format = '12'): string
    {
        $hm = trim($hm);
        if ($hm === '' || ! preg_match('/^(\d{1,2}):(\d{2})/', $hm, $m)) {
            return $hm;
        }
        $h = (int) $m[1];
        $min = $m[2];
        if ($format === '24') {
            return str_pad((string) $h, 2, '0', STR_PAD_LEFT) . ':' . $min;
        }
        $ampm = $h >= 12 ? 'PM' : 'AM';
        $h12 = $h % 12;
        if ($h12 === 0) {
            $h12 = 12;
        }

        return $h12 . ':' . $min . ' ' . $ampm;
    }

    /**
     * @param array<string, mixed> $store
     */
    private static function openState(array $store): ?bool
    {
        // Prefer the server-computed flag (set by StorePayloadFormatter, site
        // timezone — consistent with the "open now" search filter).
        if (array_key_exists('open_now', $store) && $store['open_now'] !== null) {
            return (bool) $store['open_now'];
        }
        $hours = is_array($store['hours'] ?? null) ? $store['hours'] : null;
        if (! $hours) {
            return null;
        }
        // Delegate to the SAME predicate the search filter and the payload's
        // own `open_now` badge use (StorePayloadFormatter::isOpenNow) instead
        // of re-implementing the comparison here — this used to be a THIRD,
        // divergent implementation that only checked the first hours row
        // matching today (wrong on multi-range days, e.g. lunch/dinner) and
        // had no overnight-range handling at all (wrong for e.g. an
        // 18:00-02:00 bar after midnight). isOpenNow() null (hours exist, but
        // none for today) maps to "Closed" here, matching this method's prior
        // behaviour for that case.
        return StorePayloadFormatter::isOpenNow($hours);
    }
}
