<?php
/**
 * Suppression persistence.
 *
 * @package LocalGuardScanner
 */

namespace LocalGuardScanner\Infrastructure\Database;

use InvalidArgumentException;
use RuntimeException;

/**
 * Records and reads auditable finding suppressions.
 *
 * Writing was the only supported operation until now, which meant nothing could
 * read a suppression back, revoke one, or show the administrator what is
 * currently being hidden. A suppression nobody can review is worse than none.
 */
final class SuppressionRepository {
	/** @var \wpdb */
	private $wpdb;
	/** @var string */
	private $table;

	/** @param \wpdb $wpdb WordPress database object. */
	public function __construct( $wpdb ) {
		$this->wpdb  = $wpdb;
		$this->table = Schema::table_names( $wpdb )['suppressions'];
	}

	/**
	 * @param array<string,mixed> $row Suppression row.
	 * @return int
	 */
	public function add( array $row ) {
		$clean = $this->sanitize( $row );
		if ( false === $this->wpdb->insert( $this->table, $clean, $this->formats( $clean ) ) ) {
			throw new RuntimeException( 'Suppression could not be stored.' );
		}
		return (int) $this->wpdb->insert_id;
	}

	/**
	 * Active suppressions for one site, oldest first.
	 *
	 * @param int         $site_id Site ID.
	 * @param string|null $now     UTC MySQL timestamp; defaults to the database clock.
	 * @return array<int,array<string,mixed>>
	 */
	public function active_for_site( $site_id, $now = null ) {
		$now   = is_string( $now ) && '' !== $now ? $now : gmdate( 'Y-m-d H:i:s' );
		$query = $this->wpdb->prepare(
			'SELECT id, site_id, fingerprint, detector_id, locator_hash, object_sha256, reason, author_id, created_at, expires_at
			FROM %i WHERE site_id = %d AND ( expires_at IS NULL OR expires_at > %s ) ORDER BY id ASC',
			$this->table,
			(int) $site_id,
			$now
		);
		$rows  = $this->wpdb->get_results( $query, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		$this->throw_on_database_error();
		return is_array( $rows ) ? $rows : array();
	}

	/**
	 * Every suppression for one site, including expired ones, for review.
	 *
	 * @param int $site_id Site ID.
	 * @param int $limit   Maximum rows.
	 * @return array<int,array<string,mixed>>
	 */
	public function all_for_site( $site_id, $limit = 200 ) {
		$query = $this->wpdb->prepare(
			'SELECT id, site_id, fingerprint, detector_id, locator_hash, object_sha256, reason, author_id, created_at, expires_at
			FROM %i WHERE site_id = %d ORDER BY id DESC LIMIT %d',
			$this->table,
			(int) $site_id,
			max( 1, min( 500, (int) $limit ) )
		);
		$rows  = $this->wpdb->get_results( $query, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		$this->throw_on_database_error();
		return is_array( $rows ) ? $rows : array();
	}

	/**
	 * Remove a suppression so its findings are evaluated again.
	 *
	 * @param int $id      Suppression ID.
	 * @param int $site_id Site ID the caller is authorized for.
	 * @return bool
	 */
	public function revoke( $id, $site_id ) {
		$deleted = $this->wpdb->delete(
			$this->table,
			array(
				'id'      => (int) $id,
				'site_id' => (int) $site_id,
			),
			array( '%d', '%d' )
		);
		if ( false === $deleted ) {
			throw new RuntimeException( 'Suppression could not be revoked.' );
		}
		return $deleted > 0;
	}

	/**
	 * @param array<string,mixed> $row Untrusted row.
	 * @return array<string,mixed>
	 */
	private function sanitize( array $row ) {
		$reason = isset( $row['reason'] ) ? trim( (string) $row['reason'] ) : '';
		if ( '' === $reason ) {
			throw new InvalidArgumentException( 'A suppression requires a reason.' );
		}
		$clean  = array(
			'site_id'    => isset( $row['site_id'] ) ? (int) $row['site_id'] : 0,
			'reason'     => $this->truncate( $reason, 500 ),
			'author_id'  => isset( $row['author_id'] ) ? (int) $row['author_id'] : 0,
			'created_at' => isset( $row['created_at'] ) && is_string( $row['created_at'] ) ? $row['created_at'] : gmdate( 'Y-m-d H:i:s' ),
		);
		$scoped = false;
		foreach ( array( 'fingerprint', 'locator_hash', 'object_sha256' ) as $field ) {
			if ( isset( $row[ $field ] ) && is_string( $row[ $field ] ) && preg_match( '/^[a-f0-9]{64}$/', $row[ $field ] ) ) {
				$clean[ $field ] = $row[ $field ];
				$scoped          = $scoped || 'object_sha256' !== $field;
			}
		}
		if ( isset( $row['detector_id'] ) && is_string( $row['detector_id'] ) && '' !== $row['detector_id'] ) {
			$clean['detector_id'] = $this->truncate( $row['detector_id'], 96 );
			$scoped               = true;
		}
		if ( ! $scoped ) {
			throw new InvalidArgumentException( 'A suppression requires a fingerprint, an object, or a detector.' );
		}
		if ( isset( $row['expires_at'] ) && is_string( $row['expires_at'] ) && '' !== $row['expires_at'] ) {
			if ( ! $this->is_mysql_datetime( $row['expires_at'] ) ) {
				throw new InvalidArgumentException( 'Suppression expiry must be a valid UTC MySQL datetime.' );
			}
			$clean['expires_at'] = $row['expires_at'];
		}
		return $clean;
	}

	/**
	 * @param string $value Candidate UTC MySQL datetime.
	 * @return bool
	 */
	private function is_mysql_datetime( $value ) {
		$date = \DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $value, new \DateTimeZone( 'UTC' ) );
		return false !== $date && $date->format( 'Y-m-d H:i:s' ) === $value;
	}

	/**
	 * @param string $value Text to limit.
	 * @param int    $limit Maximum characters or bytes without mbstring.
	 * @return string
	 */
	private function truncate( $value, $limit ) {
		return function_exists( 'mb_substr' ) ? mb_substr( $value, 0, $limit ) : substr( $value, 0, $limit );
	}

	/**
	 * @param array<string,mixed> $clean Sanitized row.
	 * @return string[]
	 */
	private function formats( array $clean ) {
		$formats = array();
		foreach ( array_keys( $clean ) as $column ) {
			$formats[] = in_array( $column, array( 'site_id', 'author_id' ), true ) ? '%d' : '%s';
		}
		return $formats;
	}

	/** @return void */
	private function throw_on_database_error() {
		if ( '' !== $this->wpdb->last_error ) {
			throw new RuntimeException( 'Suppressions could not be read safely.' );
		}
	}
}
