<?php
/**
 * Settings service for the Comment Security Layer addon.
 *
 * @package Polanger_Dashboard_Tools
 */

if ( ! defined( 'ABSPATH' ) ) {
  exit;
}

/**
 * Manages defaults, presets and sanitization of the addon settings.
 */
class PDT_Comment_Security_Settings {

  /**
   * Option key used for plugin settings.
   *
   * @var string
   */
  const OPTION_KEY = 'pdt_comment_security_settings';

  /**
   * Memo cache.
   *
   * @var array|null
   */
  private $cached;

  /**
   * Default settings.
   *
   * @return array
   */
  public function get_defaults() {
    return array(
      'enabled'              => true,
      'protect_logged_in'    => false,

      // Bot Protection.
      'honeypot_enabled'     => true,
      'honeypot_field_name'  => 'pdt_cs_url2',
      'timing_enabled'       => true,
      'timing_min_seconds'   => 4,
      'timing_max_seconds'   => 3600,

      // Rate Limiting.
      'flood_enabled'        => true,
      'flood_per_minute'     => 4,
      'flood_per_hour'       => 20,

      // Content Rules.
      'max_links'            => 3,
      'block_non_latin_only' => false,
      'keyword_blocklist'    => array(),
      'keyword_match_mode'   => 'word', // word | substring
      'url_blocklist'        => array(),

      // Behavior score.
      'score_enabled'        => true,
      'score_threshold_spam' => 60,
      'score_threshold_drop' => 90,

      // Silent mode.
      'silent_mode'          => 'spam', // spam | trash | drop
      'show_human_message'   => false,

      // Logs.
      'logging_enabled'      => true,
      'log_allowed'          => false,
      'log_retention_days'   => 30,

      // UX.
      'preset'               => 'balanced',
    );
  }

  /**
   * Get current settings, merged with defaults.
   *
   * @return array
   */
  public function get_settings() {
    if ( null !== $this->cached ) {
      return $this->cached;
    }

    $stored = get_option( self::OPTION_KEY, array() );
    if ( ! is_array( $stored ) ) {
      $stored = array();
    }

    $merged = array_merge( $this->get_defaults(), $stored );

    /**
     * Allow other addons (e.g. Multisite Control) to override comment security settings.
     */
    $merged = apply_filters( 'polanger_effective_settings', $merged, self::OPTION_KEY, $this->get_defaults(), 'comment-security' );

    $this->cached = $merged;
    return $merged;
  }

  /**
   * Save settings.
   *
   * Whenever the honeypot field name changes we record the previous name in
   * a short grace window so cached pages still posting the old field name
   * do not get flagged as `honeypot_missing` for the next 24 hours.
   *
   * @param array $settings           Raw input.
   * @param bool  $recalculate_preset Re-detect matching preset id.
   * @return array Sanitized settings actually persisted.
   */
  public function save_settings( $settings, $recalculate_preset = true ) {
    $previous = get_option( self::OPTION_KEY, array() );
    if ( ! is_array( $previous ) ) {
      $previous = array();
    }

    $sanitized = $this->sanitize_settings( $settings );
    if ( $recalculate_preset ) {
      $sanitized['preset'] = $this->detect_matching_preset_id( $sanitized );
    } elseif ( empty( $sanitized['preset'] ) || ( ! array_key_exists( $sanitized['preset'], $this->get_presets() ) && 'custom' !== $sanitized['preset'] ) ) {
      $sanitized['preset'] = $this->get_defaults()['preset'];
    }

    $previous_field = isset( $previous['honeypot_field_name'] ) ? sanitize_key( $previous['honeypot_field_name'] ) : '';
    $new_field      = isset( $sanitized['honeypot_field_name'] ) ? sanitize_key( $sanitized['honeypot_field_name'] ) : '';
    if ( '' !== $previous_field && '' !== $new_field && $previous_field !== $new_field ) {
      $this->add_honeypot_grace_alias( $previous_field );
    }

    update_option( self::OPTION_KEY, $sanitized );
    $this->cached = null;
    return $sanitized;
  }

  /**
   * Track a previous honeypot field name as still acceptable for 24 hours.
   *
   * Cached pages submit the old field name; without this, every cache hit
   * after a rename would be flagged as `honeypot_missing`.
   *
   * @param string $alias Previous field name.
   * @return void
   */
  private function add_honeypot_grace_alias( $alias ) {
    $alias = sanitize_key( $alias );
    if ( '' === $alias ) {
      return;
    }
    $aliases = get_option( self::OPTION_KEY . '_grace_hp', array() );
    if ( ! is_array( $aliases ) ) {
      $aliases = array();
    }

    $now    = time();
    $expiry = $now + DAY_IN_SECONDS;

    $kept = array();
    foreach ( $aliases as $existing => $expires_at ) {
      $existing   = sanitize_key( (string) $existing );
      $expires_at = (int) $expires_at;
      if ( '' === $existing || $expires_at <= $now ) {
        continue;
      }
      $kept[ $existing ] = $expires_at;
    }
    $kept[ $alias ] = $expiry;

    if ( count( $kept ) > 5 ) {
      asort( $kept );
      $kept = array_slice( $kept, -5, null, true );
    }

    update_option( self::OPTION_KEY . '_grace_hp', $kept, false );
  }

  /**
   * Get currently active honeypot grace aliases (recent renames still trusted).
   *
   * @return array<string>
   */
  public function get_honeypot_grace_aliases() {
    $aliases = get_option( self::OPTION_KEY . '_grace_hp', array() );
    if ( ! is_array( $aliases ) ) {
      return array();
    }
    $now   = time();
    $valid = array();
    foreach ( $aliases as $alias => $expires_at ) {
      $alias      = sanitize_key( (string) $alias );
      $expires_at = (int) $expires_at;
      if ( '' === $alias || $expires_at <= $now ) {
        continue;
      }
      $valid[] = $alias;
    }
    return $valid;
  }

  /**
   * Save only a subset of settings keys while preserving other tabs.
   *
   * @param array $input        Raw tab input.
   * @param array $allowed_keys Keys owned by the current tab.
   * @return array
   */
  public function save_partial_settings( $input, $allowed_keys ) {
    $current = $this->get_settings();
    $input = is_array( $input ) ? $input : array();
    $allowed_keys = is_array( $allowed_keys ) ? $allowed_keys : array();

    $bool_keys = array(
      'enabled',
      'protect_logged_in',
      'honeypot_enabled',
      'timing_enabled',
      'flood_enabled',
      'block_non_latin_only',
      'score_enabled',
      'show_human_message',
      'logging_enabled',
      'log_allowed',
    );

    foreach ( $allowed_keys as $key ) {
      if ( in_array( $key, $bool_keys, true ) ) {
        $current[ $key ] = ! empty( $input[ $key ] );
        continue;
      }

      if ( array_key_exists( $key, $input ) ) {
        $current[ $key ] = $input[ $key ];
      }
    }

    $recalculate_preset = $this->keys_affect_preset_identity( $allowed_keys );

    return $this->save_settings( $current, $recalculate_preset );
  }

  /**
   * Apply a preset by id.
   *
   * Only the protection-profile keys (identity keys + enabled flag) are
   * overwritten. User-specific data — keyword/URL blocklists, honeypot
   * field name, logging config, show_human_message — is intentionally
   * preserved so clicking a mode button never silently wipes curated data.
   *
   * @param string $preset_id Preset slug.
   * @return array Sanitized merged settings.
   */
  public function apply_preset( $preset_id ) {
    $preset_id = sanitize_key( $preset_id );
    $presets   = $this->get_presets();
    if ( ! isset( $presets[ $preset_id ] ) ) {
      return $this->get_settings();
    }

    // Start from the current stored settings so user data is preserved.
    $current = $this->get_settings();

    // Overlay only the keys that define the protection profile.
    $preset_values = $presets[ $preset_id ]['values'];
    foreach ( $this->get_preset_identity_keys() as $key ) {
      if ( array_key_exists( $key, $preset_values ) ) {
        $current[ $key ] = $preset_values[ $key ];
      }
    }

    // Also honour the top-level enabled flag carried by every preset.
    if ( array_key_exists( 'enabled', $preset_values ) ) {
      $current['enabled'] = $preset_values['enabled'];
    }

    // Pin the preset marker explicitly; skip re-detection so it is never
    // silently demoted to 'custom' by floating-point identity mismatches.
    $current['preset'] = $preset_id;

    return $this->save_settings( $current, false );
  }

  /**
   * Predefined protection presets.
   *
   * @return array
   */
  public function get_presets() {
    return array(
      'light'    => array(
        'label'       => __( 'Light', 'polanger-admin-menu-manager' ),
        'description' => __( 'Minimal protection. Honeypot only.', 'polanger-admin-menu-manager' ),
        'values'      => $this->build_preset_values(
          array(
            'timing_enabled'       => false,
            'flood_enabled'        => false,
            'score_enabled'        => false,
            'max_links'            => 5,
            'silent_mode'          => 'spam',
          )
        ),
      ),
      'balanced' => array(
        'label'       => __( 'Balanced', 'polanger-admin-menu-manager' ),
        'description' => __( 'Recommended. Honeypot + timing + flood + score.', 'polanger-admin-menu-manager' ),
        'values'      => $this->build_preset_values(
          array(
            'timing_enabled'       => true,
            'timing_min_seconds'   => 4,
            'flood_enabled'        => true,
            'flood_per_minute'     => 4,
            'flood_per_hour'       => 20,
            'score_enabled'        => true,
            'score_threshold_spam' => 60,
            'score_threshold_drop' => 90,
            'max_links'            => 3,
            'silent_mode'          => 'spam',
          )
        ),
      ),
      'strict'   => array(
        'label'       => __( 'Strict', 'polanger-admin-menu-manager' ),
        'description' => __( 'Aggressive. Lower thresholds, silent drop on high score.', 'polanger-admin-menu-manager' ),
        'values'      => $this->build_preset_values(
          array(
            'timing_enabled'       => true,
            'timing_min_seconds'   => 6,
            'flood_enabled'        => true,
            'flood_per_minute'     => 2,
            'flood_per_hour'       => 10,
            'score_enabled'        => true,
            'score_threshold_spam' => 40,
            'score_threshold_drop' => 70,
            'max_links'            => 2,
            'silent_mode'          => 'drop',
          )
        ),
      ),
    );
  }

  /**
   * Build a complete preset payload so every mode is deterministic.
   *
   * @param array $overrides Preset-specific overrides.
   * @return array
   */
  private function build_preset_values( $overrides ) {
    $values = $this->get_defaults();
    $values['enabled'] = true;

    return array_merge( $values, $overrides );
  }

  /**
   * Detect the active preset from effective settings.
   *
   * The stored `preset` key is authoritative for named presets (light /
   * balanced / strict) but is cross-checked against the actual settings so
   * an out-of-band edit (WP-CLI, direct DB write, or a filter override) is
   * caught and demoted to 'custom' instead of silently misreporting the mode.
   *
   * For the 'custom' value we trust it outright — it was explicitly written
   * by save_settings when the settings drifted away from any preset.
   *
   * @param array|null $settings Settings.
   * @return string
   */
  public function get_active_preset_id( $settings = null ) {
    $settings = is_array( $settings ) ? $settings : $this->get_settings();

    if ( ! empty( $settings['preset'] ) ) {
      $stored_preset = sanitize_key( $settings['preset'] );

      // 'custom' is always authoritative — nothing to cross-check.
      if ( 'custom' === $stored_preset ) {
        return 'custom';
      }

      // For named presets, verify the actual settings still match.
      $presets = $this->get_presets();
      if ( array_key_exists( $stored_preset, $presets ) ) {
        if ( $this->settings_match_preset( $settings, $presets[ $stored_preset ]['values'] ) ) {
          return $stored_preset;
        }
        // Settings have drifted — fall through to full detection below.
      }
    }

    return $this->detect_matching_preset_id( $settings );
  }

  /**
   * Get the active preset label for UI display.
   *
   * @param array|null $settings Settings.
   * @return string
   */
  public function get_active_preset_label( $settings = null ) {
    $preset_id = $this->get_active_preset_id( $settings );
    $presets   = $this->get_presets();

    if ( isset( $presets[ $preset_id ]['label'] ) ) {
      return $presets[ $preset_id ]['label'];
    }

    return __( 'Custom', 'polanger-admin-menu-manager' );
  }

  /**
   * Compare settings against a preset definition.
   *
   * @param array $settings Settings.
   * @param array $preset   Preset values.
   * @return bool
   */
  private function settings_match_preset( $settings, $preset ) {
    foreach ( $this->get_preset_identity_keys() as $key ) {
      if ( ! array_key_exists( $key, $preset ) ) {
        continue;
      }

      $value = $preset[ $key ];
      if ( ! array_key_exists( $key, $settings ) || $settings[ $key ] !== $value ) {
        return false;
      }
    }

    return true;
  }

  /**
   * Detect which preset matches the current core protection profile.
   *
   * Auxiliary data such as blocklist entries should not force a mode switch
   * to "Custom" when the underlying protection profile remains unchanged.
   *
   * @param array|null $settings Settings.
   * @return string
   */
  private function detect_matching_preset_id( $settings = null ) {
    $settings = is_array( $settings ) ? $settings : $this->get_settings();

    foreach ( $this->get_presets() as $preset_id => $preset ) {
      if ( $this->settings_match_preset( $settings, $preset['values'] ) ) {
        return $preset_id;
      }
    }

    return 'custom';
  }

  /**
   * Get the setting keys that define the selected protection profile.
   *
   * Content lists, logging, and UX metadata are intentionally excluded so
   * operational data entry does not change the visible mode badge.
   *
   * @return array<int,string>
   */
  private function get_preset_identity_keys() {
    return array(
      'honeypot_enabled',
      'timing_enabled',
      'timing_min_seconds',
      'timing_max_seconds',
      'flood_enabled',
      'flood_per_minute',
      'flood_per_hour',
      'max_links',
      'block_non_latin_only',
      'score_enabled',
      'score_threshold_spam',
      'score_threshold_drop',
      'silent_mode',
    );
  }

  /**
   * Whether a partial save touches fields that should change the visible mode.
   *
   * @param array $keys Submitted setting keys.
   * @return bool
   */
  private function keys_affect_preset_identity( $keys ) {
    $keys = is_array( $keys ) ? $keys : array();
    return ! empty( array_intersect( $keys, $this->get_preset_identity_keys() ) );
  }

  /**
   * Sanitize raw settings input.
   *
   * @param array $input Raw input.
   * @return array Sanitized values merged on top of defaults.
   */
  public function sanitize_settings( $input ) {
    $defaults  = $this->get_defaults();
    if ( ! is_array( $input ) ) {
      return $defaults;
    }

    $clean = $defaults;

    $bool_keys = array(
      'enabled', 'protect_logged_in', 'honeypot_enabled', 'timing_enabled',
      'flood_enabled', 'block_non_latin_only', 'score_enabled',
      'show_human_message', 'logging_enabled', 'log_allowed',
    );
    foreach ( $bool_keys as $key ) {
      $clean[ $key ] = ! empty( $input[ $key ] );
    }

    if ( isset( $input['honeypot_field_name'] ) ) {
      $name = sanitize_key( $input['honeypot_field_name'] );
      if ( '' === $name ) {
        $name = $defaults['honeypot_field_name'];
      }
      $clean['honeypot_field_name'] = $name;
    }

    $int_ranges = array(
      'timing_min_seconds'   => array( 1, 600 ),
      'timing_max_seconds'   => array( 60, 86400 ),
      'flood_per_minute'     => array( 1, 60 ),
      'flood_per_hour'       => array( 1, 1000 ),
      'max_links'            => array( 0, 50 ),
      'score_threshold_spam' => array( 0, 100 ),
      'score_threshold_drop' => array( 0, 100 ),
      'log_retention_days'   => array( 0, 365 ),
    );
    foreach ( $int_ranges as $key => $range ) {
      if ( isset( $input[ $key ] ) ) {
        $value         = (int) $input[ $key ];
        $value         = max( $range[0], min( $range[1], $value ) );
        $clean[ $key ] = $value;
      }
    }

    if ( $clean['timing_max_seconds'] <= $clean['timing_min_seconds'] ) {
      $clean['timing_max_seconds'] = max( 60, $clean['timing_min_seconds'] + 60 );
    }

    if ( $clean['score_threshold_drop'] < $clean['score_threshold_spam'] ) {
      $clean['score_threshold_drop'] = $clean['score_threshold_spam'];
    }

    if ( isset( $input['silent_mode'] ) ) {
      $mode = sanitize_key( $input['silent_mode'] );
      if ( ! in_array( $mode, array( 'spam', 'trash', 'drop' ), true ) ) {
        $mode = 'spam';
      }
      $clean['silent_mode'] = $mode;
    }

    if ( isset( $input['keyword_match_mode'] ) ) {
      $mode = sanitize_key( $input['keyword_match_mode'] );
      if ( ! in_array( $mode, array( 'word', 'substring' ), true ) ) {
        $mode = 'word';
      }
      $clean['keyword_match_mode'] = $mode;
    }

    if ( isset( $input['preset'] ) ) {
      $preset = sanitize_key( $input['preset'] );
      if ( ! array_key_exists( $preset, $this->get_presets() ) && 'custom' !== $preset ) {
        $preset = 'custom';
      }
      $clean['preset'] = $preset;
    }

    $clean['keyword_blocklist'] = $this->sanitize_string_list( isset( $input['keyword_blocklist'] ) ? $input['keyword_blocklist'] : '' );
    $clean['url_blocklist']     = $this->sanitize_string_list( isset( $input['url_blocklist'] ) ? $input['url_blocklist'] : '', true );

    return $clean;
  }

  /**
   * Sanitize a textarea / array based blocklist into an array of clean strings.
   *
   * @param mixed $raw     Raw value (string with newlines or array).
   * @param bool  $is_host If true, normalize values as URL hosts (lowercase, trim).
   * @return array<string>
   */
  private function sanitize_string_list( $raw, $is_host = false ) {
    if ( is_array( $raw ) ) {
      $list = $raw;
    } else {
      $list = preg_split( '/[\r\n,]+/', (string) $raw );
    }

    $clean = array();
    foreach ( (array) $list as $item ) {
      $item = trim( wp_strip_all_tags( (string) $item ) );
      if ( '' === $item ) {
        continue;
      }

      if ( $is_host ) {
        $item = strtolower( $item );
        // If a full URL was pasted, reduce to host.
        $maybe_host = wp_parse_url( $item, PHP_URL_HOST );
        if ( $maybe_host ) {
          $item = strtolower( $maybe_host );
        }
        $item = preg_replace( '/^www\./', '', $item );
      }

      $item = mb_substr( $item, 0, 200 );
      $clean[] = $item;
    }

    $clean = array_values( array_unique( $clean ) );
    if ( count( $clean ) > 500 ) {
      $clean = array_slice( $clean, 0, 500 );
    }
    return $clean;
  }

  /**
   * Convert an array list to a textarea string.
   *
   * @param array $list List items.
   * @return string
   */
  public function list_to_textarea( $list ) {
    if ( ! is_array( $list ) ) {
      return '';
    }
    return implode( "\n", array_map( 'strval', $list ) );
  }
}
