<?php
/**
 * Log record masker for the §12.2 / §12.3 REST surfaces.
 *
 * Phase 11 slice 11d stateless masker that projects a
 * {@see \Affilun\Infrastructure\Persistence\LogRecord}
 * into the per-row associative array shape consumed by the
 * `GET /affilun/v1/logs` (§12.2) and `GET /affilun/v1/logs/summary`
 * (§12.3) success envelopes documented in
 * `specifications/rest-api-spec.md`.
 *
 * §12.2 注意 ("秘密情報マスク済みのみ返す") binds every row
 * surfaced through the logs REST endpoints: credential-shaped
 * tokens that may have leaked into log message bodies or
 * structured context values MUST be replaced with `***` before
 * the row crosses the response boundary. The masker applies the
 * following regular-expression passes in order to every string
 * value it touches; each match is rewritten to the substitution
 * shown:
 *
 *   1. Bearer / Authorization tokens —
 *      `/(authorization\s*[:=]?\s*bearer\s+)[A-Za-z0-9._\-]{4,}/i`
 *      → `${1}***`. Catches both header-style
 *      ("Authorization: Bearer xyz") and inline log-prose
 *      ("authorization=bearer xyz") shapes.
 *
 *   2. Generic API-key tokens —
 *      `/(api[_-]?key\s*[:=]?\s*['"]?)[A-Za-z0-9._\-]{16,}(['"]?)/i`
 *      → `${1}***${2}`. The 16-character lower bound avoids
 *      masking obvious labels ("api_key=demo"); production keys
 *      are far longer.
 *
 *   3. AWS access-key ids — `/AKIA[0-9A-Z]{16}/` → `***`. Matches
 *      the canonical 20-character AWS access-key prefix grammar.
 *
 *   4. Long hex secrets — `/\b[A-Fa-f0-9]{32,}\b/` → `***`.
 *      Catches MD5+/SHA-/HMAC-shaped tokens regardless of
 *      surrounding label.
 *
 *   5. JWT-like tokens —
 *      `/eyJ[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+/`
 *      → `***`. The `eyJ` prefix is the base64-url encoding of
 *      `{"` and reliably leads JWT headers.
 *
 *   6. Email addresses (privacy) —
 *      `/[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/` →
 *      `***@***`.
 *
 * Passes are independent and applied in declaration order; a
 * value that matches more than one pass receives every applicable
 * substitution.
 *
 * Context handling:
 *
 *   - The §12.2 row shape exposes four context-derived fields —
 *     `event_type`, `request_id`, `correlation_id`, and `job_id` —
 *     extracted from the {@see LogRecord::contextJson()} blob.
 *     These four are identifiers, not secrets, so they are
 *     returned verbatim without masking.
 *
 *   - Every OTHER string value reachable inside the decoded
 *     context (recursively) is run through the masking pipeline.
 *     This covers nested arrays / objects so a credential placed
 *     under `context.headers.authorization` cannot bypass the
 *     scrubber by sitting below the top level.
 *
 *   - When `contextJson` is null OR fails to decode as a JSON
 *     object, all four context-derived fields are returned as
 *     null and no exception is raised.
 *
 *   - Fields the §12.2 spec does NOT enumerate (everything other
 *     than the four named identifiers above) are NOT surfaced on
 *     the row at all — the masker reads the closed set and
 *     ignores the rest.
 *
 * @package Affilun\Application\Logs
 */

declare(strict_types=1);

namespace Affilun\Application\Logs;

use Affilun\Infrastructure\Persistence\LogRecord;
use DateTimeZone;

/**
 * Stateless §12.2 / §12.3 row masker.
 *
 * See the file-level docblock for the six masking passes, the
 * recursive context scrubbing, and the four context-derived
 * identifier fields the §12.2 row shape exposes verbatim.
 */
final class LogMasker {

	/**
	 * Bearer / Authorization-token mask pattern.
	 */
	private const PATTERN_BEARER = '/(authorization\s*[:=]?\s*bearer\s+)[A-Za-z0-9._\-]{4,}/i';

	/**
	 * Bearer mask substitution.
	 */
	private const SUBSTITUTION_BEARER = '${1}***';

	/**
	 * Generic API-key mask pattern.
	 */
	private const PATTERN_API_KEY = '/(api[_-]?key\s*[:=]?\s*[\'"]?)[A-Za-z0-9._\-]{16,}([\'"]?)/i';

	/**
	 * API-key mask substitution.
	 */
	private const SUBSTITUTION_API_KEY = '${1}***${2}';

	/**
	 * AWS access-key id mask pattern.
	 */
	private const PATTERN_AWS = '/AKIA[0-9A-Z]{16}/';

	/**
	 * Long hex-secret mask pattern.
	 */
	private const PATTERN_LONG_HEX = '/\b[A-Fa-f0-9]{32,}\b/';

	/**
	 * JWT-like token mask pattern.
	 */
	private const PATTERN_JWT = '/eyJ[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+/';

	/**
	 * Amazon Login-with-Amazon (LwA) access-token mask pattern.
	 *
	 * V3.x Creators API credentials authenticate against the LwA
	 * token endpoint (`api.amazon.com|.co.uk|.co.jp/auth/o2/token`)
	 * and receive bearer tokens of the shape `Atc|MQI...` (or
	 * `Atc|EwI...`) per Amazon's documented samples. The pipe
	 * character makes them stand out from the other token shapes,
	 * so a dedicated pattern keeps the masking efficient.
	 *
	 * KI-019 Phase B (ADR-0022 §8.3): Bearer tokens MUST NOT
	 * appear in log lines / audit rows / context_json payloads.
	 */
	private const PATTERN_LWA_TOKEN = '/Atc\|[A-Za-z0-9_\-]{16,}/';

	/**
	 * Email-address mask pattern.
	 */
	private const PATTERN_EMAIL = '/[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/';

	/**
	 * Email mask substitution.
	 */
	private const SUBSTITUTION_EMAIL = '***@***';

	/**
	 * Generic three-asterisk substitution.
	 */
	private const SUBSTITUTION_STARS = '***';

	/**
	 * Closed set of context keys surfaced on the §12.2 row shape.
	 *
	 * @var list<string>
	 */
	private const CONTEXT_FIELDS = array(
		'event_type',
		'request_id',
		'correlation_id',
		'job_id',
	);

	/**
	 * Project a {@see LogRecord} into the §12.2 row shape with
	 * masking applied.
	 *
	 * Returned shape:
	 *
	 *   `array(
	 *       'id'                 => int|null,
	 *       'event_type'         => ?string,
	 *       'level'              => string,
	 *       'channel'            => string,
	 *       'message'            => string,
	 *       'request_id'         => ?string,
	 *       'correlation_id'     => ?string,
	 *       'job_id'             => ?string,
	 *       'related_product_id' => ?int,
	 *       'related_job_id'     => ?int,
	 *       'created_at'         => string,
	 *   )`
	 *
	 * `created_at` is rendered as an ISO-8601 UTC string
	 * (`Y-m-d\TH:i:s\Z`) so callers do not need to re-format the
	 * MySQL `DATETIME` representation.
	 *
	 * @param LogRecord $record Record to project.
	 *
	 * @return array<string, mixed>
	 */
	public function maskRecord( LogRecord $record ): array {
		$contextFields = $this->extractContextFields( $record->contextJson() );

		return array(
			'id'                 => $record->id(),
			'event_type'         => $contextFields['event_type'],
			'level'              => $record->level(),
			'channel'            => $record->channel(),
			'message'            => $this->maskString( $record->message() ),
			'request_id'         => $contextFields['request_id'],
			'correlation_id'     => $contextFields['correlation_id'],
			'job_id'             => $contextFields['job_id'],
			'related_product_id' => $record->relatedProductId(),
			'related_job_id'     => $record->relatedJobId(),
			'created_at'         => $this->formatCreatedAt( $record ),
		);
	}

	/**
	 * Apply every masking pass to the supplied string value.
	 *
	 * @param string $value Value to mask.
	 */
	private function maskString( string $value ): string {
		$masked = $value;

		$bearer = preg_replace( self::PATTERN_BEARER, self::SUBSTITUTION_BEARER, $masked );
		if ( is_string( $bearer ) ) {
			$masked = $bearer;
		}

		$apiKey = preg_replace( self::PATTERN_API_KEY, self::SUBSTITUTION_API_KEY, $masked );
		if ( is_string( $apiKey ) ) {
			$masked = $apiKey;
		}

		$aws = preg_replace( self::PATTERN_AWS, self::SUBSTITUTION_STARS, $masked );
		if ( is_string( $aws ) ) {
			$masked = $aws;
		}

		$hex = preg_replace( self::PATTERN_LONG_HEX, self::SUBSTITUTION_STARS, $masked );
		if ( is_string( $hex ) ) {
			$masked = $hex;
		}

		$jwt = preg_replace( self::PATTERN_JWT, self::SUBSTITUTION_STARS, $masked );
		if ( is_string( $jwt ) ) {
			$masked = $jwt;
		}

		$lwa = preg_replace( self::PATTERN_LWA_TOKEN, self::SUBSTITUTION_STARS, $masked );
		if ( is_string( $lwa ) ) {
			$masked = $lwa;
		}

		$email = preg_replace( self::PATTERN_EMAIL, self::SUBSTITUTION_EMAIL, $masked );
		if ( is_string( $email ) ) {
			$masked = $email;
		}

		return $masked;
	}

	/**
	 * Decode `$contextJson` and extract the four §12.2 identifier
	 * fields. Returns null for every field when the JSON is absent
	 * or fails to decode as an object.
	 *
	 * Every OTHER string value inside the decoded context is run
	 * through {@see self::maskString()} recursively for parity with
	 * `message`, even though the masked context is not currently
	 * surfaced on the §12.2 row — recursive scrubbing keeps the
	 * masker safe for future row-shape additions and is cheap.
	 *
	 * @param string|null $contextJson Raw JSON blob, or null.
	 *
	 * @return array{
	 *     event_type: ?string,
	 *     request_id: ?string,
	 *     correlation_id: ?string,
	 *     job_id: ?string,
	 * }
	 */
	private function extractContextFields( ?string $contextJson ): array {
		$empty = array(
			'event_type'     => null,
			'request_id'     => null,
			'correlation_id' => null,
			'job_id'         => null,
		);

		if ( $contextJson === null || $contextJson === '' ) {
			return $empty;
		}

		$decoded = json_decode( $contextJson, true );
		if ( ! is_array( $decoded ) ) {
			return $empty;
		}

		// Apply masking recursively to every value (kept for parity
		// even though the masked context is not surfaced on the row).
		$this->maskRecursive( $decoded );

		$out = $empty;
		foreach ( self::CONTEXT_FIELDS as $key ) {
			if ( array_key_exists( $key, $decoded ) ) {
				$value = $decoded[ $key ];
				if ( is_string( $value ) && $value !== '' ) {
					$out[ $key ] = $value;
				}
			}
		}

		return $out;
	}

	/**
	 * Recursively apply {@see self::maskString()} to every string
	 * value reachable in `$values`. Mutates `$values` in place by
	 * reference.
	 *
	 * @param array<int|string, mixed> $values Values to scrub.
	 */
	private function maskRecursive( array &$values ): void {
		foreach ( $values as $key => &$value ) {
			if ( is_string( $value ) ) {
				// Identifier fields are returned verbatim and not
				// re-read after masking; masking them in the working
				// copy is harmless. Skipping them keeps the recursive
				// scrub cheap when contexts are id-heavy.
				if ( is_string( $key ) && in_array( $key, self::CONTEXT_FIELDS, true ) ) {
					continue;
				}
				$value = $this->maskString( $value );
				continue;
			}
			if ( is_array( $value ) ) {
				$this->maskRecursive( $value );
			}
		}
		unset( $value );
	}

	/**
	 * Render the record's `created_at` as an ISO-8601 UTC string.
	 *
	 * @param LogRecord $record Record to read.
	 */
	private function formatCreatedAt( LogRecord $record ): string {
		return $record->createdAt()
			->setTimezone( new DateTimeZone( 'UTC' ) )
			->format( 'Y-m-d\TH:i:s\Z' );
	}
}
