<?php
/**
 * A record of who changed what.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Audit;

use TextTango\Install\Schema;
use TextTango\Support\Paging;

defined( 'ABSPATH' ) || exit;

/**
 * Writes and reads the audit trail (spec §19).
 *
 * The questions this exists to answer are all asked after the fact, usually by
 * somebody who was not there: who replaced the API token last Tuesday, who
 * turned the daily cap down to ten, who ticked marketing consent on a
 * customer's profile. None of them can be answered from the message log, which
 * records what was sent rather than what was decided.
 *
 * ### Nothing here is a secret
 *
 * Values are never recorded for credentials — only that one changed. An audit
 * trail is read by more people than the settings screen, kept longer, and
 * exported into support tickets; a token sitting in it would outlive every
 * precaution taken elsewhere in this plugin. For ordinary settings the old and
 * new values are kept, because "the cap changed" without saying from what is
 * barely worth writing down.
 */
final class AuditLog {

	/**
	 * Credentials changed.
	 */
	public const ACTION_SECRET = 'secret';

	/**
	 * Settings saved.
	 */
	public const ACTION_SETTINGS = 'settings';

	/**
	 * A message sent by hand rather than by an event.
	 */
	public const ACTION_MANUAL_SEND = 'manual_send';

	/**
	 * A number added to the opt-out list.
	 */
	public const ACTION_OPT_OUT = 'opt_out';

	/**
	 * Somebody's marketing preference changed on their behalf.
	 */
	public const ACTION_CONSENT = 'consent';

	/**
	 * The receipt endpoint was registered or re-keyed.
	 */
	public const ACTION_WEBHOOK = 'webhook';

	/**
	 * A campaign was sent or stopped.
	 */
	public const ACTION_CAMPAIGN = 'campaign';

	/**
	 * Record an event.
	 *
	 * Deliberately forgiving: a failure to write the audit trail must never break
	 * the thing being audited. Losing one line is bad; refusing to save a
	 * merchant's settings because the trail could not be written is worse.
	 *
	 * @param string               $action  One of the ACTION_* constants.
	 * @param string               $subject What it happened to, in a few words.
	 * @param array<string, mixed> $detail  Extra context. Must contain no secrets.
	 */
	public static function record( string $action, string $subject, array $detail = array() ): void {
		global $wpdb;

		$actor = self::actor();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table.
		$wpdb->insert(
			Schema::audit_table_name(),
			array(
				'actor_id'   => $actor['id'],
				'actor_name' => $actor['name'],
				'action'     => $action,
				'subject'    => $subject,
				'detail'     => empty( $detail ) ? null : (string) wp_json_encode( $detail ),
				'ip'         => $actor['ip'],
				'created_at' => gmdate( 'Y-m-d H:i:s' ),
			),
			array( '%d', '%s', '%s', '%s', '%s', '%s', '%s' )
		);
	}

	/**
	 * Who is doing this, captured as of now.
	 *
	 * @return array{id: int, name: string, ip: string}
	 */
	private static function actor(): array {
		if ( defined( 'WP_CLI' ) && WP_CLI ) {
			return array(
				'id'   => 0,
				'name' => __( 'WP-CLI', 'texttango' ),
				'ip'   => '',
			);
		}

		$user = wp_get_current_user();

		if ( ! $user || 0 === $user->ID ) {
			return array(
				'id'   => 0,
				'name' => __( 'System', 'texttango' ),
				'ip'   => self::ip(),
			);
		}

		return array(
			'id'   => (int) $user->ID,
			'name' => (string) ( '' !== $user->display_name ? $user->display_name : $user->user_login ),
			'ip'   => self::ip(),
		);
	}

	/**
	 * The requesting address, truncated to what is useful.
	 *
	 * Kept because "who" is sometimes only answerable as "from where", and
	 * dropped for anything that is not a request at all.
	 */
	private static function ip(): string {
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Sanitized on the next line.
		$raw = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';

		return substr( $raw, 0, 100 );
	}

	/**
	 * Most recent entries.
	 *
	 * @param int $limit Maximum rows.
	 * @return array<int, array<string, mixed>>
	 */
	public static function recent( int $limit = 30 ): array {
		global $wpdb;

		$table = Schema::audit_table_name();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; the name comes from $wpdb->prefix.
		$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} ORDER BY id DESC LIMIT %d", $limit ), ARRAY_A );

		return is_array( $rows ) ? $rows : array();
	}

	/**
	 * One page of rows, newest first.
	 *
	 * Who changed what used to be capped at a fixed number with no way to reach
	 * the row after it. This is a record somebody consults when they need an
	 * answer about one person or one change, so "the newest N" is not enough.
	 *
	 * @param Paging $paging Which page.
	 * @return array<int, array<string, mixed>>
	 */
	public static function paged( Paging $paging ): array {
		global $wpdb;

		$table = Schema::audit_table_name();

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; the name is built from $wpdb->prefix and cannot be parameterised.
		$sql = "SELECT * FROM {$table} ORDER BY id DESC LIMIT %d OFFSET %d";

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; prepared on the line above.
		$rows = $wpdb->get_results( $wpdb->prepare( $sql, $paging->per_page(), $paging->offset() ), ARRAY_A );

		return is_array( $rows ) ? $rows : array();
	}

	/**
	 * How many entries the trail holds.
	 */
	public static function count(): int {
		global $wpdb;

		$table = Schema::audit_table_name();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; the name comes from $wpdb->prefix.
		return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
	}

	/**
	 * Remove entries older than a cutoff.
	 *
	 * @param string $cutoff UTC datetime.
	 * @param int    $limit  Rows to remove per call.
	 * @return int How many went.
	 */
	public static function purge_before( string $cutoff, int $limit = 500 ): int {
		global $wpdb;

		$table = Schema::audit_table_name();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table.
		$deleted = $wpdb->query(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only the table name is interpolated, from $wpdb->prefix.
				"DELETE FROM {$table} WHERE created_at < %s LIMIT %d",
				$cutoff,
				$limit
			)
		);

		return false === $deleted ? 0 : (int) $deleted;
	}

	/**
	 * An action in words.
	 *
	 * @param string $action One of the ACTION_* constants.
	 */
	public static function label( string $action ): string {
		$labels = array(
			self::ACTION_SECRET      => __( 'Credentials', 'texttango' ),
			self::ACTION_SETTINGS    => __( 'Settings', 'texttango' ),
			self::ACTION_MANUAL_SEND => __( 'Manual send', 'texttango' ),
			self::ACTION_OPT_OUT     => __( 'Opt-out', 'texttango' ),
			self::ACTION_CONSENT     => __( 'Consent', 'texttango' ),
			self::ACTION_WEBHOOK     => __( 'Receipts', 'texttango' ),
			self::ACTION_CAMPAIGN    => __( 'Campaign', 'texttango' ),
		);

		return $labels[ $action ] ?? $action;
	}

	/**
	 * Render a stored detail blob as one readable line.
	 *
	 * @param string $json Stored JSON, or ''.
	 */
	public static function describe( string $json ): string {
		if ( '' === trim( $json ) ) {
			return '';
		}

		$detail = json_decode( $json, true );

		if ( ! is_array( $detail ) ) {
			return '';
		}

		$parts = array();

		foreach ( $detail as $key => $value ) {
			if ( is_array( $value ) ) {
				$value = implode( ', ', array_map( 'strval', $value ) );
			}

			$parts[] = $key . ': ' . (string) $value;
		}

		return implode( ' · ', $parts );
	}
}
