<?php
declare(strict_types=1);
// ==================================================
// Nuclear Fancy Pants Edition™ — NUCLEAR+++
// File: /includes/class-best-time-learner.php
// Description: Multi-armed bandit posting time optimizer. Explores candidate hour slots per channel per day-of-week, locks in winners after 5 trials. Timezone-aware. - NUCLEAR+++
// Last Edited: 2026-04-22
// ==================================================

namespace AutoMarketerAI;

defined('ABSPATH') || exit;

final class BestTimeLearner
{
    // wp_options key pattern: amai_learned_times_{channel}
    private const OPTION_PREFIX = 'amai_learned_times_';

    // Minimum trials per slot before exploitation kicks in
    private const MIN_TRIALS = 5;

    // Engagement score weights -- same formula as PerformanceAnalyzer
    private const WEIGHT_LIKES    = 1;
    private const WEIGHT_COMMENTS = 3;
    private const WEIGHT_REACH    = 0.01;

    // Candidate hour slots per channel (local time, 24hr integers)
    private const CANDIDATES = [
        'facebook'  => [8, 9, 11, 13, 15],
        'instagram' => [6, 8, 11, 12, 15],
        'x'         => [8, 9, 12, 17, 19],
        'tiktok'    => [12, 15, 17, 19, 21],
        'google'    => [8, 9, 10, 11, 13],
    ];

    // ---------------------------------------------------------------------------
    // Public API
    // ---------------------------------------------------------------------------

    // Returns the best posting hour (int, local time) for a given channel + day-of-week.
    // Day-of-week: 0=Monday ... 6=Sunday.
    // During exploration returns the next under-trialed slot (round-robin).
    // Falls back to first candidate if channel unknown.
    public static function getBestHour(string $channel, int $day_of_week): int
    {
        $state      = self::loadState($channel);
        $day_key    = $day_of_week;
        $day_state  = $state[$day_key] ?? [];
        $candidates = self::CANDIDATES[$channel] ?? self::CANDIDATES['facebook'];

        $winner = self::resolveWinner($day_state, $candidates);
        if ($winner !== null) {
            return $winner;
        }

        return self::nextExplorationSlot($day_state, $candidates);
    }

    // Returns a formatted time string 'HH:MM:SS' for direct use in storePosts.
    public static function getBestTime(string $channel, int $day_of_week): string
    {
        return sprintf('%02d:00:00', self::getBestHour($channel, $day_of_week));
    }

    // Records engagement outcome for a post after stats are fetched.
    // Called from StatsEngine after a successful stat update.
    public static function recordOutcome(
        string $channel,
        int $day_of_week,
        int $posted_hour_local,
        int $likes,
        int $comments,
        ?int $reach
    ): void {
        $candidates = self::CANDIDATES[$channel] ?? [];
        if (!in_array($posted_hour_local, $candidates, true)) {
            return;
        }

        $score = ($likes * self::WEIGHT_LIKES)
               + ($comments * self::WEIGHT_COMMENTS)
               + (($reach ?? 0) * self::WEIGHT_REACH);

        $state   = self::loadState($channel);
        $day_key = $day_of_week;

        if (!isset($state[$day_key])) {
            $state[$day_key] = [];
        }

        $hour_key = $posted_hour_local;

        if (!isset($state[$day_key][$hour_key])) {
            $state[$day_key][$hour_key] = ['trials' => 0, 'total_score' => 0.0, 'locked' => false];
        }

        $state[$day_key][$hour_key]['trials']     += 1;
        $state[$day_key][$hour_key]['total_score'] = round(
            (float) $state[$day_key][$hour_key]['total_score'] + $score,
            4
        );

        // Lock in winner once all candidates on this day have MIN_TRIALS
        if (!$state[$day_key][$hour_key]['locked']) {
            if (self::allCandidatesTried($state[$day_key], $candidates)) {
                $winner_hour = self::computeWinnerHour($state[$day_key], $candidates);
                foreach ($candidates as $h) {
                    // hk unused
                    if (isset($state[$day_key][$h])) {
                        $state[$day_key][$h]['locked'] = ($h === $winner_hour);
                    }
                }
            }
        }

        self::saveState($channel, $state);
    }

    // Retroactively learns from all existing posted data in wp_amai_scheduled_posts.
    // Safe to call multiple times.
    public static function backfill(): void
    {
        global $wpdb;

        $timezone_str = self::getSiteTimezone();
        $tz           = new \DateTimeZone($timezone_str);
        $table        = $wpdb->prefix . 'amai_scheduled_posts';

        $rows = $wpdb->get_results(
            "SELECT channel, scheduled_date, scheduled_time, stat_likes, stat_comments, stat_reach" .
            " FROM `{$table}`" .
            " WHERE status = 'posted'" .
            "   AND stats_fetched_at IS NOT NULL" .
            "   AND scheduled_date IS NOT NULL" .
            "   AND scheduled_time IS NOT NULL",
            ARRAY_A
        );

        if (empty($rows)) {
            return;
        }

        foreach ($rows as $row) {
            $channel = (string) ($row['channel'] ?? '');
            if (!isset(self::CANDIDATES[$channel])) {
                continue;
            }

            $dt_str = $row['scheduled_date'] . ' ' . $row['scheduled_time'];

            try {
                $dt          = new \DateTimeImmutable($dt_str, $tz);
                $day_of_week = ((int) $dt->format('N')) - 1;
                $hour        = (int) $dt->format('G');
                $likes       = (int) ($row['stat_likes']    ?? 0);
                $comments    = (int) ($row['stat_comments'] ?? 0);
                $reach       = isset($row['stat_reach']) && $row['stat_reach'] !== null
                               ? (int) $row['stat_reach']
                               : null;

                self::recordOutcome($channel, $day_of_week, $hour, $likes, $comments, $reach);
            } catch (\Throwable $e) {
                continue;
            }
        }
    }

    // Returns the full state array for a channel -- useful for debug/admin display.
    public static function getState(string $channel): array
    {
        return self::loadState($channel);
    }

    // Resets all learned data for a channel.
    public static function reset(string $channel): void
    {
        delete_option(self::OPTION_PREFIX . $channel);
    }

    // ---------------------------------------------------------------------------
    // Internal helpers
    // ---------------------------------------------------------------------------

    private static function loadState(string $channel): array
    {
        $raw = get_option(self::OPTION_PREFIX . $channel, null);
        if (!is_array($raw)) {
            return [];
        }
        return $raw;
        foreach ($raw as $day => $hours) {
            if (!is_array($hours)) {
                continue;
            }
            foreach ($hours as $hour => $data) {
            }
        }
    }

    private static function saveState(string $channel, array $state): void
    {
        update_option(self::OPTION_PREFIX . $channel, $state, false);
    }

    private static function resolveWinner(array $day_state, array $candidates): ?int
    {
        foreach ($candidates as $hour) {
            if (!empty($day_state[$hour]['locked'])) {
                return $hour;
            }
        }
        return null;
    }

    private static function nextExplorationSlot(array $day_state, array $candidates): int
    {
        $min_trials = PHP_INT_MAX;
        $pick       = $candidates[0];

        foreach ($candidates as $hour) {
            $trials = (int) ($day_state[$hour]['trials'] ?? 0);
            if ($trials < $min_trials) {
                $min_trials = $trials;
                $pick       = $hour;
            }
        }

        return $pick;
    }

    private static function allCandidatesTried(array $day_state, array $candidates): bool
    {
        foreach ($candidates as $hour) {
            if ((int) ($day_state[$hour]['trials'] ?? 0) < self::MIN_TRIALS) {
                return false;
            }
        }
        return true;
    }

    private static function computeWinnerHour(array $day_state, array $candidates): int
    {
        $best_avg  = -1.0;
        $best_hour = $candidates[0];

        foreach ($candidates as $hour) {
            $trials = (int)   ($day_state[$hour]['trials']      ?? 0);
            $total  = (float) ($day_state[$hour]['total_score'] ?? 0.0);
            if ($trials === 0) {
                continue;
            }
            $avg = $total / $trials;
            if ($avg > $best_avg) {
                $best_avg  = $avg;
                $best_hour = $hour;
            }
        }

        return $best_hour;
    }

    // Reads timezone from wp_amai_business_profile, falls back to WP site timezone.
    public static function getSiteTimezone(): string
    {
        global $wpdb;

        $tz = $wpdb->get_var(
            "SELECT timezone FROM {$wpdb->prefix}amai_business_profile LIMIT 1"
        );

        if (!empty($tz) && self::isValidTimezone($tz)) {
            return $tz;
        }

        $wp_tz = get_option('timezone_string', '');
        if (!empty($wp_tz) && self::isValidTimezone($wp_tz)) {
            return $wp_tz;
        }

        return 'UTC';
    }

    private static function isValidTimezone(string $tz): bool
    {
        return in_array($tz, \DateTimeZone::listIdentifiers(), true);
    }
}
