<?php
/**
 * Finding suppression matching.
 *
 * @package LocalGuardScanner
 */

namespace LocalGuardScanner\Application\Findings;

/**
 * Decides whether a stored suppression covers an observed finding.
 *
 * Three scopes are supported, from narrowest to widest:
 *
 * - `fingerprint`: this exact finding on this exact object.
 * - `locator_hash` (optionally pinned to `object_sha256`): any finding on this
 *   object while its content is unchanged. This is what survives a rule being
 *   added later, because a new detector on the same untouched file produces a
 *   different fingerprint. Pinning the hash makes the suppression expire by
 *   itself the moment the file changes.
 * - `detector_id`: every finding from one detector. Widest and most dangerous,
 *   so it should require a deliberate confirmation in the interface.
 */
final class SuppressionService {
	/**
	 * @param string                         $fingerprint  Finding fingerprint.
	 * @param string[]                       $detector_ids Detector IDs present in the finding.
	 * @param mixed[]                       $suppressions Stored records, active and expired.
	 * @param int                            $now          Unix timestamp.
	 * @param array<string,mixed>            $context      Optional object context: site_id, locator_hash, sha256.
	 * @return bool
	 */
	public function is_suppressed( $fingerprint, array $detector_ids, array $suppressions, $now, array $context = array() ) {
		foreach ( $suppressions as $suppression ) {
			if ( ! is_array( $suppression ) ) {
				continue;
			}
			if ( $this->has_expired( $suppression, (int) $now ) ) {
				continue;
			}
			if ( ! $this->matches_site( $suppression, $context ) ) {
				continue;
			}
			if ( $this->matches_fingerprint( $suppression, (string) $fingerprint ) ) {
				return true;
			}
			if ( $this->matches_object( $suppression, $context ) ) {
				return true;
			}
			if ( isset( $suppression['detector_id'] ) && is_string( $suppression['detector_id'] ) && in_array( $suppression['detector_id'], $detector_ids, true ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Accepts both the Unix timestamp used internally and the `expires_at`
	 * datetime stored by the schema, so the two can never drift again.
	 *
	 * @param array<string,mixed> $suppression Stored record.
	 * @param int                 $now         Unix timestamp.
	 * @return bool
	 */
	private function has_expired( array $suppression, $now ) {
		$expires = 0;
		if ( isset( $suppression['expires'] ) && is_numeric( $suppression['expires'] ) ) {
			$expires = (int) $suppression['expires'];
		} elseif ( isset( $suppression['expires_at'] ) && is_string( $suppression['expires_at'] ) && '' !== $suppression['expires_at'] ) {
			$date = \DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $suppression['expires_at'], new \DateTimeZone( 'UTC' ) );
			if ( false === $date || $date->format( 'Y-m-d H:i:s' ) !== $suppression['expires_at'] ) {
				return true;
			}
			$expires = $date->getTimestamp();
		} elseif ( isset( $suppression['expires_at'] ) && is_numeric( $suppression['expires_at'] ) ) {
			$expires = (int) $suppression['expires_at'];
		}
		return $expires > 0 && $expires <= $now;
	}

	/**
	 * @param array<string,mixed> $suppression Stored record.
	 * @param array<string,mixed> $context     Object context.
	 * @return bool
	 */
	private function matches_site( array $suppression, array $context ) {
		if ( ! array_key_exists( 'site_id', $context ) || ! array_key_exists( 'site_id', $suppression ) ) {
			return true;
		}
		return (int) $suppression['site_id'] === (int) $context['site_id'];
	}

	/**
	 * @param array<string,mixed> $suppression Stored record.
	 * @param string              $fingerprint Finding fingerprint.
	 * @return bool
	 */
	private function matches_fingerprint( array $suppression, $fingerprint ) {
		if ( ! isset( $suppression['fingerprint'] ) || ! is_string( $suppression['fingerprint'] ) || '' === $suppression['fingerprint'] ) {
			return false;
		}
		return hash_equals( $suppression['fingerprint'], $fingerprint );
	}

	/**
	 * @param array<string,mixed> $suppression Stored record.
	 * @param array<string,mixed> $context     Object context.
	 * @return bool
	 */
	private function matches_object( array $suppression, array $context ) {
		if ( ! isset( $suppression['locator_hash'] ) || ! is_string( $suppression['locator_hash'] ) || '' === $suppression['locator_hash'] ) {
			return false;
		}
		if ( ! isset( $context['locator_hash'] ) || ! is_string( $context['locator_hash'] ) || '' === $context['locator_hash'] ) {
			return false;
		}
		if ( ! hash_equals( $suppression['locator_hash'], $context['locator_hash'] ) ) {
			return false;
		}
		if ( ! isset( $suppression['object_sha256'] ) || ! is_string( $suppression['object_sha256'] ) || '' === $suppression['object_sha256'] ) {
			return true;
		}
		if ( ! isset( $context['sha256'] ) || ! is_string( $context['sha256'] ) || '' === $context['sha256'] ) {
			return false;
		}
		return hash_equals( $suppression['object_sha256'], $context['sha256'] );
	}
}
