<?php
/**
 * Event_Logger — write-attribution audit log for MCP-driven changes.
 *
 * One chokepoint, one writer. Schema is installed by the Activator and
 * upgraded on admin_init when DB_VERSION bumps. Reads are not logged in v1
 * (see .context/project.json → logging.scope).
 *
 * @package Ppok\AIToolkit
 * @license GPL-2.0-or-later
 */

namespace Ppok\AIToolkit;

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

final class Event_Logger {

	public const DB_VERSION        = '1';
	public const DB_VERSION_OPTION = 'ppok_ai_toolkit_db_version';
	public const TABLE_BASE        = 'ppok_ai_events';

	/**
	 * Hard cap on the audit row's serialized payload column. The column is
	 * LONGTEXT (4GB) but a single row holding a multi-megabyte block tree
	 * burns memory on every read of the activity viewer and bloats backups
	 * with little forensic benefit. Anything past the cap is replaced with
	 * a stub describing the original size + the keys that were dropped.
	 * Override with the ppok_ai_toolkit_event_payload_max_bytes filter.
	 */
	public const PAYLOAD_MAX_BYTES_DEFAULT = 262144; // 256 KiB

	private ?Request_Context $context;

	public function __construct( ?Request_Context $context = null ) {
		$this->context = $context;
	}

	public function set_request_context( Request_Context $context ): void {
		$this->context = $context;
	}

	public static function table_name(): string {
		global $wpdb;
		return $wpdb->prefix . self::TABLE_BASE;
	}

	public function maybe_install_schema(): void {
		if ( get_option( self::DB_VERSION_OPTION ) === self::DB_VERSION ) {
			return;
		}
		self::install_schema();
		update_option( self::DB_VERSION_OPTION, self::DB_VERSION );
	}

	public static function install_schema(): void {
		global $wpdb;
		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		$charset_collate = $wpdb->get_charset_collate();
		$table           = self::table_name();

		// dbDelta is whitespace-sensitive: each column on its own line,
		// two spaces between PRIMARY KEY and the column, KEY (not INDEX).
		$sql = "CREATE TABLE {$table} (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  created_at DATETIME NOT NULL,
  ability_slug VARCHAR(191) NOT NULL,
  correlation_id VARCHAR(64) NULL,
  mcp_client VARCHAR(191) NULL,
  actor_user_id BIGINT UNSIGNED NULL,
  actor_token_id VARCHAR(64) NULL,
  target_post_id BIGINT UNSIGNED NULL,
  result VARCHAR(32) NOT NULL,
  error_code VARCHAR(64) NULL,
  reason VARCHAR(500) NULL,
  duration_ms INT UNSIGNED NULL,
  ip VARCHAR(45) NULL,
  payload LONGTEXT NULL,
  PRIMARY KEY  (id),
  KEY created_at (created_at),
  KEY ability_slug (ability_slug),
  KEY correlation_id (correlation_id),
  KEY mcp_client (mcp_client),
  KEY actor_user_id (actor_user_id),
  KEY target_post_id (target_post_id),
  KEY result (result),
  KEY error_code (error_code)
) {$charset_collate};";

		dbDelta( $sql );
	}

	public function record( array $event ): int {
		global $wpdb;
		$row = $this->normalize( $event );
		$wpdb->insert( self::table_name(), $row );
		return (int) $wpdb->insert_id;
	}

	private function normalize( array $event ): array {
		$ctx_correlation = $this->context ? $this->context->get_correlation_id() : null;
		$ctx_client      = $this->context ? $this->context->get_mcp_client() : null;
		$ctx_ip          = $this->context ? $this->context->get_ip() : null;

		$correlation_id = $event['correlation_id'] ?? $ctx_correlation;
		$mcp_client     = $event['mcp_client'] ?? $ctx_client;
		$ip             = $event['ip'] ?? $ctx_ip;

		return array(
			'created_at'     => $event['created_at'] ?? gmdate( 'Y-m-d H:i:s' ),
			'ability_slug'   => self::trunc( $event['ability_slug'] ?? '', 191 ),
			'correlation_id' => ( null !== $correlation_id && '' !== $correlation_id ) ? self::trunc( $correlation_id, 64 ) : null,
			'mcp_client'     => ( null !== $mcp_client && '' !== $mcp_client ) ? self::trunc( $mcp_client, 191 ) : null,
			'actor_user_id'  => isset( $event['actor_user_id'] ) ? (int) $event['actor_user_id'] : null,
			'actor_token_id' => isset( $event['actor_token_id'] ) ? self::trunc( $event['actor_token_id'], 64 ) : null,
			'target_post_id' => isset( $event['target_post_id'] ) ? (int) $event['target_post_id'] : null,
			'result'         => self::trunc( $event['result'] ?? 'server_error', 32 ),
			'error_code'     => isset( $event['error_code'] ) ? self::trunc( $event['error_code'], 64 ) : null,
			'reason'         => isset( $event['reason'] ) ? self::trunc( $event['reason'], 500 ) : null,
			'duration_ms'    => isset( $event['duration_ms'] ) ? max( 0, (int) $event['duration_ms'] ) : null,
			'ip'             => ( null !== $ip && '' !== $ip ) ? self::trunc( $ip, 45 ) : null,
			'payload'        => self::shape_payload( $event['payload'] ?? null ),
		);
	}

	/**
	 * Serialize the payload to JSON and clamp it to the audit-row cap.
	 * Anything oversized is replaced with a stub identifying the original
	 * size and the top-level keys that were dropped, so the audit row still
	 * tells the story ("a giant input came in") without storing the whole
	 * giant input.
	 *
	 * @param mixed $payload
	 */
	private static function shape_payload( $payload ): ?string {
		if ( null === $payload ) {
			return null;
		}
		if ( is_string( $payload ) ) {
			$json = $payload;
		} else {
			$json = wp_json_encode( $payload );
			if ( false === $json ) {
				return wp_json_encode(
					array(
						'_truncated' => true,
						'_reason'    => 'payload could not be JSON-encoded (non-UTF-8 bytes)',
					)
				);
			}
		}

		$max = self::payload_max_bytes();
		if ( strlen( $json ) <= $max ) {
			return $json;
		}

		$keys = is_array( $payload ) ? array_keys( $payload ) : array();
		return wp_json_encode(
			array(
				'_truncated'     => true,
				'_original_size' => strlen( $json ),
				'_limit'         => $max,
				'_dropped_keys'  => $keys,
			)
		);
	}

	private static function payload_max_bytes(): int {
		$max = (int) apply_filters( 'ppok_ai_toolkit_event_payload_max_bytes', self::PAYLOAD_MAX_BYTES_DEFAULT );
		return $max > 0 ? $max : self::PAYLOAD_MAX_BYTES_DEFAULT;
	}

	private static function trunc( $value, int $max ): string {
		return substr( (string) $value, 0, $max );
	}
}
