<?php

declare(strict_types=1);

/**
 * APO-195 Card 1 — Pages action service.
 *
 * Wraps MyronContentRepository::{createPage, savePage, deletePage} so both
 * the admin form path (content/page-editor.php) AND the new API path
 * /api/v1/content/pages/... funnel through identical logic. APO-191 publish
 * reliability + APO-192 live-content resolution remain intact by construction
 * — the service-layer call sites are the same as before; the route just
 * dispatches through the service now.
 *
 * Menu + category assignment sync remain admin-form concerns (they read
 * form-specific arrays like menu_enabled[]/menu_label[]); the API path uses
 * the dedicated menu/category action endpoints to set those associations.
 */
final class MyronPagesActionService
{
    public function __construct(
        private readonly MyronContentRepository $repository,
        private readonly ?MyronPageRoleService $pageRoles = null,
    ) {
    }

    /** @return array<string, mixed> */
    public function assignRole(string $role, string $pageId, MyronActionCallerContext $caller): array
    {
        if (!$this->pageRoles instanceof MyronPageRoleService) {
            throw new LogicException('Page-role service is unavailable.');
        }
        return $this->pageRoles->assign($role, $pageId, $caller->callerId);
    }

    /** @return array<string, mixed> */
    public function clearRole(string $role, ?string $expectedPageId, MyronActionCallerContext $caller): array
    {
        if (!$this->pageRoles instanceof MyronPageRoleService) {
            throw new LogicException('Page-role service is unavailable.');
        }
        return $this->pageRoles->clear($role, $expectedPageId);
    }

    /**
     * Create a page.
     *
     * @param array<string, mixed> $content
     * @return array{pageId: string, publishedAt: ?string}
     */
    public function create(
        string $templateId,
        string $pageName,
        string $slug,
        ?string $parentPageId,
        string $seoTitle,
        string $metaDescription,
        array $content,
        bool $publishNow,
        ?string $publishedAt,
        ?string $sourceTemplateId,
        ?string $sourceBundleId,
        MyronActionCallerContext $caller,
        array $seoOverrides = [],
        array $categoryIds = [],
        array $author = [],
        ?bool $includeInNav = null
    ): array {
        $templateId = trim($templateId);
        if ($templateId === '') {
            throw new InvalidArgumentException('templateId is required.');
        }
        if ($author !== []) {
            $content = $this->withPostAuthor($content, $author);
        }
        $pageId = $this->repository->createPage(
            $templateId,
            $pageName,
            $slug,
            $parentPageId,
            $seoTitle,
            $metaDescription,
            $content,
            $publishNow,
            $publishedAt,
            $sourceTemplateId,
            $sourceBundleId,
            $seoOverrides,
            $includeInNav
        );
        if ($categoryIds !== []) {
            $this->repository->replacePageCategoryAssignments($pageId, $this->normalizeStringList($categoryIds));
        }
        if ($publishNow) {
            myronSearchIndexer()->indexPageById($pageId);
        }
        return ['pageId' => $pageId, 'publishedAt' => $publishNow ? ($publishedAt ?? '') : null];
    }

    /**
     * Update an existing page.
     *
     * @param array<string, mixed> $content
     * @return array{pageId: string, publishedAt: ?string}
     */
    public function update(
        string $pageId,
        string $templateId,
        string $pageName,
        string $slug,
        ?string $parentPageId,
        string $seoTitle,
        string $metaDescription,
        array $content,
        bool $publishNow,
        ?string $publishedAt,
        MyronActionCallerContext $caller,
        array $seoOverrides = []
    ): array {
        $pageId = trim($pageId);
        if ($pageId === '') {
            throw new InvalidArgumentException('pageId is required.');
        }
        if (trim($templateId) === '') {
            throw new InvalidArgumentException('templateId is required.');
        }
        $this->repository->savePage(
            $pageId,
            $templateId,
            $pageName,
            $slug,
            $parentPageId,
            $seoTitle,
            $metaDescription,
            $content,
            $publishNow,
            $publishedAt,
            $seoOverrides
        );
        // Always re-evaluate index state: a slug/path change on an already-published
        // page must update the index even when publishNow=false (the page is still
        // published from a prior version). indexPageById is a no-op for drafts.
        myronSearchIndexer()->indexPageById($pageId);
        return ['pageId' => $pageId, 'publishedAt' => $publishNow ? ($publishedAt ?? '') : null];
    }

    /**
     * Merge-safe API update. Omitted fields preserve the current editable page
     * state by default; mode=replace intentionally treats supplied content as a
     * full content envelope while still preserving page identity fields when
     * they are omitted.
     *
     * @param array<string, mixed> $input
     * @return array{pageId:string,publishedAt:?string,categories?:array<int,string>,author?:array<string,string>}
     */
    public function updateFromInput(array $input, MyronActionCallerContext $caller): array
    {
        $pageId = trim((string) ($input['pageId'] ?? ''));
        if ($pageId === '') {
            throw new InvalidArgumentException('pageId is required.');
        }

        $page = $this->repository->getEditablePage($pageId);
        if (!is_array($page)) {
            throw new MyronActionPreconditionError('Page not found: ' . $pageId);
        }

        $mode = strtolower(trim((string) ($input['mode'] ?? 'merge')));
        if (!in_array($mode, ['merge', 'replace'], true)) {
            throw new InvalidArgumentException('mode must be merge or replace.');
        }

        $content = is_array($page['content'] ?? null) ? (array) $page['content'] : [];
        if (array_key_exists('content', $input)) {
            $incomingContent = is_array($input['content'] ?? null) ? (array) $input['content'] : [];
            $content = $mode === 'replace' ? $incomingContent : $this->mergeRecursiveDistinct($content, $incomingContent);
        }
        if (is_array($input['author'] ?? null)) {
            $content = $this->withPostAuthor($content, (array) $input['author']);
        }

        $parentPageId = array_key_exists('parentPageId', $input)
            ? $this->nullableString($input['parentPageId'])
            : (isset($page['parent_page_id']) ? (string) $page['parent_page_id'] : null);
        $publishedAt = array_key_exists('publishedAt', $input) ? $this->nullableString($input['publishedAt']) : null;
        $publishNow = (bool) ($input['publish'] ?? false);

        $this->repository->savePage(
            $pageId,
            array_key_exists('templateId', $input) ? trim((string) $input['templateId']) : (string) ($page['template_id'] ?? ''),
            array_key_exists('name', $input) ? (string) $input['name'] : (string) ($page['title'] ?? ''),
            array_key_exists('slug', $input) ? (string) $input['slug'] : (string) ($page['slug'] ?? ''),
            $parentPageId,
            array_key_exists('seoTitle', $input) ? (string) $input['seoTitle'] : (string) ($page['seo_title'] ?? ''),
            array_key_exists('metaDescription', $input) ? (string) $input['metaDescription'] : (string) ($page['meta_description'] ?? ''),
            $content,
            $publishNow,
            $publishedAt,
            $this->seoOverridesFromPage($page)
        );

        $result = ['pageId' => $pageId, 'publishedAt' => $publishNow ? ($publishedAt ?? myronNow()) : null];
        if (array_key_exists('categoryIds', $input)) {
            $ids = $this->normalizeStringList(is_array($input['categoryIds'] ?? null) ? (array) $input['categoryIds'] : []);
            $this->repository->replacePageCategoryAssignments($pageId, $ids);
            $result['categories'] = $ids;
        }
        if (is_array($input['author'] ?? null)) {
            $result['author'] = $this->postAuthorFromContent($content);
        }
        if (array_key_exists('includeInNav', $input) || array_key_exists('include_in_nav', $input)) {
            $includeInNav = $this->normalizeBoolean($input['includeInNav'] ?? $input['include_in_nav'] ?? false);
            $this->repository->setPageIncludeInNav($pageId, $includeInNav);
            $result['includeInNav'] = $includeInNav;
        }

        myronSearchIndexer()->indexPageById($pageId);
        return $result;
    }

    /**
     * @return array{pageId:string,includeInNav:bool}
     */
    public function setNavVisibility(string $pageId, bool $includeInNav, MyronActionCallerContext $caller): array
    {
        $pageId = trim($pageId);
        if ($pageId === '') {
            throw new InvalidArgumentException('pageId is required.');
        }
        if (!is_array($this->repository->getEditablePage($pageId))) {
            throw new MyronActionPreconditionError('Page not found: ' . $pageId);
        }

        $this->repository->setPageIncludeInNav($pageId, $includeInNav);
        myronSearchIndexer()->indexPageById($pageId);

        return [
            'pageId' => $pageId,
            'includeInNav' => $includeInNav,
        ];
    }

    /**
     * @param list<string> $categoryIds
     * @return array{pageId:string,categoryIds:list<string>,categories:list<array<string,mixed>>}
     */
    public function setCategories(string $pageId, array $categoryIds, MyronActionCallerContext $caller): array
    {
        $pageId = trim($pageId);
        if ($pageId === '') {
            throw new InvalidArgumentException('pageId is required.');
        }
        if (!is_array($this->repository->getEditablePage($pageId))) {
            throw new MyronActionPreconditionError('Page not found: ' . $pageId);
        }
        $ids = $this->normalizeStringList($categoryIds);
        $this->repository->replacePageCategoryAssignments($pageId, $ids);
        myronSearchIndexer()->indexPageById($pageId);

        return [
            'pageId' => $pageId,
            'categoryIds' => $ids,
            'categories' => $this->repository->listCategoriesForPage($pageId, true),
        ];
    }

    /**
     * @return array{pageId:string,categoryIds:list<string>,categories:list<array<string,mixed>>}
     */
    public function getCategories(string $pageId, MyronActionCallerContext $caller): array
    {
        $pageId = trim($pageId);
        if ($pageId === '') {
            throw new InvalidArgumentException('pageId is required.');
        }
        if (!is_array($this->repository->getEditablePage($pageId))) {
            throw new MyronActionPreconditionError('Page not found: ' . $pageId);
        }

        return [
            'pageId' => $pageId,
            'categoryIds' => $this->repository->listCategoryIdsForPage($pageId),
            'categories' => $this->repository->listCategoriesForPage($pageId, true),
        ];
    }

    /**
     * @param array<string, mixed> $author
     * @return array{pageId:string,author:array<string,string>}
     */
    public function setAuthor(string $pageId, array $author, MyronActionCallerContext $caller): array
    {
        $pageId = trim($pageId);
        if ($pageId === '') {
            throw new InvalidArgumentException('pageId is required.');
        }
        $page = $this->repository->getEditablePage($pageId);
        if (!is_array($page)) {
            throw new MyronActionPreconditionError('Page not found: ' . $pageId);
        }

        $content = $this->withPostAuthor(is_array($page['content'] ?? null) ? (array) $page['content'] : [], $author);
        $this->repository->savePage(
            $pageId,
            (string) ($page['template_id'] ?? ''),
            (string) ($page['title'] ?? ''),
            (string) ($page['slug'] ?? ''),
            isset($page['parent_page_id']) ? (string) $page['parent_page_id'] : null,
            (string) ($page['seo_title'] ?? ''),
            (string) ($page['meta_description'] ?? ''),
            $content,
            false,
            null,
            $this->seoOverridesFromPage($page)
        );
        myronSearchIndexer()->indexPageById($pageId);

        return ['pageId' => $pageId, 'author' => $this->postAuthorFromContent($content)];
    }

    /**
     * @return array{pageId:string,author:array<string,string>}
     */
    public function getAuthor(string $pageId, MyronActionCallerContext $caller): array
    {
        $pageId = trim($pageId);
        if ($pageId === '') {
            throw new InvalidArgumentException('pageId is required.');
        }
        $page = $this->repository->getEditablePage($pageId);
        if (!is_array($page)) {
            throw new MyronActionPreconditionError('Page not found: ' . $pageId);
        }

        return [
            'pageId' => $pageId,
            'author' => $this->postAuthorFromContent(is_array($page['content'] ?? null) ? (array) $page['content'] : []),
        ];
    }

    /**
     * Publish an existing page (saves the current draft as published).
     *
     * @return array{pageId: string, publishedAt: string}
     */
    public function publish(string $pageId, ?string $publishedAt, MyronActionCallerContext $caller): array
    {
        $pageId = trim($pageId);
        if ($pageId === '') {
            throw new InvalidArgumentException('pageId is required.');
        }
        $page = $this->repository->getEditablePage($pageId);
        if (!is_array($page)) {
            throw new MyronActionPreconditionError('Page not found: ' . $pageId);
        }
        // Re-save the page with publishNow=true; preserves all current draft state.
        $this->repository->savePage(
            $pageId,
            (string) ($page['template_id'] ?? ''),
            (string) ($page['title'] ?? ''),
            (string) ($page['slug'] ?? ''),
            isset($page['parent_page_id']) ? (string) $page['parent_page_id'] : null,
            (string) ($page['seo_title'] ?? ''),
            (string) ($page['meta_description'] ?? ''),
            is_array($page['content'] ?? null) ? (array) $page['content'] : [],
            true,
            $publishedAt,
            $this->seoOverridesFromPage($page)
        );
        myronSearchIndexer()->indexPageById($pageId);
        return ['pageId' => $pageId, 'publishedAt' => $publishedAt ?? myronNow()];
    }

    /**
     * Unpublish an existing page (mark as draft; preserves draft content).
     *
     * @return array{pageId: string, publicationState: string}
     */
    public function unpublish(string $pageId, MyronActionCallerContext $caller): array
    {
        $pageId = trim($pageId);
        if ($pageId === '') {
            throw new InvalidArgumentException('pageId is required.');
        }
        $page = $this->repository->getEditablePage($pageId);
        if (!is_array($page)) {
            throw new MyronActionPreconditionError('Page not found: ' . $pageId);
        }
        $this->repository->savePage(
            $pageId,
            (string) ($page['template_id'] ?? ''),
            (string) ($page['title'] ?? ''),
            (string) ($page['slug'] ?? ''),
            isset($page['parent_page_id']) ? (string) $page['parent_page_id'] : null,
            (string) ($page['seo_title'] ?? ''),
            (string) ($page['meta_description'] ?? ''),
            is_array($page['content'] ?? null) ? (array) $page['content'] : [],
            false,
            null,
            $this->seoOverridesFromPage($page)
        );
        myronSearchIndexer()->removePageById($pageId);
        return ['pageId' => $pageId, 'publicationState' => 'draft'];
    }

    /**
     * @param array<string, mixed> $seoOverrides
     * @return array{pageId:string, updated:bool}
     */
    public function saveSeo(string $pageId, array $seoOverrides, MyronActionCallerContext $caller): array
    {
        $pageId = trim($pageId);
        if ($pageId === '') {
            throw new InvalidArgumentException('pageId is required.');
        }
        $page = $this->repository->getEditablePage($pageId);
        if (!is_array($page)) {
            throw new MyronActionPreconditionError('Page not found: ' . $pageId);
        }
        $this->repository->savePage(
            $pageId,
            (string) ($page['template_id'] ?? ''),
            (string) ($page['title'] ?? ''),
            (string) ($page['slug'] ?? ''),
            isset($page['parent_page_id']) ? (string) $page['parent_page_id'] : null,
            (string) ($page['seo_title'] ?? ''),
            (string) ($page['meta_description'] ?? ''),
            is_array($page['content'] ?? null) ? (array) $page['content'] : [],
            false,
            null,
            array_merge($this->seoOverridesFromPage($page), $seoOverrides)
        );
        return ['pageId' => $pageId, 'updated' => true];
    }

    /**
     * Delete a page.
     *
     * @return array{pageId: string, deleted: bool, title: string}
     */
    public function delete(string $pageId, MyronActionCallerContext $caller): array
    {
        $pageId = trim($pageId);
        if ($pageId === '') {
            throw new InvalidArgumentException('pageId is required.');
        }
        $deleted = $this->repository->deletePage($pageId);
        myronSearchIndexer()->removePageById($pageId);
        return [
            'pageId'  => $pageId,
            'deleted' => true,
            'title'   => (string) ($deleted['title'] ?? ''),
        ];
    }

    /**
     * @param array<string, mixed> $page
     * @return array<string, mixed>
     */
    private function seoOverridesFromPage(array $page): array
    {
        $fields = [
            'canonical_override',
            'robots_override',
            'og_title',
            'og_description',
            'og_image',
            'twitter_title',
            'twitter_description',
            'twitter_image',
            'jsonld_mode',
            'jsonld_type',
            'jsonld_custom',
            'indexable_override',
        ];
        $out = [];
        foreach ($fields as $field) {
            $out[$field] = $page[$field] ?? '';
        }
        return $out;
    }

    /**
     * @param array<string, mixed> $content
     * @param array<string, mixed> $author
     * @return array<string, mixed>
     */
    private function withPostAuthor(array $content, array $author): array
    {
        $meta = is_array($content['_postMeta'] ?? null) ? (array) $content['_postMeta'] : [];
        $current = is_array($meta['author'] ?? null) ? (array) $meta['author'] : [];
        foreach (['authorName', 'authorUserId', 'avatarFileId', 'bio'] as $field) {
            if (array_key_exists($field, $author)) {
                $current[$field] = trim((string) $author[$field]);
            }
        }
        if (array_key_exists('name', $author) && !array_key_exists('authorName', $author)) {
            $current['authorName'] = trim((string) $author['name']);
        }
        if (array_key_exists('userId', $author) && !array_key_exists('authorUserId', $author)) {
            $current['authorUserId'] = trim((string) $author['userId']);
        }
        if (array_key_exists('avatar', $author) && !array_key_exists('avatarFileId', $author)) {
            $current['avatarFileId'] = trim((string) $author['avatar']);
        }
        $meta['author'] = $this->normalizePostAuthor($current);
        $content['_postMeta'] = $meta;

        return $content;
    }

    /**
     * @param array<string, mixed> $content
     * @return array<string, string>
     */
    private function postAuthorFromContent(array $content): array
    {
        $meta = is_array($content['_postMeta'] ?? null) ? (array) $content['_postMeta'] : [];
        return $this->normalizePostAuthor(is_array($meta['author'] ?? null) ? (array) $meta['author'] : []);
    }

    /**
     * @param array<string, mixed> $author
     * @return array<string, string>
     */
    private function normalizePostAuthor(array $author): array
    {
        return [
            'authorName' => trim((string) ($author['authorName'] ?? '')),
            'authorUserId' => trim((string) ($author['authorUserId'] ?? '')),
            'avatarFileId' => trim((string) ($author['avatarFileId'] ?? '')),
            'bio' => trim((string) ($author['bio'] ?? '')),
        ];
    }

    /**
     * @param array<int|string, mixed> $values
     * @return list<string>
     */
    private function normalizeStringList(array $values): array
    {
        return array_values(array_unique(array_filter(
            array_map(static fn (mixed $value): string => trim((string) $value), array_values($values)),
            static fn (string $value): bool => $value !== ''
        )));
    }

    private function nullableString(mixed $value): ?string
    {
        if ($value === null) {
            return null;
        }
        $string = trim((string) $value);
        return $string === '' ? null : $string;
    }

    private function normalizeBoolean(mixed $value): bool
    {
        if (is_bool($value)) {
            return $value;
        }

        $normalized = strtolower(trim((string) $value));
        return in_array($normalized, ['1', 'true', 'yes', 'on'], true);
    }

    /**
     * @param array<string, mixed> $base
     * @param array<string, mixed> $patch
     * @return array<string, mixed>
     */
    private function mergeRecursiveDistinct(array $base, array $patch): array
    {
        foreach ($patch as $key => $value) {
            if (is_array($value) && is_array($base[$key] ?? null) && !array_is_list($value) && !array_is_list((array) $base[$key])) {
                $base[$key] = $this->mergeRecursiveDistinct((array) $base[$key], $value);
                continue;
            }
            $base[$key] = $value;
        }

        return $base;
    }
}
