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

namespace LocalGuardScanner\Infrastructure\Database;

use RuntimeException;

/**
 * Upserts bounded inventory metadata.
 */
final class InventoryRepository {
	/** @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 )['inventory'];
	}

	/**
	 * @param array<string,mixed> $item Inventory row.
	 * @return bool
	 */
	public function upsert( array $item ) {
		$prepared = $this->wpdb->prepare(
			'INSERT INTO %i
			(site_id, object_type, locator_hash, locator, size_bytes, modified_at, sha256, metadata_json, baseline_status, first_seen_scan, last_seen_scan)
			VALUES (%d, %s, %s, %s, %d, %d, %s, %s, %s, %d, %d)
			ON DUPLICATE KEY UPDATE size_bytes = VALUES(size_bytes), modified_at = VALUES(modified_at), sha256 = VALUES(sha256), metadata_json = VALUES(metadata_json), baseline_status = VALUES(baseline_status), last_seen_scan = VALUES(last_seen_scan)',
			$this->table,
			$item['site_id'],
			$item['object_type'],
			$item['locator_hash'],
			$item['locator'],
			$item['size_bytes'],
			$item['modified_at'],
			$item['sha256'],
			isset( $item['metadata_json'] ) ? $item['metadata_json'] : null,
			$item['baseline_status'],
			$item['first_seen_scan'],
			$item['last_seen_scan']
		);
		return false !== $this->wpdb->query( $prepared ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
	}

	/**
	 * Read the previous safe baseline for an object.
	 *
	 * @param int    $site_id      Site ID.
	 * @param string $object_type  Object type.
	 * @param string $locator_hash Locator hash.
	 * @return array<string,mixed>|null
	 */
	public function find( $site_id, $object_type, $locator_hash ) {
		$query = $this->wpdb->prepare(
			'SELECT sha256, metadata_json, last_seen_scan FROM %i WHERE site_id = %d AND object_type = %s AND locator_hash = %s LIMIT 1',
			$this->table,
			(int) $site_id,
			$object_type,
			$locator_hash
		);
		$row   = $this->wpdb->get_row( $query, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		$this->throw_on_database_error();
		return is_array( $row ) ? $row : null;
	}

	/**
	 * Delete inventory whose last scan checkpoint no longer exists.
	 *
	 * @param int $limit Maximum rows.
	 * @return int
	 */
	public function delete_orphans( $limit ) {
		$scans = Schema::table_names( $this->wpdb )['scans'];
		$query = $this->wpdb->prepare(
			'SELECT inventory.id FROM %i inventory LEFT JOIN %i scans ON scans.id = inventory.last_seen_scan WHERE scans.id IS NULL ORDER BY inventory.id ASC LIMIT %d',
			$this->table,
			$scans,
			max( 1, (int) $limit )
		);
		$ids   = $this->wpdb->get_col( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		$ids   = array_values( array_filter( array_map( 'intval', $ids ) ) );
		if ( empty( $ids ) ) {
			return 0;
		}
		$deleted = 0;
		foreach ( $ids as $id ) {
			$result = $this->wpdb->delete( $this->table, array( 'id' => $id ), array( '%d' ) );
			if ( false !== $result ) {
				$deleted += (int) $result;
			}
		}
		return $deleted;
	}

	/**
	 * Return WordPress core locators observed during a completed file pass.
	 *
	 * @param int $scan_id Scan ID.
	 * @return string[]
	 */
	public function seen_core_file_locators( $scan_id ) {
		$query    = $this->wpdb->prepare(
			"SELECT locator FROM %i WHERE site_id = 0 AND object_type = 'file' AND last_seen_scan = %d AND (locator LIKE %s OR locator LIKE %s OR locator NOT LIKE %s)",
			$this->table,
			(int) $scan_id,
			$this->wpdb->esc_like( 'wp-admin/' ) . '%',
			$this->wpdb->esc_like( 'wp-includes/' ) . '%',
			'%' . $this->wpdb->esc_like( '/' ) . '%'
		);
		$locators = $this->wpdb->get_col( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		$this->throw_on_database_error();
		return array_values( array_filter( array_map( 'strval', $locators ) ) );
	}

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