<?php

namespace Mtc\Plugins\CmsAuditLog\Providers;

use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\ServiceProvider;
use Mtc\Cms\Models\PageListItemData;
use Mtc\Plugins\CmsAuditLog\Src\AuditLogger;
use Mtc\Plugins\CmsAuditLog\Src\Models\AuditEntry;
use Mtc\Plugins\CmsAuditLog\Src\Observers\PageListItemDataObserver;

class CmsAuditLogServiceProvider extends ServiceProvider
{
    /**
     * HooksAdapter is a process-global singleton, so its registrations
     * must only happen once even if multiple app instances boot in the
     * same process (e.g. phpunit).
     */
    protected static bool $legacyHooksRegistered = false;

    /**
     * Dispatchers registerDispatcherListeners() has already run against,
     * keyed per instance. Model::observe() and Dispatcher::listen() do not
     * dedupe, so re-running against the same dispatcher (a rebind of
     * 'events' to an already-populated dispatcher) would stack the handlers
     * and write duplicate audit rows. A process-wide flag (like
     * $legacyHooksRegistered) would be wrong here: each fresh dispatcher
     * from a rebind still needs its own registration.
     */
    protected static ?\WeakMap $registeredDispatchers = null;

    /**
     * Page fields the page_updated diff reports on.
     */
    const PAGE_DIFF_FIELDS = [
        'title',
        'slug',
        'published',
        'template',
        'layout',
        'seo_title',
        'seo_keywords',
        'seo_description',
        'innav',
        'searchable',
        'frontpage',
        'noindex',
        'redchild',
        'review_status',
    ];

    public function register(): void
    {
        $this->loadRoutesFrom(__DIR__ . '/../routes/web.php');
        $this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
        $this->mergeConfigFrom(__DIR__ . '/../config/cms_audit_log.php', 'cms_audit_log');
    }

    public function boot(): void
    {
        if (!config('cms_audit_log.enabled')) {
            return;
        }

        $this->registerDispatcherListeners($this->app['events']);

        // The legacy bootstrap (core/includes/header.inc.php) re-binds the
        // 'events' singleton after providers have booted, which discards
        // every listener registered above. Re-register on the replacement
        // dispatcher when that happens.
        $this->app->rebinding('events', function ($app, $events) {
            Model::setEventDispatcher($events);
            $this->registerDispatcherListeners($events);
        });

        if (!self::$legacyHooksRegistered) {
            self::$legacyHooksRegistered = true;
            $this->registerLegacyHooks();
        }
    }

    /**
     * Listeners that live on the Laravel event dispatcher. Called again
     * whenever the 'events' binding is replaced, so registrations must be
     * against the given dispatcher (not the Event facade, whose resolved
     * instance may be stale).
     */
    protected function registerDispatcherListeners(Dispatcher $events): void
    {
        self::$registeredDispatchers ??= new \WeakMap();
        if (isset(self::$registeredDispatchers[$events])) {
            return;
        }
        self::$registeredDispatchers[$events] = true;

        PageListItemData::observe(PageListItemDataObserver::class);

        // Page deletion - listened to before rows are removed so the title
        // is still available. History/temp pages are skipped because version
        // housekeeping deletes those constantly without admin involvement.
        $events->listen('Page/delete:before_delete', function ($page_id, $delete_subpages = null, $delete_relations = null, $delete_linked_drafts = null) {
            // The draft/commit flows delete the old live page before swapping
            // the draft in, always passing delete_linked_drafts=false. Real
            // deletions (delete_page.php) leave it at the default (true) -
            // skip the internal swap so it is not misreported as a deletion.
            if ($delete_linked_drafts === false) {
                return;
            }

            $page = \Mtc\Cms\Models\Page::query()->find($page_id);
            if (empty($page) || in_array($page->type, ['history', 'temp'], true)) {
                return;
            }

            AuditLogger::log(AuditEntry::ACTION_PAGE_DELETED, (int) $page->id, $page->title, [
                'page_type' => $page->type,
                'slug' => $page->slug,
            ]);
        });
    }

    /**
     * Listen to the HooksAdapter actions fired by the CMS content manager
     * save paths. Registered here (not in a plugin hooks.php) because
     * hooks.php files are only loaded by the legacy web bootstrap, while
     * providers boot in web, artisan, and phpunit alike.
     */
    protected function registerLegacyHooks(): void
    {
        // Main page save (create/update of page settings + content)
        \HooksAdapter::add_action('ContentManager/processUpdateRequestPageUpdate', function ($payload) {
            $page = $payload['page'] ?? null;
            $old_page = $payload['old_page'] ?? null;
            if (empty($page->id)) {
                return;
            }

            // Preview saves leave the page as a temp copy - not a real change
            if (($page->type ?? null) === 'temp') {
                return;
            }

            $changes = [];
            if (!empty($old_page)) {
                foreach (self::PAGE_DIFF_FIELDS as $field) {
                    if ((string) ($old_page->{$field} ?? '') !== (string) ($page->{$field} ?? '')) {
                        $changes[$field] = [
                            'old' => AuditLogger::truncateValue((string) ($old_page->{$field} ?? '')),
                            'new' => AuditLogger::truncateValue((string) ($page->{$field} ?? '')),
                        ];
                    }
                }
            }

            AuditLogger::log(AuditEntry::ACTION_PAGE_UPDATED, self::livePageId($page), $page->title ?? null, [
                'page_type' => $page->type ?? null,
                'saved_page_id' => (int) $page->id,
                'changes' => $changes,
                'history_page_id' => $payload['history_page_id'] ?? null,
            ]);
        });

        // Save as version (page versioning enabled)
        \HooksAdapter::add_action('ContentManager/savePageVersion', function ($payload) {
            $page = $payload['page'] ?? null;
            if (empty($page->id)) {
                return;
            }

            AuditLogger::log(AuditEntry::ACTION_VERSION_SAVED, self::livePageId($page), $page->title ?? null, [
                'saved_page_id' => (int) $page->id,
                'draft_for_page_id' => $page->draft_for_page_id ?? null,
            ]);
        });

        // Draft/version committed as the live page
        \HooksAdapter::add_action('ContentManager/commitPage', function ($payload) {
            $page = $payload['page'] ?? null;
            if (empty($page->id)) {
                return;
            }

            AuditLogger::log(AuditEntry::ACTION_PAGE_PUBLISHED, (int) $page->id, $page->title ?? null, [
                'comment' => $payload['comment'] ?? null,
            ]);
        });

        // Content structure (lists/items) saved outside the main page save.
        // 'Pagedata/saved' fires after rows are written; the pre-existing
        // 'Pagedata/save' action fires before anything is persisted.
        // ContentManager/copyPage is deliberately NOT logged: the versioning
        // handler calls it internally on every version save and preview, so
        // it cannot be told apart from an admin-initiated copy.
        \HooksAdapter::add_action('Pagedata/saved', function ($payload) {
            $page_id = !empty($payload['page_id']) ? (int) $payload['page_id'] : null;

            AuditLogger::log(AuditEntry::ACTION_CONTENT_STRUCTURE_SAVED, $page_id);
        });
    }

    /**
     * The page id an audit entry should be filed under. Versioned saves
     * receive a history/draft copy whose draft_for_page_id points at the
     * live page - use that so entries stay findable by the real page.
     */
    protected static function livePageId(object $page): int
    {
        if (!empty($page->draft_for_page_id) && (int) $page->draft_for_page_id !== (int) $page->id) {
            return (int) $page->draft_for_page_id;
        }

        return (int) $page->id;
    }
}
