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

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

/**
 * Persists protection events into a dedicated DB table and exposes
 * read helpers for the Logs & Statistics screen.
 */
class PDT_Comment_Security_Logger {

  /**
   * Table version (bump on schema change).
   *
   * @var string
   */
  const DB_VERSION = '1.0.0';

  /**
   * Option key storing installed schema version.
   *
   * @var string
   */
  const VERSION_OPTION = 'pdt_comment_security_db_version';

  /**
   * Possible decisions logged by the engine.
   *
   * @var array
   */
  const DECISIONS = array( 'allow', 'spam', 'trash', 'drop' );

  /**
   * Runtime install guard.
   *
   * @var bool
   */
  private $installation_checked = false;

  /**
   * Get table name with prefix.
   *
   * @return string
   */
  public function table_name() {
    global $wpdb;
    return $wpdb->prefix . 'pdt_comment_security_log';
  }

  /**
   * Ensure table exists; runs cheap version check.
   *
   * @return void
   */
  public function maybe_install_table() {
    if ( $this->installation_checked ) {
      return;
    }

    $this->installation_checked = true;

    if ( get_option( self::VERSION_OPTION ) === self::DB_VERSION ) {
      return;
    }

    $this->install_table();
    if ( $this->table_exists() ) {
      update_option( self::VERSION_OPTION, self::DB_VERSION );
    }
  }

  /**
   * Create / upgrade table.
   *
   * @return void
   */
  public function install_table() {
    global $wpdb;
    $table_name      = $this->table_name();
    $charset_collate = $wpdb->get_charset_collate();

    $sql = "CREATE TABLE {$table_name} (
      id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
      created_at DATETIME NOT NULL,
      decision VARCHAR(20) NOT NULL,
      reason VARCHAR(80) NOT NULL,
      score SMALLINT NOT NULL DEFAULT 0,
      ip VARCHAR(64) NOT NULL DEFAULT '',
      ua VARCHAR(255) NOT NULL DEFAULT '',
      author VARCHAR(190) NOT NULL DEFAULT '',
      author_email VARCHAR(190) NOT NULL DEFAULT '',
      post_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
      content_excerpt TEXT NULL,
      PRIMARY KEY (id),
      KEY decision (decision),
      KEY created_at (created_at),
      KEY ip (ip)
    ) {$charset_collate};";

    if ( ! function_exists( 'dbDelta' ) ) {
      require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    }
    dbDelta( $sql );
  }

  /**
   * Insert a log row.
   *
   * Builds the column array and matching format array explicitly so a
   * future schema change cannot silently mis-align values and printf
   * placeholders.
   *
   * @param array $data Pre-validated event data.
   * @return int|false  Inserted row id or false on failure.
   */
  public function log_event( $data ) {
    global $wpdb;

    $this->maybe_install_table();
    if ( ! $this->table_exists() ) {
      return false;
    }

    $defaults = array(
      'created_at'      => current_time( 'mysql' ),
      'decision'        => 'allow',
      'reason'          => '',
      'score'           => 0,
      'ip'              => '',
      'ua'              => '',
      'author'          => '',
      'author_email'    => '',
      'post_id'         => 0,
      'content_excerpt' => '',
    );
    $merged = wp_parse_args( $data, $defaults );

    if ( ! in_array( $merged['decision'], self::DECISIONS, true ) ) {
      $merged['decision'] = 'allow';
    }

    $columns = array(
      'created_at'      => array( '%s', (string) $merged['created_at'] ),
      'decision'        => array( '%s', (string) $merged['decision'] ),
      'reason'          => array( '%s', substr( sanitize_text_field( (string) $merged['reason'] ), 0, 80 ) ),
      'score'           => array( '%d', (int) $merged['score'] ),
      'ip'              => array( '%s', substr( sanitize_text_field( (string) $merged['ip'] ), 0, 64 ) ),
      'ua'              => array( '%s', substr( sanitize_text_field( (string) $merged['ua'] ), 0, 255 ) ),
      'author'          => array( '%s', substr( sanitize_text_field( (string) $merged['author'] ), 0, 190 ) ),
      'author_email'    => array( '%s', substr( sanitize_email( (string) $merged['author_email'] ), 0, 190 ) ),
      'post_id'         => array( '%d', absint( $merged['post_id'] ) ),
      'content_excerpt' => array( '%s', mb_substr( wp_strip_all_tags( (string) $merged['content_excerpt'] ), 0, 500 ) ),
    );

    $row     = array();
    $formats = array();
    foreach ( $columns as $column => $pair ) {
      $row[ $column ] = $pair[1];
      $formats[]      = $pair[0];
    }

    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- custom log table writes.
    $inserted = $wpdb->insert( $this->table_name(), $row, $formats );

    if ( false === $inserted ) {
      return false;
    }
    return (int) $wpdb->insert_id;
  }

  /**
   * Get recent log entries with pagination.
   *
   * @param array $args See defaults below.
   * @return array{
   *   items: array<int,object>,
   *   total: int,
   *   per_page: int,
   *   page: int
   * }
   */
  public function get_logs( $args = array() ) {
    global $wpdb;

    $defaults = array(
      'per_page' => 25,
      'page'     => 1,
      'decision' => '',
    );
    $args = wp_parse_args( $args, $defaults );

    $per_page = max( 1, min( 200, (int) $args['per_page'] ) );
    $page     = max( 1, (int) $args['page'] );
    $offset   = ( $page - 1 ) * $per_page;

    $where  = '1=1';
    $params = array();
    if ( ! empty( $args['decision'] ) && in_array( $args['decision'], self::DECISIONS, true ) ) {
      $where   .= ' AND decision = %s';
      $params[] = $args['decision'];
    }

    $table = $this->table_name();
    // Table name is internal and validated by class; safe to interpolate.
    // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
    $sql_total = "SELECT COUNT(*) FROM {$table} WHERE {$where}";
    if ( ! empty( $params ) ) {
      // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared
      $total = (int) $wpdb->get_var( $wpdb->prepare( $sql_total, $params ) );
    } else {
      // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared
      $total = (int) $wpdb->get_var( $sql_total );
    }

    $params_with_pagination = array_merge( $params, array( $per_page, $offset ) );
    // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
    $sql_items = "SELECT * FROM {$table} WHERE {$where} ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d";
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared
    $items = $wpdb->get_results( $wpdb->prepare( $sql_items, $params_with_pagination ) );

    return array(
      'items'    => is_array( $items ) ? $items : array(),
      'total'    => $total,
      'per_page' => $per_page,
      'page'     => $page,
    );
  }

  /**
   * Get aggregated counts by decision for the last N days.
   *
   * @param int $days Days back to include.
   * @return array decision => count.
   */
  public function get_stats( $days = 30 ) {
    global $wpdb;

    $days  = max( 1, min( 365, (int) $days ) );
    $since = gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS );
    $table = $this->table_name();

    // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
    $sql = "SELECT decision, COUNT(*) AS cnt FROM {$table} WHERE created_at >= %s GROUP BY decision";
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared
    $rows = $wpdb->get_results( $wpdb->prepare( $sql, $since ) );

    $stats = array_fill_keys( self::DECISIONS, 0 );
    if ( is_array( $rows ) ) {
      foreach ( $rows as $row ) {
        if ( isset( $row->decision, $row->cnt ) && isset( $stats[ $row->decision ] ) ) {
          $stats[ $row->decision ] = (int) $row->cnt;
        }
      }
    }
    return $stats;
  }

  /**
   * Delete old log rows past retention.
   *
   * @param int $retention_days Retention in days. 0 disables purge.
   * @return int Rows deleted.
   */
  public function purge( $retention_days ) {
    global $wpdb;

    $retention_days = (int) $retention_days;
    if ( $retention_days <= 0 ) {
      return 0;
    }

    $cutoff = gmdate( 'Y-m-d H:i:s', time() - $retention_days * DAY_IN_SECONDS );
    $table  = $this->table_name();

    // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
    $sql = "DELETE FROM {$table} WHERE created_at < %s";
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.NoCaching
    $deleted = $wpdb->query( $wpdb->prepare( $sql, $cutoff ) );
    return (int) $deleted;
  }

  /**
   * Truncate the log table.
   *
   * @return bool
   */
  public function clear_all() {
    global $wpdb;
    $table = $this->table_name();
    // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
    $sql = "TRUNCATE TABLE {$table}";
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
    return false !== $wpdb->query( $sql );
  }

  /**
   * Check whether the custom log table currently exists.
   *
   * @return bool
   */
  private function table_exists() {
    global $wpdb;

    $table_name = $this->table_name();
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared
    $found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) );

    return $table_name === $found;
  }
}
