<?php
/**
 * Finding classification and persistence.
 *
 * @package LocalGuardScanner
 */

namespace LocalGuardScanner\Application\Findings;

use LocalGuardScanner\Domain\Detection\Evidence;
use LocalGuardScanner\Domain\Detection\FindingFingerprint;
use LocalGuardScanner\Domain\Detection\RiskClassifier;
use LocalGuardScanner\Infrastructure\Database\FindingRepository;
use LocalGuardScanner\Infrastructure\Database\SuppressionRepository;
use RuntimeException;

/**
 * Converts detector evidence into a deduplicated persisted finding.
 */
final class FindingPersister {
	/** @var FindingRepository */
	private $repository;
	/** @var RiskClassifier */
	private $classifier;
	/** @var FindingFingerprint */
	private $fingerprint;
	/** @var FindingLifecycle */
	private $lifecycle;
	/** @var SuppressionService|null */
	private $suppressions;
	/** @var SuppressionRepository|null */
	private $suppression_repository;
	/** @var array<int,array<int,array<string,mixed>>> */
	private $suppression_cache = array();

	/**
	 * @param FindingRepository          $repository             Repository.
	 * @param RiskClassifier             $classifier             Classifier.
	 * @param FindingFingerprint         $fingerprint            Fingerprint generator.
	 * @param FindingLifecycle           $lifecycle              Lifecycle.
	 * @param SuppressionService|null    $suppressions           Suppression matcher.
	 * @param SuppressionRepository|null $suppression_repository Suppression store.
	 */
	public function __construct( FindingRepository $repository, RiskClassifier $classifier, FindingFingerprint $fingerprint, FindingLifecycle $lifecycle, ?SuppressionService $suppressions = null, ?SuppressionRepository $suppression_repository = null ) {
		$this->repository             = $repository;
		$this->classifier             = $classifier;
		$this->fingerprint            = $fingerprint;
		$this->lifecycle              = $lifecycle;
		$this->suppressions           = $suppressions;
		$this->suppression_repository = $suppression_repository;
	}

	/**
	 * @param int        $scan_id     Scan ID.
	 * @param int        $site_id     Site ID.
	 * @param string     $object_type Object type.
	 * @param string     $locator     Locator.
	 * @param Evidence[]  $evidence    Evidence.
	 * @param string      $now         UTC MySQL time.
	 * @param string|null $sha256      Observed object hash, when the object has one.
	 * @return bool
	 */
	public function persist( $scan_id, $site_id, $object_type, $locator, array $evidence, $now, $sha256 = null ) {
		$finding = $this->classifier->classify( $object_type, $locator, $evidence );
		if ( null === $finding ) {
			return false;
		}
		$fingerprint         = $this->fingerprint->for_finding( $finding );
		$previous            = $this->repository->status_for( $site_id, $fingerprint );
		$status              = $this->lifecycle->next_status( $previous, true, $this->is_suppressed( $finding, $fingerprint, (int) $site_id, $locator, $sha256 ) );
		$serialized_evidence = array();
		foreach ( $finding->evidence() as $item ) {
			$serialized_evidence[] = $item->to_array();
		}
		$encoded_evidence = json_encode( $serialized_evidence, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
		if ( false === $encoded_evidence ) {
			throw new RuntimeException( 'Finding evidence could not be encoded safely.' );
		}
		return $this->repository->save(
			array(
				'scan_id'       => (int) $scan_id,
				'site_id'       => (int) $site_id,
				'fingerprint'   => $fingerprint,
				'detector_id'   => $finding->evidence()[0]->detector_id(),
				'severity'      => $finding->severity(),
				'score'         => $finding->score(),
				'confidence'    => $finding->confidence(),
				'status'        => $status,
				'object_type'   => $object_type,
				'locator'       => $locator,
				'evidence_json' => $encoded_evidence,
				'first_seen_at' => $now,
				'last_seen_at'  => $now,
			)
		);
	}

	/**
	 * Whether an active suppression covers this observation.
	 *
	 * Suppressions are read once per site and reused for the rest of the scan,
	 * so enabling this costs one query per site rather than one per finding.
	 *
	 * @param \LocalGuardScanner\Domain\Detection\Finding $finding     Classified finding.
	 * @param string                                      $fingerprint Finding fingerprint.
	 * @param int                                         $site_id     Site ID.
	 * @param string                                      $locator     Stable locator.
	 * @param string|null                                 $sha256      Observed object hash.
	 * @return bool
	 */
	private function is_suppressed( $finding, $fingerprint, $site_id, $locator, $sha256 ) {
		if ( null === $this->suppressions || null === $this->suppression_repository ) {
			return false;
		}
		if ( ! isset( $this->suppression_cache[ $site_id ] ) ) {
			try {
				$this->suppression_cache[ $site_id ] = $this->suppression_repository->active_for_site( $site_id );
			} catch ( \Throwable $error ) {
				// A suppression that cannot be read must never hide a finding, and
				// must never abort a scan either. Fail towards showing the finding.
				$this->suppression_cache[ $site_id ] = array();
			}
		}
		if ( empty( $this->suppression_cache[ $site_id ] ) ) {
			return false;
		}
		$detector_ids = array();
		foreach ( $finding->evidence() as $item ) {
			$detector_ids[] = $item->detector_id();
		}
		return $this->suppressions->is_suppressed(
			$fingerprint,
			$detector_ids,
			$this->suppression_cache[ $site_id ],
			time(),
			array(
				'site_id'      => $site_id,
				'locator_hash' => hash( 'sha256', str_replace( '\\', '/', (string) $locator ) ),
				'sha256'       => is_string( $sha256 ) ? $sha256 : null,
			)
		);
	}
}
