<?php
/**
 * Safe operational scan progress.
 *
 * @package LocalGuardScanner
 */

namespace LocalGuardScanner\Application\Scan;

/**
 * Normalizes scanner-owned progress before persistence or presentation.
 */
final class ScanProgress {
	const VERSION = 1;

	/** @var array<string,mixed> */
	private $data;

	/**
	 * @param array<string,mixed> $data Progress data.
	 */
	public function __construct( array $data = array() ) {
		$this->data = $this->normalize( $data );
	}

	/**
	 * @param mixed $json Stored JSON.
	 * @return self
	 */
	public static function from_json( $json ) {
		if ( ! is_string( $json ) || '' === $json || strlen( $json ) > 1048576 ) {
			return new self();
		}
		$decoded = json_decode( $json, true );
		return new self( is_array( $decoded ) ? $decoded : array() );
	}

	/** @return array<string,mixed> */
	public function to_array() {
		return $this->data;
	}

	/**
	 * Reconcile a volatile discovery snapshot with confirmed processing.
	 *
	 * @param bool $terminal Whether the scan has reached a terminal result.
	 * @return self
	 */
	public function reconcile_totals( $terminal = false ) {
		$data = $this->data;
		foreach ( array( 'file_objects', 'database_objects' ) as $type ) {
			$data['totals'][ $type ] = $terminal ? $data['processed'][ $type ] : max( $data['totals'][ $type ], $data['processed'][ $type ] );
		}
		return new self( $data );
	}

	/**
	 * Public progress with an exact item percentage outside discovery.
	 *
	 * @param string $phase Scan phase.
	 * @return array<string,mixed>
	 */
	public function public_data( $phase ) {
		$data               = $this->data;
		$data['percentage'] = $this->percentage( (string) $phase );
		return $data;
	}

	/**
	 * @param string $phase Scan phase.
	 * @return int|null
	 */
	private function percentage( $phase ) {
		if ( 'discovering' === $phase || 'queued' === $phase ) {
			return null;
		}
		if ( in_array( $phase, array( 'completed', 'partial' ), true ) ) {
			return 100;
		}
		$total     = $this->data['totals']['file_objects'] + $this->data['totals']['database_objects'];
		$processed = $this->data['processed']['file_objects'] + $this->data['processed']['database_objects'];
		if ( $total <= 0 ) {
			return in_array( $phase, array( 'classifying', 'completed', 'partial' ), true ) ? 100 : 0;
		}
		return max( 0, min( 100, (int) floor( min( $processed, $total ) * 100 / $total ) ) );
	}

	/**
	 * @param array<string,mixed> $data Raw progress.
	 * @return array<string,mixed>
	 */
	private function normalize( array $data ) {
		$totals         = isset( $data['totals'] ) && is_array( $data['totals'] ) ? $data['totals'] : array();
		$processed      = isset( $data['processed'] ) && is_array( $data['processed'] ) ? $data['processed'] : array();
		$discovered     = isset( $data['discovered'] ) && is_array( $data['discovered'] ) ? $data['discovered'] : array();
		$findings       = isset( $data['findings'] ) && is_array( $data['findings'] ) ? $data['findings'] : array();
		$reconciliation = isset( $data['reconciliation'] ) && is_array( $data['reconciliation'] ) ? $data['reconciliation'] : array();
		return array(
			'version'        => self::VERSION,
			'totals'         => array(
				'file_objects'     => $this->count( isset( $totals['file_objects'] ) ? $totals['file_objects'] : 0 ),
				'database_objects' => $this->count( isset( $totals['database_objects'] ) ? $totals['database_objects'] : 0 ),
			),
			'processed'      => array(
				'file_objects'     => $this->count( isset( $processed['file_objects'] ) ? $processed['file_objects'] : 0 ),
				'database_objects' => $this->count( isset( $processed['database_objects'] ) ? $processed['database_objects'] : 0 ),
			),
			'discovered'     => array(
				'file_objects'     => $this->count( isset( $discovered['file_objects'] ) ? $discovered['file_objects'] : 0 ),
				'database_objects' => $this->count( isset( $discovered['database_objects'] ) ? $discovered['database_objects'] : 0 ),
			),
			'findings'       => array(
				'review_required' => $this->count( isset( $findings['review_required'] ) ? $findings['review_required'] : 0 ),
				'informational'   => $this->count( isset( $findings['informational'] ) ? $findings['informational'] : 0 ),
			),
			'reconciliation' => array(
				'checked'  => $this->count( isset( $reconciliation['checked'] ) ? $reconciliation['checked'] : 0 ),
				'resolved' => $this->count( isset( $reconciliation['resolved'] ) ? $reconciliation['resolved'] : 0 ),
				'done'     => ! empty( $reconciliation['done'] ),
			),
			'omissions'      => $this->count( isset( $data['omissions'] ) ? $data['omissions'] : 0 ),
			'batch_number'   => $this->count( isset( $data['batch_number'] ) ? $data['batch_number'] : 0 ),
			'current_area'   => $this->area( isset( $data['current_area'] ) ? $data['current_area'] : '' ),
		);
	}

	/** @param mixed $value Count. @return int */
	private function count( $value ): int {
		return max( 0, min( PHP_INT_MAX, (int) $value ) );
	}

	/** @param mixed $value Relative directory. @return string */
	private function area( $value ): string {
		if ( ! is_string( $value ) ) {
			return '';
		}
		$value = str_replace( '\\', '/', $value );
		if ( 0 === strpos( $value, '/' ) ) {
			return '';
		}
		$value = trim( $value, '/' );
		if ( strlen( $value ) > 512 || '' === $value || false !== strpos( $value, '../' ) || '..' === $value ) {
			return '';
		}
		return (string) preg_replace( '/[^A-Za-z0-9._\/-]/', '', $value );
	}
}
