<?php

declare(strict_types=1);

namespace PrettyLinks\Pro\Targeting;

// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
// phpcs:disable WordPress.Security.ValidatedSanitizedInput.MissingUnslash
// phpcs:disable WordPress.Security.NonceVerification.Recommended
// $_SERVER values (REMOTE_ADDR, HTTP_USER_AGENT, REQUEST_URI, etc.) are read for
// click tracking / targeting / UI rendering, not form-submission input. State-changing
// operations in this class protect with wp_verify_nonce / check_admin_referer.
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_value
// phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter
// Custom plugin tables (prli_*): table names interpolated from $wpdb->prefix (trusted),
// user values bind through $wpdb->prepare(). No caching: these tables are the source
// of truth for click/redirect data and must read-through. "meta_key"/"meta_value" here
// refer to our own prli_link_metas table, not wp_postmeta.
use PrettyLinks\Pro\Redirect\DynamicMode;
use PrettyLinks\Redirect\DeviceDetector;
use PrettyLinks\Redirect\Geo;

/**
 * Evaluates a link's targeting rules against the current request context.
 *
 * Two data shapes supported transparently:
 *
 *   - v4 canonical: `targeting_rules` link meta (single row) holds a
 *     JSON array of `{operator, value, redirect_to}` objects.
 *   - v3 drop-in: when `targeting_rules` is empty and the link's
 *     `prli_dynamic_redirection` mode is `geo`/`tech`/`time`, the v3
 *     parallel-array link metas (`geo_url[]`, `geo_countries[]`,
 *     `tech_url[]`, `tech_device[]`, `tech_os[]`, `tech_browser[]`,
 *     `time_url[]`, `time_start[]`, `time_end[]`) are adapted into
 *     the same rule shape at runtime — no migration needed.
 *
 * First matching rule's `redirect_to` wins. Mode `'none'` disables
 * targeting; mode `'rotate'` hands control to RotationService.
 */
class Evaluator
{
    /**
     * Per-request memo of the visitor country lookup — Geo::country() can hit
     * a remote API, so resolve it lazily (only when a country rule is
     * evaluated) and at most once per Evaluator instance.
     *
     * @var string|null
     */
    private $country = null;

    /**
     * `prli_target_url` filter callback. Matches v3's array-in/array-out signature.
     *
     * @param  array<string, mixed> $data Merged link row with v3-compat keys.
     * @return array<string, mixed>
     */
    public function filter(array $data): array
    {
        $linkId = (int) ($data['link_id'] ?? $data['id'] ?? 0);
        if ($linkId <= 0) {
            return $data;
        }

        $mode = DynamicMode::forLink($linkId);
        if (DynamicMode::isNone($mode) || !DynamicMode::allowsTargeting($mode)) {
            return $data;
        }

        $rules = $this->loadRules($linkId);
        if (!$rules) {
            $rules = $this->legacyRules($linkId, $mode);
        }
        if (!$rules) {
            return $data;
        }

        $target  = (string) ($data['url'] ?? '');
        $context = $this->context();
        foreach ($rules as $ruleData) {
            $rule = Rule::fromArray($ruleData);
            if ($this->evaluate($rule, $context)) {
                $data['url'] = $rule->redirectTo !== '' ? $rule->redirectTo : $target;
                return $data;
            }
        }
        return $data;
    }

    /**
     * True when the link is in TIME mode AND at least one of its
     * `date_between` rules currently matches. Used by the Links-list
     * REST response to flag rows whose timed redirect is live right now.
     *
     * @param integer $linkId The link ID.
     *
     * @return boolean Whether a time rule is currently active.
     */
    public function hasActiveTimeRule(int $linkId): bool
    {
        if ($linkId <= 0 || DynamicMode::forLink($linkId) !== DynamicMode::TIME) {
            return false;
        }
        $rules = $this->loadRules($linkId);
        if (!$rules) {
            $rules = $this->legacyTimeRules($linkId);
        }
        if (!$rules) {
            return false;
        }
        $context = $this->context();
        foreach ($rules as $ruleData) {
            $rule = Rule::fromArray($ruleData);
            if ($rule->operator === 'date_between' && $this->evaluate($rule, $context)) {
                return true;
            }
        }
        return false;
    }

    /**
     * Adapt a link's legacy v3 dynamic-redirection metas (geo/tech/time) into
     * the v4 rule shape, so the editor can surface the same rules the runtime
     * honors. Returns [] unless the link is in a targeting mode (geo/tech/time)
     * with legacy rows. Read-only — writes nothing, so the original v3 rows
     * stay intact for rollback. Precedence (v4 `targeting_rules` wins) is the
     * caller's concern, mirroring filter().
     *
     * @param  integer $linkId The link ID.
     * @param  string  $mode   Pre-resolved mode; pass the one the caller already
     *                         looked up to skip a second (uncached) forLink query.
     * @return array<int, array<string, mixed>>
     */
    public function legacyRulesFor(int $linkId, string $mode = ''): array
    {
        if ($linkId <= 0) {
            return [];
        }
        if ($mode === '') {
            $mode = DynamicMode::forLink($linkId);
        }
        if (!DynamicMode::allowsTargeting($mode)) {
            return [];
        }
        return $this->legacyRules($linkId, $mode);
    }

    /**
     * Loads the v4 canonical targeting rules for a link.
     *
     * @param integer $linkId The link ID.
     *
     * @return array<int, array<string, mixed>>
     */
    private function loadRules(int $linkId): array
    {
        global $wpdb;
        $raw = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT meta_value FROM {$wpdb->prefix}prli_link_metas
                 WHERE link_id = %d AND meta_key = %s LIMIT 1",
                $linkId,
                'targeting_rules'
            )
        );
        if (!is_string($raw) || $raw === '') {
            return [];
        }
        $decoded = json_decode($raw, true);
        return is_array($decoded) ? $decoded : [];
    }

    /**
     * Dispatches to the legacy rule adapter for the link's mode.
     *
     * @param integer $linkId The link ID.
     * @param string  $mode   The dynamic-redirection mode.
     *
     * @return array<int, array<string, mixed>>
     */
    private function legacyRules(int $linkId, string $mode): array
    {
        switch ($mode) {
            case DynamicMode::GEO:
                return $this->legacyGeoRules($linkId);
            case DynamicMode::TECH:
                return $this->legacyTechRules($linkId);
            case DynamicMode::TIME:
                return $this->legacyTimeRules($linkId);
            default:
                return [];
        }
    }

    /**
     * Adapts the legacy GEO metas into v4 country rules.
     *
     * @param integer $linkId The link ID.
     *
     * @return array<int, array<string, mixed>>
     */
    private function legacyGeoRules(int $linkId): array
    {
        $urls      = $this->readArrayMeta($linkId, 'geo_url');
        $countries = $this->readArrayMeta($linkId, 'geo_countries');
        $rules     = [];
        foreach ($urls as $i => $url) {
            $url = trim($url);
            if ($url === '') {
                continue;
            }
            $codes = self::parseCountryCodes($countries[$i] ?? '');
            if (!$codes) {
                continue;
            }
            $rules[] = [
                'operator'    => 'country_in',
                'value'       => $codes,
                'redirect_to' => $url,
            ];
        }
        return $rules;
    }

    /**
     * V3 stored device/os/browser as parallel arrays where each row matches
     * when all three non-"any" dimensions match together. Adapts to v4's
     * `all_of` compound operator so the original AND semantics are kept.
     *
     * @param integer $linkId The link ID.
     *
     * @return array<int, array<string, mixed>>
     */
    private function legacyTechRules(int $linkId): array
    {
        $urls     = $this->readArrayMeta($linkId, 'tech_url');
        $devices  = $this->readArrayMeta($linkId, 'tech_device');
        $oses     = $this->readArrayMeta($linkId, 'tech_os');
        $browsers = $this->readArrayMeta($linkId, 'tech_browser');

        $rules = [];
        foreach ($urls as $i => $url) {
            $url = trim($url);
            if ($url === '') {
                continue;
            }
            $clauses = [];
            $device  = strtolower(trim((string) ($devices[$i] ?? '')));
            $os      = strtolower(trim((string) ($oses[$i] ?? '')));
            $browser = strtolower(trim((string) ($browsers[$i] ?? '')));

            if ($device !== '' && $device !== 'any') {
                $clauses[] = [
                    'operator' => 'device_type_in',
                    'value'    => [self::mapV3Device($device)],
                ];
            }
            if ($os !== '' && $os !== 'any') {
                $clauses[] = [
                    'operator' => 'os_in',
                    'value'    => [self::mapV3Os($os)],
                ];
            }
            if ($browser !== '' && $browser !== 'any') {
                $clauses[] = [
                    'operator' => 'browser_in',
                    'value'    => [self::mapV3Browser($browser)],
                ];
            }
            if (!$clauses) {
                continue;
            }
            if (count($clauses) === 1) {
                $rules[] = [
                    'operator'    => $clauses[0]['operator'],
                    'value'       => $clauses[0]['value'],
                    'redirect_to' => $url,
                ];
                continue;
            }
            $rules[] = [
                'operator'    => 'all_of',
                'value'       => $clauses,
                'redirect_to' => $url,
            ];
        }
        return $rules;
    }

    /**
     * Adapts the legacy TIME metas into v4 date_between rules.
     *
     * @param integer $linkId The link ID.
     *
     * @return array<int, array<string, mixed>>
     */
    private function legacyTimeRules(int $linkId): array
    {
        $urls   = $this->readArrayMeta($linkId, 'time_url');
        $starts = $this->readArrayMeta($linkId, 'time_start');
        $ends   = $this->readArrayMeta($linkId, 'time_end');

        $rules = [];
        foreach ($urls as $i => $url) {
            $url = trim($url);
            if ($url === '') {
                continue;
            }
            $from = trim((string) ($starts[$i] ?? ''));
            $to   = trim((string) ($ends[$i] ?? ''));
            if ($from === '' || $to === '') {
                continue;
            }
            $rules[] = [
                'operator'    => 'date_between',
                'value'       => [
                    'from' => $from,
                    'to'   => $to,
                ],
                'redirect_to' => $url,
            ];
        }
        return $rules;
    }

    /**
     * Read an array-valued v3 link meta (multiple rows per key, ordered
     * by meta_order then id).
     *
     * @param integer $linkId The link ID.
     * @param string  $key    The meta key to read.
     *
     * @return array<int, string>
     */
    private function readArrayMeta(int $linkId, string $key): array
    {
        global $wpdb;
        $rows = $wpdb->get_col(
            $wpdb->prepare(
                "SELECT meta_value FROM {$wpdb->prefix}prli_link_metas
                 WHERE link_id = %d AND meta_key = %s
                 ORDER BY meta_order ASC, id ASC",
                $linkId,
                $key
            )
        );
        return is_array($rows) ? array_map('strval', $rows) : [];
    }

    /**
     * Accepts v3's "[US] [CA]" bracket format, comma-separated, or
     * whitespace-separated lists. Returns uppercase 2-letter codes only.
     *
     * @param  mixed $raw The raw stored country list.
     * @return array<int, string>
     */
    private static function parseCountryCodes($raw): array
    {
        $raw = (string) $raw;
        if ($raw === '') {
            return [];
        }
        if (preg_match_all('/\[([A-Za-z]{2})\]/', $raw, $m)) {
            return array_values(array_unique(array_map('strtoupper', $m[1])));
        }
        $parts = preg_split('/[\s,]+/', $raw) ?: [];
        $out   = [];
        foreach ($parts as $p) {
            $p = strtoupper(trim((string) $p));
            if (preg_match('/^[A-Z]{2}$/', $p)) {
                $out[] = $p;
            }
        }
        return array_values(array_unique($out));
    }

    /**
     * V3 emitted `phone` / `mobile` / `tablet` / `desktop`. v4 has only
     * `mobile` / `tablet` / `desktop` / `bot`. Phone folds into mobile.
     *
     * @param string $v3Device The v3 device name.
     *
     * @return string The v4 device type.
     */
    private static function mapV3Device(string $v3Device): string
    {
        if ($v3Device === 'phone') {
            return 'mobile';
        }
        return $v3Device;
    }

    /**
     * V3 stored lowercase abbreviated OS names; DeviceDetector returns full
     * proper-cased names. Map to what DeviceDetector emits so os_in matches.
     *
     * V3 → DeviceDetector: win → Windows, macosx → macOS, linux → Linux,
     * android → Android, ios → iOS.
     *
     * @param string $v3Os The v3 OS name.
     *
     * @return string The DeviceDetector OS name.
     */
    private static function mapV3Os(string $v3Os): string
    {
        $map = [
            'win'     => 'Windows',
            'macosx'  => 'macOS',
            'macos'   => 'macOS',
            'linux'   => 'Linux',
            'android' => 'Android',
            'ios'     => 'iOS',
        ];
        return $map[$v3Os] ?? $v3Os;
    }

    /**
     * V3 stored lowercase abbreviated browser names; DeviceDetector returns
     * full proper-cased names. Map to what DeviceDetector emits.
     *
     * V3 → DeviceDetector: edge → Microsoft Edge, ie → Internet Explorer,
     * android → Android Browser. Others (chrome, firefox, safari, opera) match
     * after case-insensitive eval so no mapping needed.
     *
     * @param string $v3Browser The v3 browser name.
     *
     * @return string The DeviceDetector browser name.
     */
    private static function mapV3Browser(string $v3Browser): string
    {
        $map = [
            'edge'    => 'Microsoft Edge',
            'ie'      => 'Internet Explorer',
            'android' => 'Android Browser',
        ];
        return $map[$v3Browser] ?? $v3Browser;
    }

    /**
     * Builds the request context evaluated against targeting rules.
     *
     * @return array<string, mixed>
     */
    private function context(): array
    {
        $ua     = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
        $device = DeviceDetector::detect($ua);
        return [
            'device_type' => $this->resolveDeviceType($device),
            'browser'     => (string) ($device['btype'] ?? ''),
            'os'          => (string) ($device['os'] ?? ''),
            'hour'        => (int) current_time('G'),
            'weekday'     => (int) current_time('w'),
            'now'         => (int) time(),
            'user_agent'  => $ua,
        ];
    }

    /**
     * The visitor's 2-letter country code: CDN headers first, then the
     * IP-geolocation API via Geo::country() (v3 parity — this fallback is
     * what makes geo targeting work on hosts without CDN geo headers).
     *
     * Deliberately uses the FULL client IP even when `anonymize_ips` is on:
     * the option governs what is *stored* (click rows, cache keys — both
     * anonymized, see Geo::cacheKey), not the in-flight routing decision.
     * Product decision, 2026-07-11 (PR #716).
     *
     * @return string Uppercase ISO 3166-1 alpha-2 code, or '' if unknown.
     */
    private function resolveCountry(): string
    {
        if ($this->country === null) {
            // Geo::country() guarantees uppercase (including filter overrides).
            $this->country = Geo::country(Geo::resolveIp());
        }
        return $this->country;
    }

    /**
     * Resolves the device type from the DeviceDetector result, falling back to
     * the naive strpos ladder only when the detector returns an empty type.
     *
     * @param array<string, mixed>|null $device The DeviceDetector result, or null.
     *
     * @return string The resolved device type.
     */
    private function resolveDeviceType(?array $device): string
    {
        if ($device === null) {
            return $this->deviceType();
        }
        if (!empty($device['is_bot'])) {
            return 'bot';
        }
        if (isset($device['type']) && $device['type'] !== '') {
            return (string) $device['type'];
        }
        return $this->deviceType();
    }

    /**
     * Naive user-agent device-type fallback used when DeviceDetector is empty.
     *
     * @return string The detected device type.
     */
    private function deviceType(): string
    {
        $ua = strtolower((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
        if ($ua === '') {
            return 'unknown';
        }
        if (strpos($ua, 'bot') !== false || strpos($ua, 'crawler') !== false || strpos($ua, 'spider') !== false) {
            return 'bot';
        }
        if (strpos($ua, 'tablet') !== false || strpos($ua, 'ipad') !== false) {
            return 'tablet';
        }
        if (strpos($ua, 'mobi') !== false || strpos($ua, 'android') !== false) {
            return 'mobile';
        }
        return 'desktop';
    }

    /**
     * Evaluates a single rule against the request context.
     *
     * @param Rule                 $rule    The targeting rule.
     * @param array<string, mixed> $context The request context.
     *
     * @return boolean Whether the rule matches.
     */
    private function evaluate(Rule $rule, array $context): bool
    {
        $value = $rule->value;

        switch ($rule->operator) {
            // Country is resolved lazily via resolveCountry() rather than read
            // from $context — it can require a remote IP-geolocation call, so
            // it must only run when a country rule is actually evaluated.
            // An unknown country matches no country rule (v3 parity: geo
            // required a resolved country), so country_not_in cannot fail
            // open during lookup failures.
            case 'country_in':
            case 'country_not_in':
                if (!is_array($value)) {
                    return false;
                }
                $country = $this->resolveCountry();
                if ($country === '') {
                    return false;
                }
                $matches = in_array($country, array_map('strtoupper', $value), true);
                return $rule->operator === 'country_in' ? $matches : !$matches;
            case 'device_type_in':
                return is_array($value) && in_array((string) $context['device_type'], $value, true);
            case 'browser_in':
                return is_array($value)
                    && in_array(strtolower((string) $context['browser']), array_map('strtolower', $value), true);
            case 'os_in':
                return is_array($value)
                    && in_array(strtolower((string) $context['os']), array_map('strtolower', $value), true);
            case 'hour_between':
                if (!is_array($value) || !isset($value['from'], $value['to'])) {
                    return false;
                }
                $from = (int) $value['from'];
                $to   = (int) $value['to'];
                $hour = (int) $context['hour'];
                if ($from <= $to) {
                    return $hour >= $from && $hour <= $to;
                }
                return $hour >= $from || $hour <= $to;
            case 'weekday_in':
                return is_array($value) && in_array((int) $context['weekday'], array_map('intval', $value), true);
            case 'date_between':
                if (!is_array($value) || !isset($value['from'], $value['to'])) {
                    return false;
                }
                $fromTs = $this->parseTargetingDateTime((string) $value['from']);
                $toTs   = $this->parseTargetingDateTime((string) $value['to']);
                if ($fromTs === false || $toTs === false) {
                    return false;
                }
                $now = (int) ($context['now'] ?? time());
                return $now >= $fromTs && $now <= $toTs;
            case 'all_of':
                if (!is_array($value) || !$value) {
                    return false;
                }
                foreach ($value as $sub) {
                    if (!is_array($sub)) {
                        return false;
                    }
                    $subRule = new Rule(
                        (string) ($sub['operator'] ?? ''),
                        $sub['value'] ?? null,
                        ''
                    );
                    if (!$this->evaluate($subRule, $context)) {
                        return false;
                    }
                }
                return true;
            default:
                return false;
        }
    }

    /**
     * Parse a targeting datetime into a real Unix timestamp.
     *
     * Naive strings from the link editor (`YYYY-MM-DD HH:MM[:SS]`) are
     * interpreted in the WordPress site timezone so they match the UI help
     * copy and the hour/weekday operators. Strings with an explicit offset
     * or Z keep that absolute instant (`DateTimeImmutable` ignores the
     * constructor timezone when the string already carries one). Unparseable
     * input returns false.
     *
     * @param string $raw Datetime string from a rule value.
     *
     * @return integer|false Unix timestamp, or false on failure.
     */
    private function parseTargetingDateTime(string $raw)
    {
        $raw = trim($raw);
        if ($raw === '') {
            return false;
        }

        // DateTimeImmutable ignores the timezone arg when $raw already carries
        // an offset/Z, so one constructor covers both naive (site TZ) and
        // absolute (stay-as-written) inputs.
        try {
            return (new \DateTimeImmutable($raw, wp_timezone()))->getTimestamp();
        } catch (\Exception $e) {
            return false;
        }
    }
}
