<?php
/**
 * Chatbot session retention and cleanup.
 *
 * @package KimaAI\ChatBot
 */

declare(strict_types=1);

namespace KimaAI\ChatBot;

use KimaAI\Admin\Settings;
use KimaAI\Logger;
use KimaAI\Models\ChatCsatModel;
use KimaAI\Models\ChatFeedbackAdminModel;
use KimaAI\Models\ChatFeedbackModel;
use KimaAI\Models\ChatModel;

defined('ABSPATH') || exit;

final class ChatbotSessionRetention {

  public const HOOK = 'kimaai_chatbot_session_retention_cleanup';
  public const GROUP = 'kimaai';
  public const OPTION_LAST_RUN = 'kimaai_chatbot_session_retention_last_run';
  public const LOCK_TRANSIENT = 'kimaai_chatbot_session_retention_lock';

  /**
   * Retention period presets in days, keyed by mode slug.
   *
   * @var array<string, int>
   */
  private const PRESET_DAYS = [
    '6_months' => 180,
    '1_year' => 365,
    '2_years' => 730,
    '5_years' => 1825,
  ];

  /**
   * Create the retention handler.
   *
   * @param Settings $settings Plugin settings.
   * @param ChatModel $chat_model Chat data model.
   * @param ChatFeedbackModel $feedback_model Message feedback model.
   * @param ChatCsatModel $csat_model CSAT feedback model.
   * @param ChatFeedbackAdminModel $feedback_admin_model Admin feedback model.
   */
  public function __construct(
    private Settings $settings,
    private ChatModel $chat_model,
    private ChatFeedbackModel $feedback_model,
    private ChatCsatModel $csat_model,
    private ChatFeedbackAdminModel $feedback_admin_model
  ) {
  }

  /**
   * Register WordPress action and filter hooks.
   *
   * @return void
   */
  public function registerHooks(): void {
    add_action(self::HOOK, [$this, 'runScheduledCleanup']);
    add_action('init', [$this, 'maybeMaintainSchedule'], 30);
  }

  /**
   * Ensure the cleanup schedule is correct on admin and cron requests.
   *
   * @return void
   */
  public function maybeMaintainSchedule(): void {
    $is_cron = defined('DOING_CRON') && DOING_CRON;
    $is_admin = is_admin();

    if (!$is_admin && !$is_cron) {
      return;
    }

    $resolved = $this->resolveSettings();

    if (empty($resolved['enabled'])) {
      $this->unscheduleCleanup();
      return;
    }

    $this->ensureSchedule();
    $this->maybeRunFallbackCleanup($resolved);
  }

  /**
   * Entry point for the scheduled cleanup action.
   *
   * @return void
   */
  public function runScheduledCleanup(): void {
    $this->runCleanup('schedule');
  }

  /**
   * Resolve the retention settings into a normalized payload.
   *
   * @return array
   */
  public function resolveSettings(): array {
    $chatbot_settings = $this->settings->getOption(null, 'chatbot', 'settings');

    $enabled = !empty($chatbot_settings['sessionRetentionEnabled']);
    $mode = sanitize_text_field((string) ($chatbot_settings['sessionRetentionMode'] ?? '6_months'));
    $custom_days = absint($chatbot_settings['sessionRetentionDays'] ?? 0);

    return [
      'enabled' => $enabled,
      'mode' => $mode,
      'customDays' => $custom_days,
      'effectiveDays' => $this->getEffectiveRetentionDays($chatbot_settings, $mode, $custom_days),
      'settings' => [
        'sessionRetentionEnabled' => $enabled,
        'sessionRetentionMode' => $mode,
        'sessionRetentionDays' => $custom_days,
      ],
    ];
  }

  /**
   * Calculate the effective retention days from the configured mode.
   *
   * @param array $chatbot_settings Settings array.
   * @param string|null $mode Optional pre-resolved mode.
   * @param int|null $custom_days Optional pre-resolved custom days.
   * @return int
   */
  public function getEffectiveRetentionDays(array $chatbot_settings = [], ?string $mode = null, ?int $custom_days = null): int {
    $mode = $mode !== null ? sanitize_text_field($mode) : sanitize_text_field((string) ($chatbot_settings['sessionRetentionMode'] ?? '6_months'));
    $custom_days = $custom_days !== null ? absint($custom_days) : absint($chatbot_settings['sessionRetentionDays'] ?? 0);

    $days = $mode === 'custom_days'
    ? $custom_days
    : (self::PRESET_DAYS[$mode] ?? 0);

    /**
     * Filters the effective chatbot session retention period in days.
     *
     * @param int $days Effective retention days.
     * @param array $chatbot_settings Raw chatbot settings.
     * @param string $mode Selected retention mode.
     */
    $days = (int) apply_filters('kimaai_chatbot_session_retention_days', $days, $chatbot_settings, $mode);

    return max($days, 0);
  }

  /**
   * Delete one batch of session-linked records transactionally.
   *
   * @param string[] $session_ids Session IDs to delete.
   * @return array Per-table deleted counts.
   * @throws \RuntimeException When chat rows remain after the delete.
   * @throws \Throwable On database error during cleanup.
   */
  public function deleteSessionsBatch(array $session_ids): array {
    global $wpdb;

    if ($session_ids === []) {
      return $this->emptyCounts();
    }

    // Keep chat + analytics cleanup atomic so a failed chat delete does not leave partial state behind.
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control; caching does not apply.
    $wpdb->query('START TRANSACTION');

    try {
      $feedback_ids = $this->feedback_model->getIdsBySessionIds($session_ids);
      $csat_ids = $this->csat_model->getIdsBySessionIds($session_ids);

      $counts = [
        'sessionsDeleted' => count($session_ids),
        'chatRowsDeleted' => 0,
        'feedbackRowsDeleted' => 0,
        'csatRowsDeleted' => 0,
        'adminFeedbackMessageRowsDeleted' => 0,
        'adminFeedbackCsatRowsDeleted' => 0,
      ];

      if ($feedback_ids !== []) {
        $this->resetDbLastError();
        $counts['adminFeedbackMessageRowsDeleted'] = $this->feedback_admin_model->deleteByKindAndRefIds('message', $feedback_ids);
        $this->assertNoDbError('admin feedback message cleanup');
      }

      if ($csat_ids !== []) {
        $this->resetDbLastError();
        $counts['adminFeedbackCsatRowsDeleted'] = $this->feedback_admin_model->deleteByKindAndRefIds('csat', $csat_ids);
        $this->assertNoDbError('admin feedback csat cleanup');
      }

      $this->resetDbLastError();
      $counts['feedbackRowsDeleted'] = $this->feedback_model->deleteBySessionIds($session_ids);
      $this->assertNoDbError('message feedback cleanup');

      $this->resetDbLastError();
      $counts['csatRowsDeleted'] = $this->csat_model->deleteBySessionIds($session_ids);
      $this->assertNoDbError('csat cleanup');

      $this->resetDbLastError();
      $counts['chatRowsDeleted'] = $this->chat_model->deleteSessions($session_ids);
      $this->assertNoDbError('chat cleanup');

      if ($this->chat_model->countSessions($session_ids) > 0) {
        throw new \RuntimeException('Failed to delete all chat rows for the requested sessions.');
      }

      // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control; caching does not apply.
      $wpdb->query('COMMIT');

      return $counts;
    } catch (\Throwable $exception) {
      // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control; caching does not apply.
      $wpdb->query('ROLLBACK');
      throw $exception;
    }
  }

  /**
   * Run the full cleanup loop from a given source context.
   *
   * @param string $source Source identifier ('schedule' or 'fallback').
   * @return void
   */
  private function runCleanup(string $source): void {
    $resolved = $this->resolveSettings();
    $effective_days = (int) ($resolved['effectiveDays'] ?? 0);

    if (empty($resolved['enabled']) || $effective_days < 1) {
      return;
    }

    $cutoff_timestamp = $this->getCutoffTimestamp($effective_days);
    $context = [
      'source' => $source,
      'settings' => $resolved['settings'],
      'mode' => (string) $resolved['mode'],
      'effectiveDays' => $effective_days,
      'cutoffTimestamp' => $cutoff_timestamp,
    ];

    /**
     * Filters whether chatbot session retention cleanup should run.
     *
     * @param bool $should_run Whether cleanup should run.
     * @param array $context Cleanup context including settings, mode, days, source, and cutoff timestamp.
     */
    $should_run = (bool) apply_filters('kimaai_chatbot_session_retention_should_run', true, $context);

    if (!$should_run || !$this->acquireRunLock()) {
      return;
    }

    $batch_size = $this->getBatchSize($context);
    $totals = $this->emptyCounts();
    $totals['batchesProcessed'] = 0;
    $error_message = '';

    /**
     * Fires before chatbot session retention cleanup starts.
     *
     * @param array $context Cleanup context including settings, mode, days, source, and cutoff timestamp.
     */
    do_action('kimaai_chatbot_session_retention_before_cleanup', $context);

    try {
      while (true) {
        $session_ids = $this->chat_model->getExpiredSessionIds($cutoff_timestamp, $batch_size);

        if ($session_ids === []) {
          break;
        }

        $batch_context = $context;
        $batch_context['sessionIds'] = $session_ids;

        try {
          $batch_counts = $this->deleteSessionsBatch($session_ids);
        } catch (\Throwable $exception) {
          $error_message = $exception->getMessage();
          Logger::error('Chatbot session retention cleanup failed: ' . $error_message);
          break;
        }

        $totals = $this->mergeCounts($totals, $batch_counts);
        ++$totals['batchesProcessed'];

        $after_batch_context = $batch_context;
        $after_batch_context['deletedCounts'] = $batch_counts;
      }
    } finally {
      update_option(self::OPTION_LAST_RUN, time(), false);
      delete_transient(self::LOCK_TRANSIENT);
    }

    $after_context = $context;
    $after_context['deletedCounts'] = $totals;
    $after_context['error'] = $error_message;

    /**
     * Fires after chatbot session retention cleanup finishes.
     *
     * @param array $after_context Cleanup context plus aggregate per-table deleted counts and any final error message.
     */
    do_action('kimaai_chatbot_session_retention_after_cleanup', $after_context);
  }

  /**
   * Run a fallback cleanup when the scheduled action is overdue.
   *
   * @param array $resolved Resolved retention settings.
   * @return void
   */
  private function maybeRunFallbackCleanup(array $resolved): void {
    $last_run = (int) get_option(self::OPTION_LAST_RUN, 0);
    $now = time();

    if ($last_run > 0 && ($now - $last_run) < DAY_IN_SECONDS) {
      return;
    }

    $next_run = $this->getNextScheduledCleanupTimestamp();
    if ($next_run > $now) {
      return;
    }

    if (empty($resolved['enabled']) || (int) ($resolved['effectiveDays'] ?? 0) < 1) {
      return;
    }

    $this->runCleanup('fallback');
  }

  /**
   * Register the recurring cleanup schedule if it is not already set up.
   *
   * @return void
   */
  private function ensureSchedule(): void {
    if ($this->hasActionScheduler()) {
      if (function_exists('as_next_scheduled_action') && as_next_scheduled_action(self::HOOK, [], self::GROUP)) {
        return;
      }

      if (function_exists('as_schedule_recurring_action')) {
        as_schedule_recurring_action(time() + HOUR_IN_SECONDS, DAY_IN_SECONDS, self::HOOK, [], self::GROUP);
        return;
      }
    }

    if (wp_next_scheduled(self::HOOK)) {
      return;
    }

    wp_schedule_event(time() + HOUR_IN_SECONDS, 'daily', self::HOOK);
  }

  /**
   * Remove all scheduled cleanup actions.
   *
   * @return void
   */
  private function unscheduleCleanup(): void {
    if ($this->hasActionScheduler() && function_exists('as_unschedule_all_actions')) {
      as_unschedule_all_actions(self::HOOK, [], self::GROUP);
    }

    wp_clear_scheduled_hook(self::HOOK);
  }

  /**
   * Check whether the Action Scheduler library is available.
   *
   * @return bool
   */
  private function hasActionScheduler(): bool {
    return function_exists('as_schedule_recurring_action')
    || function_exists('as_next_scheduled_action')
    || function_exists('as_unschedule_all_actions');
  }

  /**
   * Return the Unix timestamp of the next scheduled cleanup action.
   *
   * @return int
   */
  private function getNextScheduledCleanupTimestamp(): int {
    if ($this->hasActionScheduler() && function_exists('as_next_scheduled_action')) {
      $next = as_next_scheduled_action(self::HOOK, [], self::GROUP);
      if (is_numeric($next) && (int) $next > 0) {
        return (int) $next;
      }
    }

    $next = wp_next_scheduled(self::HOOK);

    return is_numeric($next) ? (int) $next : 0;
  }

  /**
   * Clear the last database error so a fresh check can detect new failures.
   *
   * @return void
   */
  private function resetDbLastError(): void {
    global $wpdb;

    $wpdb->last_error = '';
  }

  /**
   * Throw a RuntimeException if a database error occurred after the last operation.
   *
   * @param string $operation Short description of the operation that was attempted.
   * @return void
   * @throws \RuntimeException When a database error is detected.
   */
  private function assertNoDbError(string $operation): void {
    global $wpdb;

    if ($wpdb->last_error !== '') {
      throw new \RuntimeException(
        sprintf(
          'Database error during %s: %s',
          esc_html($operation),
          esc_html($wpdb->last_error)
        )
      );
    }
  }

  /**
   * Attempt to acquire a run lock to prevent concurrent cleanup runs.
   *
   * @return bool True if the lock was acquired, false if already held.
   */
  private function acquireRunLock(): bool {
    if (get_transient(self::LOCK_TRANSIENT)) {
      return false;
    }

    set_transient(self::LOCK_TRANSIENT, 1, 2 * HOUR_IN_SECONDS);

    return true;
  }

  /**
   * Calculate the UTC cutoff datetime string for the given retention period.
   *
   * @param int $effective_days Number of days to retain sessions.
   * @return string MySQL-formatted datetime string in the site's timezone.
   */
  private function getCutoffTimestamp(int $effective_days): string {
    return wp_date('Y-m-d H:i:s', time() - ($effective_days * DAY_IN_SECONDS), wp_timezone());
  }

  /**
   * Get the configured batch size for the cleanup loop.
   *
   * @param array $context Cleanup context.
   * @return int
   */
  private function getBatchSize(array $context): int {
    /**
     * Filters the chatbot session retention cleanup batch size.
     *
     * @param int $batch_size Number of sessions to delete per batch.
     * @param array $context Cleanup context including settings, mode, days, source, and cutoff timestamp.
     */
    $batch_size = (int) apply_filters('kimaai_chatbot_session_retention_batch_size', 100, $context);

    return max(1, $batch_size);
  }

  /**
   * Merge two count arrays by summing each key.
   *
   * @param array $left Base counts.
   * @param array $right Counts to add.
   * @return array
   */
  private function mergeCounts(array $left, array $right): array {
    foreach ($right as $key => $value) {
      $left[$key] = (int) ($left[$key] ?? 0) + (int) $value;
    }

    return $left;
  }

  /**
   * Return an array of zeroed per-table deletion counts.
   *
   * @return array
   */
  private function emptyCounts(): array {
    return [
      'sessionsDeleted' => 0,
      'chatRowsDeleted' => 0,
      'feedbackRowsDeleted' => 0,
      'csatRowsDeleted' => 0,
      'adminFeedbackMessageRowsDeleted' => 0,
      'adminFeedbackCsatRowsDeleted' => 0,
    ];
  }
}
