<?php
/**
 * @package     MefisAssetBridge
 * @subpackage  MefisAssetBridgeCore
 *
 * @copyright   Copyright (C) 2025 Goran Usljebrka. All rights reserved.
 * @license     Proprietary — No resale or incorporation into other projects without explicit written permission. see LICENSE.txt
 */

namespace MefisAssetBridgeCore;

/**
 * Scrubs sensitive data from log messages before they are passed to the CMS logger.
 *
 * Applied inside {@see Log::log()} so every message at every severity level is
 * filtered automatically, regardless of which CMS is in use.
 *
 * Typical message format produced by ApiClient:
 *
 *   API Payload: {"payload":{…},"signature":"…","uuid":"…"} | URL: http://…
 *   API Response: {"statusCode":200,"success":true,"payload":{…}} | URL: http://…
 *
 * The filter:
 *  1. Locates the first `{` in the message and attempts to parse the JSON.
 *  2. Walks the decoded structure with {@see redactArray()}.
 *  3. Re-encodes and re-assembles the message.
 *  4. Falls back to regex-based redaction if the JSON cannot be parsed.
 */
final class LogFilter
{
    /**
     * Top-level scalar fields whose values are replaced with `[REDACTED]`.
     *
     * @var string[]
     */
    private const SENSITIVE_SCALAR_KEYS = [
        'token',
        'refresh_token',
        'access_token',
        'token_id',
        'email',
        'password',
        'signature',
        'public_key',
        'private_key',
    ];

    /**
     * Top-level keys whose entire value (sub-tree) is replaced with `[REDACTED]`.
     *
     * e.g. the `upload` block containing S3 pre-signed URLs and ETags.
     *
     * @var string[]
     */
    private const SENSITIVE_BLOCK_KEYS = [
        'upload',
    ];

    /**
     * Substrings that mark any string value as S3-related and trigger redaction.
     *
     * @var string[]
     */
    private const S3_URL_SIGNATURES = [
        'amazonaws.com',
        'X-Amz-',
    ];

    /**
     * Filter a log message, redacting all sensitive data.
     *
     * Messages that contain no `{` character are returned unchanged — only
     * messages with an embedded JSON payload are processed.
     *
     * @param  string $message Raw log message.
     * @return string          Filtered log message.
     */
    public static function filter(string $message): string
    {
        $bracePos = strpos($message, '{');
        if ($bracePos === false) {
            return $message;
        }

        // Separate optional " | URL: …" tail from the JSON body.
        $prefix   = substr($message, 0, $bracePos);
        $jsonPart = substr($message, $bracePos);
        $urlTail  = '';

        if (preg_match('/^(.*?)(\s\| URL: \S+)$/s', $jsonPart, $matches)) {
            $jsonPart = $matches[1];
            $urlTail  = $matches[2];
        }

        $decoded = json_decode($jsonPart, true);

        if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
            // JSON parse failed — apply best-effort regex redaction on the raw string.
            return $prefix . static::applyRegexRedaction($jsonPart) . $urlTail;
        }

        $filtered = static::redactArray($decoded);

        return $prefix . json_encode($filtered, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . $urlTail;
    }

    // -----------------------------------------------------------------------
    // Internal helpers
    // -----------------------------------------------------------------------

    /**
     * Recursively walk an array and redact sensitive values.
     *
     * @param  array $data Decoded JSON structure.
     * @return array       Redacted copy.
     */
    private static function redactArray(array $data): array
    {
        foreach ($data as $key => $value) {
            // Block keys — replace the entire sub-tree.
            if (is_string($key) && in_array($key, static::SENSITIVE_BLOCK_KEYS, true)) {
                $data[$key] = '[REDACTED]';
                continue;
            }

            // Scalar sensitive keys.
            if (is_string($key) && in_array($key, static::SENSITIVE_SCALAR_KEYS, true)) {
                $data[$key] = '[REDACTED]';
                continue;
            }

            // Recurse into nested arrays.
            if (is_array($value)) {
                $data[$key] = static::redactArray($value);
                continue;
            }

            // String values that look like S3 pre-signed URLs.
            if (is_string($value)) {
                foreach (static::S3_URL_SIGNATURES as $marker) {
                    if (strpos($value, $marker) !== false) {
                        $data[$key] = '[S3-URL-REDACTED]';
                        break;
                    }
                }
            }
        }

        return $data;
    }

    /**
     * Best-effort regex-based redaction for messages whose JSON cannot be parsed.
     *
     * Targets `"key":"value"` patterns for the scalar sensitive key list only.
     *
     * @param  string $raw Raw JSON-like string.
     * @return string      String with matched values replaced by `"[REDACTED]"`.
     */
    private static function applyRegexRedaction(string $raw): string
    {
        $keys    = array_merge(static::SENSITIVE_SCALAR_KEYS, static::SENSITIVE_BLOCK_KEYS);
        $keyList = implode('|', array_map('preg_quote', $keys, array_fill(0, count($keys), '/')));

        // Replace "key":"any JSON value (string, object, or array start)"
        // — conservatively targets string values only for reliability.
        $pattern = '/("(?:' . $keyList . ')"\s*:\s*)"(?:[^"\\\\]|\\\\.)*"/';

        return preg_replace($pattern, '$1"[REDACTED]"', $raw) ?? $raw;
    }
}
