<?php

namespace App\Observers;

use App\Models\Log;
use Illuminate\Support\Facades\DB;
use RuntimeException;

/**
 * PRC-194 — Append-only forward hash chain for the logs table.
 *
 * On `creating`:
 *   • Reads the row_hash of the last existing row (latest id wins under
 *     `lockForUpdate` to avoid two concurrent writers chaining off the
 *     same prev_hash → forked chain).
 *   • Computes row_hash = sha256(prev_hash || canonical payload of this row).
 *   • Writes prev_hash + row_hash on the model attributes before INSERT.
 *
 * On `updating` / `deleting`: throws. The logs table is append-only.
 * Bypassing the observer (e.g. raw DB::update/delete, mass operations
 * `whereXxx->delete()`) is still possible — this is an app-layer
 * discipline boundary, not a DBA-level grant. CLAUDE.md explicitly
 * accepts that scope.
 *
 * Canonical payload (NOT the model's full toArray):
 *   id is excluded because it's only known post-INSERT; updated_at is
 *   excluded because Laravel may overwrite it on save without changing
 *   semantic state. created_at is set explicitly before chain
 *   computation so the same row always produces the same hash.
 */
class LogObserver
{
    /**
     * @internal Test/seed support. When `true`, the observer skips the
     * append-only enforcement on `updating`/`deleting`. Production code
     * MUST NEVER toggle this — only used by the dedicated artisan
     * commands (e.g. logs:seal-existing) that legitimately rewrite the
     * pre-chain backfill window.
     */
    public static bool $bypassImmutability = false;

    public function creating(Log $log): void
    {
        // Make sure created_at is fixed before hashing — Eloquent sets it
        // right before INSERT inside the model's performInsert(), but we
        // need a deterministic value for the chain.
        if ($log->created_at === null) {
            $log->created_at = now();
            $log->updated_at = $log->updated_at ?? $log->created_at;
        }

        DB::transaction(function () use ($log) {
            // Find latest row's hash under SELECT … FOR UPDATE to prevent
            // two concurrent INSERTs from chaining off the same prev_hash.
            $previous = Log::query()
                ->orderByDesc('id')
                ->limit(1)
                ->lockForUpdate()
                ->first(['id', 'row_hash']);

            $prevHash = $previous?->row_hash; // NULL for genesis row OR pre-chain rows

            $log->prev_hash = $prevHash;
            $log->row_hash = self::computeRowHash($log, $prevHash);
        });
    }

    public function updating(Log $log): void
    {
        if (self::$bypassImmutability) {
            return;
        }

        throw new RuntimeException(
            'Log table is append-only (PRC-194). Updates are not permitted — log_id='.$log->id
        );
    }

    public function deleting(Log $log): void
    {
        if (self::$bypassImmutability) {
            return;
        }

        throw new RuntimeException(
            'Log table is append-only (PRC-194). Deletes are not permitted — log_id='.$log->id
        );
    }

    /**
     * Canonical row payload → SHA-256 hex.
     *
     * Field order is fixed (NOT alphabetical from $attributes) so the
     * hash is stable across PHP versions and Eloquent attribute order
     * mutations. Any field added in future MUST be appended at the end
     * to keep historical chains verifiable.
     */
    public static function computeRowHash(Log $log, ?string $prevHash): string
    {
        $payload = [
            'prev_hash' => $prevHash ?? '',
            'observer' => (string) ($log->observer ?? ''),
            'module' => (string) ($log->module ?? ''),
            'type' => (string) ($log->type ?? ''),
            'client_id' => $log->client_id !== null ? (int) $log->client_id : null,
            'admin_id' => $log->admin_id !== null ? (int) $log->admin_id : null,
            'contact_id' => $log->contact_id !== null ? (int) $log->contact_id : null,
            'ip_address' => (string) ($log->ip_address ?? ''),
            'method' => (string) ($log->method ?? ''),
            'description' => (string) ($log->description ?? ''),
            // request/response are cast to array → encode deterministically
            'request' => self::canonicalJson($log->getAttribute('request')),
            'response' => self::canonicalJson($log->getAttribute('response')),
            'elapsed_ms' => $log->elapsed_ms !== null ? (int) $log->elapsed_ms : null,
            'user_agent' => (string) ($log->user_agent ?? ''),
            'session_id' => (string) ($log->session_id ?? ''),
            'created_at' => optional($log->created_at)->toIso8601String() ?? '',
        ];

        return hash('sha256', json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
    }

    /**
     * Canonical JSON encoder for nested array fields.
     *
     * - null → empty string ""
     * - array → ksort recursive then json_encode (so {a:1,b:2} and
     *   {b:2,a:1} produce the same hash input)
     * - already-string (raw JSON) → re-decode + canonicalize, fall back
     *   to the original string if decode fails
     */
    protected static function canonicalJson(mixed $value): string
    {
        if ($value === null) {
            return '';
        }

        if (is_string($value)) {
            $decoded = json_decode($value, true);
            if (json_last_error() !== JSON_ERROR_NONE) {
                return $value;
            }
            $value = $decoded;
        }

        if (is_array($value)) {
            self::ksortRecursive($value);

            return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '';
        }

        return (string) $value;
    }

    protected static function ksortRecursive(array &$array): void
    {
        foreach ($array as &$value) {
            if (is_array($value)) {
                self::ksortRecursive($value);
            }
        }
        unset($value);

        ksort($array);
    }
}
