<?php
/**
 * Safe administration view model for findings.
 *
 * @package LocalGuardScanner
 */

namespace LocalGuardScanner\Admin;

/**
 * Validates stored rows before the HTML rendering boundary.
 */
final class FindingPresenter {
	const MAX_EVIDENCE_BYTES = 262144;

	/** @var FindingExplanationCatalog */
	private $catalog;
	/** @var FindingLocationPresenter */
	private $locations;
	/** @var FindingRemediationCatalog */
	private $remediation;

	/** @param FindingExplanationCatalog $catalog Explanation catalog. @param FindingLocationPresenter|null $locations Location presenter. @param FindingRemediationCatalog|null $remediation Remediation catalog. */
	public function __construct( FindingExplanationCatalog $catalog, ?FindingLocationPresenter $locations = null, ?FindingRemediationCatalog $remediation = null ) {
		$this->catalog     = $catalog;
		$this->locations   = null !== $locations ? $locations : new FindingLocationPresenter();
		$this->remediation = null !== $remediation ? $remediation : new FindingRemediationCatalog();
	}

	/**
	 * @param array<string,mixed> $row Stored finding row.
	 * @return array{id:int,scan_id:int,site_id:int,severity:string,score:int,confidence:int,status:string,locator:string,title:string,last_seen_at:string,resolved_at:string,explanations:array<int,array{title:string,detected:string,importance:string,steps:string[],context:string,known:bool}>,priority:bool}
	 */
	public function present( array $row ): array {
		$severity     = isset( $row['severity'] ) && is_string( $row['severity'] ) && in_array( $row['severity'], array( 'critical', 'high', 'medium', 'low', 'info' ), true ) ? $row['severity'] : 'info';
		$status       = isset( $row['status'] ) && is_string( $row['status'] ) && in_array( $row['status'], array( 'new', 'persistent', 'ignored', 'resolved' ), true ) ? $row['status'] : 'new';
		$evidence     = $this->decode_evidence( isset( $row['evidence_json'] ) && is_string( $row['evidence_json'] ) ? $row['evidence_json'] : '' );
		$explanations = $this->catalog->explain_many( $evidence );
		$object_type  = isset( $row['object_type'] ) && is_string( $row['object_type'] ) && in_array( $row['object_type'], array( 'file', 'plugin', 'db_user', 'db_option', 'db_content' ), true ) ? $row['object_type'] : '';
		$locator      = $this->safe_locator( isset( $row['locator'] ) && is_string( $row['locator'] ) ? $row['locator'] : '' );
		$site_id      = isset( $row['site_id'] ) ? max( 0, (int) $row['site_id'] ) : 0;
		$location     = $this->locations->present( $object_type, $locator, $site_id );

		return array(
			'id'           => isset( $row['id'] ) ? max( 0, (int) $row['id'] ) : 0,
			'scan_id'      => isset( $row['scan_id'] ) ? max( 0, (int) $row['scan_id'] ) : 0,
			'site_id'      => $site_id,
			'object_type'  => $object_type,
			'severity'     => $severity,
			'score'        => isset( $row['score'] ) ? max( 0, min( 100, (int) $row['score'] ) ) : 0,
			'confidence'   => isset( $row['confidence'] ) ? max( 0, min( 100, (int) $row['confidence'] ) ) : 0,
			'status'       => $status,
			'locator'      => $locator,
			'location'     => $location,
			'title'        => $explanations[0]['title'],
			'last_seen_at' => $this->safe_datetime( isset( $row['last_seen_at'] ) ? $row['last_seen_at'] : '' ),
			'resolved_at'  => $this->safe_datetime( isset( $row['resolved_at'] ) ? $row['resolved_at'] : '' ),
			'explanations' => $explanations,
			'remediation'  => $this->remediation->guide( $location['kind'] ),
			'priority'     => in_array( $severity, array( 'critical', 'high' ), true ),
		);
	}

	/**
	 * @param array<int,array<string,mixed>> $rows Stored rows.
	 * @return array<int,array{id:int,scan_id:int,site_id:int,severity:string,score:int,confidence:int,status:string,locator:string,title:string,last_seen_at:string,resolved_at:string,explanations:array<int,array{title:string,detected:string,importance:string,steps:string[],context:string,known:bool}>,priority:bool}>
	 */
	public function present_many( array $rows ): array {
		return array_values( array_map( array( $this, 'present' ), $rows ) );
	}

	/**
	 * @param array<int,array<string,mixed>> $presented Presented findings.
	 * @return int|null
	 */
	public function first_priority_index( array $presented ): ?int {
		foreach ( $presented as $index => $finding ) {
			if ( ! empty( $finding['priority'] ) ) {
				return (int) $index;
			}
		}
		return null;
	}

	/** @return mixed[] */
	private function decode_evidence( string $json ): array {
		if ( '' === $json || strlen( $json ) > self::MAX_EVIDENCE_BYTES ) {
			return array();
		}
		$decoded = json_decode( $json, true );
		return is_array( $decoded ) ? $decoded : array();
	}

	/** @return string */
	private function safe_locator( string $locator ): string {
		$locator = str_replace( '\\', '/', trim( $locator ) );
		if ( '' === $locator || strlen( $locator ) > 2048 || preg_match( '/[\x00-\x1F\x7F]/', $locator ) || preg_match( '#^(?:/|[a-z]:/|[a-z][a-z0-9+.-]*://)#i', $locator ) ) {
			return __( 'Location withheld because it is not a safe relative locator.', 'localguard-scanner' );
		}
		foreach ( explode( '/', $locator ) as $segment ) {
			if ( '..' === $segment ) {
				return __( 'Location withheld because it is not a safe relative locator.', 'localguard-scanner' );
			}
		}
		return ltrim( $locator, '/' );
	}

	/** @param mixed $value Stored UTC MySQL datetime. @return string */
	private function safe_datetime( $value ): string {
		return is_string( $value ) && 1 === preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $value ) ? $value : '';
	}
}
