<?php

namespace App\Services;

use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Mtc\ContentManager\Contracts\PageModel;
use Mtc\ContentManager\Models\ContentCategory;
use Mtc\ContentManager\Models\ContentTag;
use Mtc\ContentManager\Models\Template;
use Mtc\MercuryDataModels\Franchise;
use Mtc\MercuryDataModels\SeoData;
use Mtc\MercuryDataModels\User;

class PageBulkUpdateService
{
    /**
     * Final-step actions. `publish` applies changes live (today's behaviour);
     * `draft` ("Save as version") applies metadata live but never publishes
     * and retains a draft content version. Both no-op to `publish` when
     * versioning is disabled for the tenant.
     */
    public const VERSION_ACTION_PUBLISH = 'publish';
    public const VERSION_ACTION_DRAFT = 'draft';

    /**
     * Page columns the bulk-update UI is allowed to touch. Anything not in
     * this list (identity columns, nested-set internals, JSON content blobs,
     * timestamps) is intentionally excluded — see CLAUDE.md note about
     * keeping the `_lft`/`_rgt`/`parent_id` tree intact.
     */
    private const ALLOWED_FIELDS = [
        'title',
        'status',
        'published_at',
        'unpublished_at',
        'featured',
        'template_id',
        'author_id',
        'franchise_id',
        'password',
        'excerpt',
        'estimated_read_time',
    ];

    private const BOOLEAN_FIELDS = [
        'featured',
    ];

    private const INTEGER_FIELDS = [
        'estimated_read_time',
        'template_id',
        'author_id',
        'franchise_id',
    ];

    private const DATETIME_FIELDS = [
        'published_at',
        'unpublished_at',
    ];

    private const TEXTAREA_FIELDS = [
        'excerpt',
    ];

    private const PASSWORD_FIELDS = [
        'password',
    ];

    private const RELATIONSHIP_FIELDS = [
        'template_id' => [
            'label' => 'Template',
            'model' => Template::class,
            'label_column' => 'name',
        ],
        'author_id' => [
            'label' => 'Author',
            'model' => User::class,
            'label_column' => 'name',
        ],
        'franchise_id' => [
            'label' => 'Franchise',
            'model' => Franchise::class,
            'label_column' => 'name',
        ],
    ];

    private const SELECT_FIELDS = [
        'status' => [
            'label' => 'Status',
            'options' => [
                ['value' => 'draft', 'label' => 'Draft'],
                ['value' => 'published', 'label' => 'Published'],
            ],
        ],
    ];

    private const FIELD_LABELS = [
        'title' => 'Title',
        'excerpt' => 'Excerpt',
        'password' => 'Password',
        'featured' => 'Featured',
        'published_at' => 'Publish date',
        'unpublished_at' => 'Unpublish date',
        'estimated_read_time' => 'Estimated read time (minutes)',
    ];

    private const FIELD_HELPERS = [
        'published_at' => 'Make sure status is marked as Published if setting a future publish time.',
        'unpublished_at' => 'The page will automatically unpublish at this date and time.',
        'excerpt' => 'Maximum 180 characters.',
    ];

    /**
     * Many-to-many relations the bulk update can replace wholesale.
     * Values submitted are full arrays of IDs; existing pivot rows are
     * synced so the post-update set matches the submitted set exactly.
     */
    private const MULTI_RELATION_FIELDS = [
        'categories' => [
            'label' => 'Categories',
            'model' => ContentCategory::class,
            'label_column' => 'name',
        ],
        'tags' => [
            'label' => 'Tags',
            'model' => ContentTag::class,
            'label_column' => 'name',
        ],
    ];

    /**
     * SEO sub-fields living inside the `seo` JSON column (via SeoDataCast).
     * Bulk-editable on a per-key basis — values are merged into the existing
     * seo array, not replaced wholesale, so unrelated keys stay intact.
     * Note: SeoDataCast is asymmetric — `get()` exposes `json_schema`
     * (snake) while `set()` reads `jsonSchema` (camel). We use camelCase
     * here so writes round-trip correctly through `set()`, and translate
     * on read in getPageData(). See [[seo-data-cast-asymmetry]].
     */
    private const SEO_FIELDS = [
        'title' => [
            'label' => 'SEO title',
            'type' => 'string',
            'helper' => 'Minimum 43 characters.',
        ],
        'description' => [
            'label' => 'SEO description',
            'type' => 'textarea',
            'helper' => 'Minimum 100 characters.',
        ],
        'heading' => [
            'label' => 'SEO heading',
            'type' => 'string',
            'helper' => 'Not applicable for every page, mainly used on Search Results page.',
        ],
        'canonical' => [
            'label' => 'SEO canonical',
            'type' => 'string',
            'helper' => 'If empty, this will be a self-referencing canonical set at code level.',
        ],
        'jsonSchema' => [
            'label' => 'SEO JSON schema',
            'type' => 'textarea',
        ],
    ];

    /**
     * SEO sub-keys whose name in the cast-exposed array differs from the
     * name used by the cast's storage writer. Read flows hit the left key,
     * write flows hit the right key. Only json_schema is affected today.
     */
    private const SEO_READ_KEY_ALIASES = [
        'jsonSchema' => 'json_schema',
    ];

    /**
     * Map of bulk SEO field keys (camelCase) → seo_data table columns.
     * pages.seo is a mirror that PageRepository writes alongside seo_data,
     * but seo_data (keyed by URL path) is the canonical store the front-end
     * reads from. Both have to move together or bulk updates "succeed"
     * without showing up on the live site.
     */
    private const SEO_DATA_COLUMNS = [
        'title' => 'title',
        'description' => 'description',
        'heading' => 'heading',
        'canonical' => 'canonical',
        'jsonSchema' => 'json_schema',
    ];

    private const SEO_PREFIX = 'seo.';

    public function getFields(): array
    {
        $fields = [];

        foreach (self::ALLOWED_FIELDS as $field) {
            $fields[] = $this->getFieldMetadata($field);
        }

        foreach (self::MULTI_RELATION_FIELDS as $name => $config) {
            $fields[] = [
                'name' => $name,
                'label' => $config['label'],
                'type' => 'multiselect',
                'nullable' => true,
                'options' => $this->getRelationshipOptions($config['model'], $config['label_column']),
            ];
        }

        foreach (self::SEO_FIELDS as $key => $config) {
            $field = [
                'name' => self::SEO_PREFIX . $key,
                'label' => $config['label'],
                'type' => $config['type'],
                'nullable' => true,
            ];
            if (!empty($config['helper'])) {
                $field['helper_text'] = $config['helper'];
            }
            $fields[] = $field;
        }

        usort($fields, fn ($a, $b) => strcmp($a['label'], $b['label']));

        return $fields;
    }

    public function getAllowedFields(): array
    {
        return array_column($this->getFields(), 'name');
    }

    /**
     * Whether page versioning is enabled for the current tenant. Drives the
     * final-step "Save & publish" / "Save as version" choice in the UI.
     */
    public function versioningEnabled(): bool
    {
        return app('page_repository')->hasVersioning();
    }

    /**
     * Per-field validation rules applied to submitted bulk values, mirroring
     * the constraints the per-page edit form (UpdatePageRequest and the SEO
     * fields form) enforces — plus the underlying column limits so a bulk
     * write can't push past what the edit screen would allow or trigger a
     * DB truncation. Keyed by the same field names getFields() exposes,
     * including dotted seo.* keys; fields absent from the map accept any
     * value. Each entry is ['rules' => [...], 'each' => [...]] where the
     * optional 'each' validates individual elements of array-valued fields.
     */
    public function getValidationRules(): array
    {
        $statusValues = array_column(self::SELECT_FIELDS['status']['options'], 'value');

        return [
            'title' => ['rules' => ['nullable', 'string', 'max:255']],
            'status' => ['rules' => ['nullable', Rule::in($statusValues)]],
            'published_at' => ['rules' => ['nullable', 'date']],
            'unpublished_at' => ['rules' => ['nullable', 'date']],
            'featured' => ['rules' => ['nullable', 'boolean']],
            'template_id' => ['rules' => ['nullable', 'integer']],
            'author_id' => ['rules' => ['nullable', 'integer']],
            'franchise_id' => ['rules' => ['nullable', 'integer']],
            'password' => ['rules' => ['nullable', 'string', 'max:255']],
            'excerpt' => ['rules' => ['nullable', 'string', 'max:255']],
            'estimated_read_time' => ['rules' => ['nullable', 'integer', 'min:0', 'max:65535']],
            'categories' => ['rules' => ['nullable', 'array'], 'each' => ['integer']],
            'tags' => ['rules' => ['nullable', 'array'], 'each' => ['integer']],
            self::SEO_PREFIX . 'title' => ['rules' => ['nullable', 'string', 'max:250']],
            self::SEO_PREFIX . 'description' => ['rules' => ['nullable', 'string', 'max:500']],
            self::SEO_PREFIX . 'heading' => ['rules' => ['nullable', 'string', 'max:255']],
            self::SEO_PREFIX . 'canonical' => ['rules' => ['nullable', 'string', 'max:255']],
            self::SEO_PREFIX . 'jsonSchema' => ['rules' => ['nullable', 'json']],
        ];
    }

    /**
     * Return current values for the selected pages, flattened so seo sub-keys
     * appear as `seo.X` keys alongside plain columns. Lets the bulk-update UI
     * pre-fill cells using the same `data[fieldName]` lookup pattern it uses
     * for vehicles/offers. SEO values are sourced from `seo_data` (keyed by
     * URL path) when present so the form mirrors what's actually live.
     */
    public function getPageData(array $ids): array
    {
        $pages = app(PageModel::class)
            ->newQuery()
            ->with(['categories:id', 'tags:id'])
            ->whereIn('id', $ids)
            ->get();

        $seoByPath = SeoData::query()
            ->whereIn('path', $pages->map->urlPath()->all())
            ->get()
            ->keyBy('path');

        return $pages->mapWithKeys(function ($page) use ($seoByPath) {
            $row = [];

            foreach (self::ALLOWED_FIELDS as $field) {
                $row[$field] = $page->{$field};
            }

            $row['categories'] = $page->categories->pluck('id')->all();
            $row['tags'] = $page->tags->pluck('id')->all();

            $mirror = $page->seo ?? [];
            $live = $seoByPath->get($page->urlPath());

            foreach (array_keys(self::SEO_FIELDS) as $key) {
                $row[self::SEO_PREFIX . $key] = $this->readSeoValue($key, $live, $mirror);
            }

            return [$page->id => $row];
        })->toArray();
    }

    private function readSeoValue(string $key, ?SeoData $live, array $mirror): mixed
    {
        if ($live !== null) {
            $column = self::SEO_DATA_COLUMNS[$key] ?? null;
            if ($column && $live->{$column} !== null) {
                return $live->{$column};
            }
        }

        $mirrorKey = self::SEO_READ_KEY_ALIASES[$key] ?? $key;
        return $mirror[$mirrorKey] ?? null;
    }

    public function update(
        string $mode,
        array $ids,
        array $data,
        string $versionAction = self::VERSION_ACTION_PUBLISH
    ): int {
        $allowedFields = $this->getAllowedFields();

        return DB::transaction(function () use ($mode, $ids, $data, $allowedFields, $versionAction) {
            if ($mode === 'same') {
                return $this->updateSameValue($ids, $data['field'], $data['value'], $allowedFields, $versionAction);
            }

            return $this->updateDifferentValues($ids, $data['updates'], $allowedFields, $versionAction);
        });
    }

    /**
     * Soft-delete the selected pages via per-model delete() so the Page
     * nested-set descendant cascade, slug-suffix observer and audit rows all
     * fire as required by the model. Already-archived rows are skipped.
     */
    public function archive(array $ids): int
    {
        $archived = 0;
        $pages = app(PageModel::class)->newQuery()->whereIn('id', $ids)->get();

        foreach ($pages as $page) {
            if ($page->trashed()) {
                continue;
            }
            $page->delete();
            $archived++;
        }

        return $archived;
    }

    private function updateSameValue(
        array $ids,
        string $field,
        mixed $value,
        array $allowedFields,
        string $versionAction = self::VERSION_ACTION_PUBLISH
    ): int {
        if (!in_array($field, $allowedFields, true)) {
            throw new \InvalidArgumentException("Field '{$field}' is not allowed for bulk update.");
        }

        $pages = app(PageModel::class)->newQuery()->whereIn('id', $ids)->get();
        $updated = 0;

        foreach ($pages as $page) {
            if ($this->applyAndPersist($page, [$field => $value], $versionAction)) {
                $updated++;
            }
        }

        return $updated;
    }

    private function updateDifferentValues(
        array $ids,
        array $updates,
        array $allowedFields,
        string $versionAction = self::VERSION_ACTION_PUBLISH
    ): int {
        $updated = 0;
        $pages = app(PageModel::class)->newQuery()->whereIn('id', $ids)->get()->keyBy('id');

        foreach ($updates as $pageId => $fields) {
            if (!in_array((int) $pageId, array_map('intval', $ids), true)) {
                continue;
            }

            $page = $pages->get((int) $pageId);
            if (!$page) {
                continue;
            }

            $cleanFields = collect($fields)
                ->filter(fn ($_value, $field) => in_array($field, $allowedFields, true))
                ->all();

            if ($this->applyAndPersist($page, $cleanFields, $versionAction)) {
                $updated++;
            }
        }

        return $updated;
    }

    /**
     * Apply a batch of field updates to one page in a single pass and
     * persist them. Groups by handler so SEO writes can be merged with
     * existing seo_data (the canonical store) before either store is
     * touched — without that merge a partial bulk write would clobber
     * untouched seo_data columns with empty strings from pages.seo.
     */
    private function applyAndPersist(
        $page,
        array $fieldsByName,
        string $versionAction = self::VERSION_ACTION_PUBLISH
    ): bool {
        /** @var \App\PageRepository $repository */
        $repository = app('page_repository');
        $isDraftAction = $versionAction === self::VERSION_ACTION_DRAFT && $repository->hasVersioning();

        // "Save as version" never publishes: hold status at draft and keep a
        // content restore-point (below) instead of pushing the change live.
        if ($isDraftAction && array_key_exists('status', $fieldsByName)) {
            $fieldsByName['status'] = 'draft';
        }

        $dirty = false;
        $seoUpdates = [];

        foreach ($fieldsByName as $field => $value) {
            if (str_starts_with($field, self::SEO_PREFIX)) {
                $seoUpdates[substr($field, strlen(self::SEO_PREFIX))] = $value;
                continue;
            }

            if (array_key_exists($field, self::MULTI_RELATION_FIELDS)) {
                if ($this->syncMultiRelation($page, $field, $value)) {
                    $dirty = true;
                }
                continue;
            }

            if ($this->applyColumnUpdate($page, $field, $value)) {
                $dirty = true;
            }
        }

        if (!empty($seoUpdates) && $this->applySeoUpdates($page, $seoUpdates)) {
            $dirty = true;
        }

        if ($page->isDirty()) {
            $page->save();
        }

        // Retain a draft content version as a restore-point for the state at
        // bulk-edit time. page_versions snapshot content only (not the
        // metadata this bulk touched), so this is a history marker, not a
        // held-back change — the metadata above is already applied live.
        if ($dirty && $isDraftAction) {
            $repository->snapshotContentVersion($page, (int) Auth::id(), false);
        }

        return $dirty;
    }

    private function applyColumnUpdate($page, string $field, mixed $value): bool
    {
        $castValue = $this->castValue($field, $value);

        if ($page->{$field} === $castValue) {
            return false;
        }

        $page->{$field} = $castValue;
        return true;
    }

    /**
     * Apply one or more SEO sub-field updates to a page, merging with the
     * canonical seo_data row first so any column the bulk call did NOT
     * touch survives. Writes go to both seo_data (live source) and
     * pages.seo (admin form mirror) using the merged set so the two
     * stores stay aligned. See `project_seo_dual_store` memory.
     */
    private function applySeoUpdates($page, array $seoUpdates): bool
    {
        $path = $page->urlPath();
        $seoData = SeoData::query()->where('path', $path)->first();
        $existingMirror = $page->seo ?? [];

        $merged = [];
        $changed = false;

        foreach (self::SEO_DATA_COLUMNS as $bulkKey => $column) {
            $mirrorReadKey = self::SEO_READ_KEY_ALIASES[$bulkKey] ?? $bulkKey;
            $existing = $seoData?->{$column} ?? ($existingMirror[$mirrorReadKey] ?? null);

            if (array_key_exists($bulkKey, $seoUpdates)) {
                $newValue = $this->castSeoValue($seoUpdates[$bulkKey]);
                if ($existing !== $newValue) {
                    $changed = true;
                }
                $merged[$bulkKey] = $newValue;
            } else {
                $merged[$bulkKey] = $existing ?? '';
            }
        }

        if (!$changed) {
            return false;
        }

        // pages.seo mirror — write under cast.set's keys (camelCase
        // jsonSchema, plain otherwise) so cast.set preserves all values
        // when re-encoding the JSON column.
        $newMirror = $existingMirror;
        foreach ($merged as $bulkKey => $value) {
            $newMirror[$bulkKey] = $value;
        }
        $page->seo = $newMirror;

        // seo_data canonical — full merged set so untouched columns keep
        // their previous values rather than being nulled by partial writes.
        $payload = ['path' => $path];
        foreach ($merged as $bulkKey => $value) {
            $payload[self::SEO_DATA_COLUMNS[$bulkKey]] = $value;
        }
        SeoData::query()->updateOrCreate(['path' => $path], $payload);

        return true;
    }

    /**
     * Sync the pivot to exactly $value. Returns true when the set actually
     * changes; pivot writes happen directly so the caller's $page->save()
     * is a no-op for these fields (but still safe to invoke).
     */
    private function syncMultiRelation($page, string $field, mixed $value): bool
    {
        $ids = $this->normaliseIdArray($value);
        $existing = $page->{$field}()->pluck($page->{$field}()->getRelated()->getQualifiedKeyName())
            ->map(fn ($id) => (int) $id)
            ->sort()
            ->values()
            ->all();
        $incoming = collect($ids)->sort()->values()->all();

        if ($existing === $incoming) {
            return false;
        }

        $page->{$field}()->sync($ids);
        return true;
    }

    private function normaliseIdArray(mixed $value): array
    {
        if ($value === null || $value === '') {
            return [];
        }

        $array = is_array($value) ? $value : [$value];

        return array_values(array_filter(
            array_map('intval', $array),
            fn ($id) => $id > 0,
        ));
    }

    private function getFieldMetadata(string $field): array
    {
        $base = [
            'name' => $field,
            'label' => self::FIELD_LABELS[$field] ?? $this->generateLabel($field),
            'nullable' => true,
        ];

        if (!empty(self::FIELD_HELPERS[$field])) {
            $base['helper_text'] = self::FIELD_HELPERS[$field];
        }

        if (array_key_exists($field, self::RELATIONSHIP_FIELDS)) {
            $config = self::RELATIONSHIP_FIELDS[$field];
            return array_merge($base, [
                'type' => 'select',
                'label' => $config['label'],
                'options' => $field === 'author_id'
                    ? $this->getAuthorOptions()
                    : $this->getRelationshipOptions($config['model'], $config['label_column']),
            ]);
        }

        if (array_key_exists($field, self::SELECT_FIELDS)) {
            $config = self::SELECT_FIELDS[$field];
            return array_merge($base, [
                'type' => 'select',
                'label' => $config['label'],
                'options' => $config['options'],
            ]);
        }

        if (in_array($field, self::BOOLEAN_FIELDS, true)) {
            return array_merge($base, [
                'type' => 'boolean',
                'nullable' => false,
            ]);
        }

        if (in_array($field, self::INTEGER_FIELDS, true)) {
            return array_merge($base, ['type' => 'integer']);
        }

        if (in_array($field, self::DATETIME_FIELDS, true)) {
            return array_merge($base, ['type' => 'datetime']);
        }

        if (in_array($field, self::PASSWORD_FIELDS, true)) {
            return array_merge($base, ['type' => 'password']);
        }

        if (in_array($field, self::TEXTAREA_FIELDS, true)) {
            return array_merge($base, ['type' => 'textarea']);
        }

        return array_merge($base, ['type' => 'string']);
    }

    private function getRelationshipOptions(string $modelClass, string $labelColumn): array
    {
        return $modelClass::query()
            ->select(['id', $labelColumn])
            ->orderBy($labelColumn)
            ->get()
            ->map(fn ($item) => [
                'value' => $item->id,
                'label' => $item->{$labelColumn},
            ])
            ->toArray();
    }

    /**
     * Authors are scoped to the current tenant's users with the
     * `create-content` permission, matching what PageViewResource
     * exposes for the per-page edit form. Users live on the central
     * database, so we can't just query the User model directly —
     * that would leak names from every site into the dropdown.
     */
    private function getAuthorOptions(): array
    {
        $tenant = tenant();
        if (!$tenant) {
            return [];
        }

        return $tenant->users()
            ->select(['users.id', 'users.name'])
            ->orderBy('users.name')
            ->get()
            ->filter(fn (User $user) => $user->hasPermissionTo('create-content'))
            ->map(fn (User $user) => [
                'value' => $user->id,
                'label' => $user->name,
            ])
            ->values()
            ->toArray();
    }

    private function generateLabel(string $field): string
    {
        return ucwords(str_replace('_', ' ', $field));
    }

    private function castValue(string $field, mixed $value): mixed
    {
        if ($value === null || $value === '') {
            return null;
        }

        if (in_array($field, self::BOOLEAN_FIELDS, true)) {
            return filter_var($value, FILTER_VALIDATE_BOOLEAN);
        }

        if (in_array($field, self::INTEGER_FIELDS, true)) {
            return (int) $value;
        }

        return $value;
    }

    private function castSeoValue(mixed $value): mixed
    {
        if ($value === null || $value === '') {
            return '';
        }

        return $value;
    }
}
