<?php

declare(strict_types=1);

namespace App\Models\Concerns;

use App\Enums\AuditEvent;
use App\Models\AuditLog;
use App\Services\AuditLogger;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;

/**
 * Records every create, update, delete and restore of a model into `audit_logs`.
 *
 * Deliberately hand-written rather than pulled from a package: we need
 * pre-rendered human descriptions, a mask for secret attributes, an exportable
 * UI and a retention policy, and this is less code than bending a dependency
 * into that shape.
 *
 * Only *changed* attributes are stored. Full row snapshots on every write would
 * make this the largest table in the database within a year, and the diff is
 * what the UI actually renders ("Status: in_stock -> assigned").
 *
 * @mixin Model
 */
trait Auditable
{
    /**
     * When true, lifecycle events on this instance are not logged.
     *
     * @see disableAuditing()
     */
    protected bool $auditingDisabled = false;

    /**
     * True for the duration of a restore, so the `updated` event that
     * SoftDeletes::restore() triggers is not logged alongside `restored`.
     */
    protected bool $auditingRestore = false;

    /**
     * Registered automatically by Eloquent because of the trait name.
     */
    public static function bootAuditable(): void
    {
        static::created(static function (self $model): void {
            $model->writeAuditLog(AuditEvent::Created);
        });

        static::updated(static function (self $model): void {
            $model->writeAuditLog(AuditEvent::Updated);
        });

        static::deleted(static function (self $model): void {
            $model->writeAuditLog(AuditEvent::Deleted);
        });

        /*
         | `restoring` / `restored` are only ever fired by SoftDeletes::restore()
         | — a model without that trait never dispatches them, so registering a
         | listener here is a harmless no-op for one. Going through the base
         | Model's own registerModelEvent() rather than the SoftDeletes trait's
         | restoring()/restored() sugar methods means this works for every
         | consumer of Auditable without needing to ask first whether the model
         | soft deletes.
         |
         | SoftDeletes::restore() calls save(), so a restore fires `updated`
         | (deleted_at -> null) and then `restored`. Logging both would put two
         | entries in the trail for one action, the first of them noise. The
         | flag suppresses the `updated` entry for that one save.
         */
        static::registerModelEvent('restoring', static function (self $model): void {
            $model->auditingRestore = true;
        });

        static::registerModelEvent('restored', static function (self $model): void {
            $model->auditingRestore = false;
            $model->writeAuditLog(AuditEvent::Restored);
        });
    }

    /**
     * The audit trail for this record, newest first.
     */
    public function auditLogs(): MorphMany
    {
        return $this->morphMany(AuditLog::class, 'auditable')->latest('created_at');
    }

    /**
     * Set to false on a model instance to skip auditing a specific write.
     *
     * Used by bulk operations such as the Excel importer, which records one
     * summary `imported` event instead of one row per created record.
     */
    public function disableAuditing(): static
    {
        $this->auditingDisabled = true;

        return $this;
    }

    public function enableAuditing(): static
    {
        $this->auditingDisabled = false;

        return $this;
    }

    /**
     * Human-readable sentence describing what happened.
     *
     * Written once, at the moment we still have full context, so the log stays
     * readable years later even if the model's fields change. Override in a
     * model to say something more specific.
     */
    protected function auditDescription(AuditEvent $event): string
    {
        return trim(sprintf(
            '%s %s %s',
            $event->verb(),
            $this->auditModelName(),
            $this->auditLabel(),
        ));
    }

    /**
     * Identifier of this record as a person would refer to it.
     */
    protected function auditLabel(): string
    {
        foreach (['asset_tag', 'employee_code', 'name', 'title', 'key'] as $attribute) {
            $value = $this->getAttribute($attribute);

            if (is_string($value) && $value !== '') {
                return $value;
            }
        }

        return '#'.$this->getKey();
    }

    /**
     * Model name as shown in the log, e.g. "Asset Category".
     */
    protected function auditModelName(): string
    {
        return trim((string) preg_replace('/(?<!^)[A-Z]/', ' $0', class_basename($this)));
    }

    /**
     * Attributes that must never appear in the log for this model, in addition
     * to the application-wide list in config('ams.audit.hidden_attributes').
     *
     * @return array<int, string>
     */
    protected function auditHiddenAttributes(): array
    {
        return [];
    }

    /**
     * Build and persist the audit record for one lifecycle event.
     */
    protected function writeAuditLog(AuditEvent $event): void
    {
        if ($this->auditingDisabled) {
            return;
        }

        // The save() inside a restore is described by the `restored` entry.
        if ($event === AuditEvent::Updated && $this->auditingRestore) {
            return;
        }

        [$old, $new] = $this->auditPayload($event);

        // An update that only touched timestamps carries no information.
        if ($event === AuditEvent::Updated && $old === [] && $new === []) {
            return;
        }

        app(AuditLogger::class)->log(
            event: $event,
            model: $this,
            old: $old,
            new: $new,
            description: $this->auditDescription($event),
        );
    }

    /**
     * Old/new attribute sets for the given event.
     *
     * Create  — new values only.
     * Update  — the changed attributes, before and after.
     * Delete  — the full original row, because this is the last chance to
     *           capture what was removed.
     * Restore — the change that brought it back.
     *
     * @return array{0: array<string, mixed>, 1: array<string, mixed>}
     */
    protected function auditPayload(AuditEvent $event): array
    {
        return match ($event) {
            AuditEvent::Created => [
                [],
                $this->sanitiseAuditValues($this->getAttributes()),
            ],
            AuditEvent::Updated, AuditEvent::Restored => [
                $this->sanitiseAuditValues(array_intersect_key($this->getOriginal(), $this->getChanges())),
                $this->sanitiseAuditValues($this->getChanges()),
            ],
            default => [
                $this->sanitiseAuditValues($this->getOriginal()),
                [],
            ],
        };
    }

    /**
     * Drop noise, mask secrets and truncate oversized values.
     *
     * Secrets are masked rather than removed so that "the password changed"
     * remains visible in the trail without the value ever being stored.
     *
     * @param  array<string, mixed>  $attributes
     * @return array<string, mixed>
     */
    protected function sanitiseAuditValues(array $attributes): array
    {
        /** @var array<int, string> $ignored */
        $ignored = config('ams.audit.ignored_attributes', []);
        /** @var array<int, string> $hidden */
        $hidden = array_merge(
            config('ams.audit.hidden_attributes', []),
            $this->auditHiddenAttributes(),
        );
        $mask = (string) config('ams.audit.mask', '********');
        $maxLength = (int) config('ams.audit.max_value_length', 500);

        $result = [];

        foreach ($attributes as $key => $value) {
            if (in_array($key, $ignored, strict: true)) {
                continue;
            }

            if (in_array($key, $hidden, strict: true)) {
                $result[$key] = $mask;

                continue;
            }

            if (is_string($value) && mb_strlen($value) > $maxLength) {
                $value = mb_substr($value, 0, $maxLength).'…';
            }

            $result[$key] = $value;
        }

        return $result;
    }
}
