<?php
/*
 * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
 * SPDX-License-Identifier: Apache-2.0
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * See the LICENSE file in the project root for the full license text.
 */

declare(strict_types=1);

namespace App\Services;

use App\Domain\User;
use App\Http\Request;
use JsonException;
use PDO;
use RuntimeException;

/**
 * Cross-cutting audit writer.
 *
 * Callers are expected to invoke record() inside the same SQL transaction as
 * the change itself, so that a rollback also rolls back the audit row.
 *
 * No-op rule: for action = 'UPDATE', if $before and $after are both non-null
 * and semantically equal, no row is written. Other actions always write.
 * Equality is structural (arrays deep-compared via JSON canonicalisation)
 * so reordered keys don't count as a change.
 */
final class AuditLogger
{
    public function __construct(private readonly PDO $pdo)
    {
    }

    /**
     * @param array<string,mixed>|null $before row snapshot before the change
     * @param array<string,mixed>|null $after  row snapshot after the change
     */
    public function record(
        string $action,
        string $entityType,
        ?int $entityId = null,
        ?array $before = null,
        ?array $after = null,
        ?int $userId = null,
        ?string $userEmail = null,
        ?string $ipAddress = null,
        ?string $userAgent = null,
    ): ?int {
        if (self::isNoOp($action, $before, $after)) {
            return null;
        }

        $stmt = $this->pdo->prepare(
            'INSERT INTO audit_log
                 (occurred_at, user_id, user_email, action, entity_type, entity_id,
                  before_json, after_json, ip_address, user_agent)
             VALUES (?,?,?,?,?,?,?,?,?,?)'
        );
        $stmt->execute([
            gmdate('Y-m-d\TH:i:s\Z'),
            $userId,
            $userEmail,
            $action,
            $entityType,
            $entityId,
            self::encodeJson($before),
            self::encodeJson($after),
            $ipAddress,
            $userAgent,
        ]);
        return (int) $this->pdo->lastInsertId();
    }

    /**
     * Thin wrapper that fills userId / userEmail / ip / user-agent from the
     * Request + User. Controllers should prefer this over record() to avoid
     * forgetting request-level metadata.
     *
     * @param array<string,mixed>|null $before
     * @param array<string,mixed>|null $after
     */
    public function recordForRequest(
        string $action,
        string $entityType,
        ?int $entityId,
        ?array $before,
        ?array $after,
        Request $req,
        ?User $actor,
    ): ?int {
        return $this->record(
            action:     $action,
            entityType: $entityType,
            entityId:   $entityId,
            before:     $before,
            after:      $after,
            userId:     $actor?->id,
            userEmail:  $actor?->email,
            ipAddress:  $req->ip(),
            userAgent:  $req->userAgent(),
        );
    }

    private static function isNoOp(string $action, ?array $before, ?array $after): bool
    {
        if ($action !== 'UPDATE') {
            return false;
        }
        if ($before === null || $after === null) {
            return false;
        }
        return self::canonicalJson($before) === self::canonicalJson($after);
    }

    private static function encodeJson(?array $value): ?string
    {
        if ($value === null) {
            return null;
        }
        try {
            return json_encode(
                $value,
                JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
            );
        } catch (JsonException $e) {
            throw new RuntimeException('Audit payload is not JSON-encodable', 0, $e);
        }
    }

    /**
     * Canonical encoding: sort object keys recursively so two structurally
     * equal arrays compare equal regardless of key order.
     */
    private static function canonicalJson(array $value): string
    {
        self::ksortRecursive($value);
        return self::encodeJson($value) ?? 'null';
    }

    private static function ksortRecursive(array &$arr): void
    {
        // Only associative arrays get re-sorted. Numeric-indexed lists keep
        // their order — assignment day ordering in a list matters semantically.
        $isList = array_is_list($arr);
        if (!$isList) {
            ksort($arr);
        }
        foreach ($arr as &$v) {
            if (is_array($v)) {
                self::ksortRecursive($v);
            }
        }
    }
}
