<?php

declare(strict_types=1);

/**
 * APO-195 Card 2 — Section Edit action service.
 *
 * Wraps the runtime-template editing surface (templates-manager.php section
 * mutations) so both admin form path AND API path operate on the same
 * MyronContentRepository methods. The service resolves the runtime template
 * by sourceTemplateId and the section list from the runtime template's
 * schema before applying each mutation — same shape the admin route does
 * inline.
 *
 * Verbs (9): create-runtime-draft, insert-section, move-section, remove-section,
 * update-section-editability, update-section-design,
 * update-template-section-content, save-section-variant, apply-section-variant.
 */
final class MyronSectionEditActionService
{
    public function __construct(
        private readonly MyronContentRepository $repository,
        private readonly MyronTemplateDefinitionRepository $templateDefinitions,
        private readonly MyronSectionRegistryRepository $sectionRegistry,
        private readonly MyronSectionVariantRepository $variants,
        private readonly MyronComponentRegistryRepository $components,
        private readonly MyronSectionContentSchemaService $schema,
    ) {
    }

    /**
     * Create or resolve an editable runtime template target.
     *
     * @return array{sourceTemplateId: string, runtimeTemplateId: string}
     */
    public function createRuntimeDraft(string $sourceTemplateId, MyronActionCallerContext $caller): array
    {
        $sourceTemplateId = trim($sourceTemplateId);
        if ($sourceTemplateId === '') {
            throw new InvalidArgumentException('sourceTemplateId is required.');
        }
        $existingRuntime = $this->repository->findRuntimeTemplateByIdOrSourceTemplateId($sourceTemplateId);
        if (is_array($existingRuntime) && $this->isCustomSiteTemplate($existingRuntime)) {
            return [
                'sourceTemplateId'  => (string) ($existingRuntime['source_template_id'] ?? $sourceTemplateId),
                'runtimeTemplateId' => (string) ($existingRuntime['id'] ?? ''),
            ];
        }

        $definition = $this->templateDefinitions->findTemplateById($sourceTemplateId);
        if (!is_array($definition)) {
            throw new MyronActionPreconditionError('Source template not found: ' . $sourceTemplateId);
        }
        if (!function_exists('myronTemplateInstantiationService')) {
            throw new MyronActionPreconditionError('Template instantiation service is unavailable.');
        }
        $runtime = (array) myronTemplateInstantiationService()->instantiateTemplate($definition);
        return [
            'sourceTemplateId'   => $sourceTemplateId,
            'runtimeTemplateId'  => (string) ($runtime['id'] ?? ''),
        ];
    }

    /**
     * Insert a section at a position in the runtime draft.
     *
     * @return array{sourceTemplateId: string, position: int, sectionKey: string}
     */
    public function insertSection(
        string $sourceTemplateId,
        string $sectionKey,
        int $insertAt,
        MyronActionCallerContext $caller
    ): array {
        $sourceTemplateId = trim($sourceTemplateId);
        $sectionKey       = trim($sectionKey);
        if ($sourceTemplateId === '' || $sectionKey === '') {
            throw new InvalidArgumentException('sourceTemplateId and sectionKey are required.');
        }
        $this->sectionRegistry->assertGovernedSectionKey($sectionKey, $sourceTemplateId);
        if (in_array($sectionKey, MyronSectionRegistryRepository::forbiddenBodySectionKeys(), true)) {
            throw new InvalidArgumentException('Chrome and primitive-only components cannot be inserted as body sections.');
        }
        $sectionRecord = $this->sectionRegistry->findSectionByKey($sectionKey);
        if (!is_array($sectionRecord)) {
            throw new InvalidArgumentException('Unknown governed section.');
        }
        $primaryComponents = array_values(array_map('strval', is_array($sectionRecord['primaryComponents'] ?? null) ? $sectionRecord['primaryComponents'] : []));
        $componentId = trim((string) ($primaryComponents[0] ?? $sectionKey));
        if ($componentId === '' || in_array($componentId, MyronSectionRegistryRepository::forbiddenBodySectionKeys(), true)) {
            throw new InvalidArgumentException('The selected section cannot be inserted into the body composition.');
        }
        if (!is_array($this->components->findComponentById($componentId))) {
            throw new InvalidArgumentException('The selected section is missing a valid render component bridge.');
        }
        $runtime = $this->resolveRuntime($sourceTemplateId);
        $sections = $this->sectionsFromRuntime($runtime);
        $defaultContentEditable = $sectionRecord['defaultContentEditable'] ?? null;
        $variant = trim((string) ($sectionRecord['variant'] ?? 'default'));
        $editingContract = is_array($sectionRecord['editing'] ?? null) ? $sectionRecord['editing'] : [];
        $defaultContent = is_array($sectionRecord['defaultContent'] ?? null) ? $sectionRecord['defaultContent'] : [];
        $insertAt = max(0, min(count($sections), $insertAt));
        $candidateSection = [
            'sectionKey'  => $sectionKey,
            'componentId' => $componentId,
            'variant'     => $variant !== '' ? $variant : 'default',
            'source'      => 'api',
        ];
        $contractedSection = $this->schema->hasComponentContract($candidateSection, $defaultContent);
        $resolvedEditable = $contractedSection
            ? true
            : ($defaultContentEditable === null ? false : (bool) $defaultContentEditable);
        $inserted = $candidateSection + [
            'editing'     => [
                'contentEditable' => $resolvedEditable,
            ],
        ];
        if ($editingContract !== []) {
            $inserted['editing'] = array_merge($inserted['editing'], $editingContract);
            $inserted['editing']['contentEditable'] = $resolvedEditable;
        }
        $inserted['sectionUuid'] = myronSectionUuid($inserted, $insertAt);
        $instanceKey = myronSectionInstanceKey($inserted, $insertAt);

        array_splice($sections, $insertAt, 0, [$inserted]);
        $this->repository->updateRuntimeTemplateSections((string) $runtime['id'], $sections);
        if ($defaultContent !== []) {
            $schema = is_array($runtime['schema'] ?? null) ? $runtime['schema'] : [];
            $content = is_array($schema['defaultContent'] ?? null) ? $schema['defaultContent'] : [];
            $content['sections'] = is_array($content['sections'] ?? null) ? $content['sections'] : [];
            $content['sections'][$instanceKey] = $defaultContent;
            $content['_schemaVersion'] = 2;
            $this->repository->updateRuntimeTemplateDefaultContent((string) $runtime['id'], $content);
        }
        return [
            'sourceTemplateId' => $sourceTemplateId,
            'position'         => $insertAt,
            'sectionKey'       => $sectionKey,
        ];
    }

    /**
     * Move a section up or down by one position.
     *
     * @return array{sourceTemplateId: string, position: int, newPosition: int}
     */
    public function moveSection(string $sourceTemplateId, int $position, string $direction, MyronActionCallerContext $caller): array
    {
        if (!in_array($direction, ['up', 'down'], true)) {
            throw new InvalidArgumentException('direction must be "up" or "down".');
        }
        $runtime = $this->resolveRuntime($sourceTemplateId);
        $sections = $this->sectionsFromRuntime($runtime);
        if (!isset($sections[$position])) {
            throw new InvalidArgumentException('Invalid position: ' . $position);
        }
        $target = $direction === 'up' ? $position - 1 : $position + 1;
        if (!isset($sections[$target])) {
            return ['sourceTemplateId' => $sourceTemplateId, 'position' => $position, 'newPosition' => $position];
        }
        [$sections[$position], $sections[$target]] = [$sections[$target], $sections[$position]];
        $this->repository->updateRuntimeTemplateSections((string) $runtime['id'], $sections);
        return [
            'sourceTemplateId' => $sourceTemplateId,
            'position'         => $position,
            'newPosition'      => $target,
        ];
    }

    /**
     * Remove one runtime-template section by exactly one stable selector.
     *
     * Page-version content is intentionally retained for rollback. Only the
     * matching template-default content entry is removed.
     *
     * @return array{sourceTemplateId:string,removedPosition:int,removedSectionUuid:string,removedInstanceKey:string,remainingCount:int}
     */
    public function removeSection(
        string $sourceTemplateId,
        ?int $position,
        ?string $sectionUuid,
        MyronActionCallerContext $caller
    ): array {
        $sourceTemplateId = trim($sourceTemplateId);
        $sectionUuid = trim((string) $sectionUuid);
        $hasPosition = $position !== null;
        $hasSectionUuid = $sectionUuid !== '';
        if ($sourceTemplateId === '') {
            throw new InvalidArgumentException('sourceTemplateId is required.');
        }
        if ($hasPosition === $hasSectionUuid) {
            throw new InvalidArgumentException('Provide exactly one section selector: position or sectionUuid.');
        }
        if ($hasPosition && $position < 0) {
            throw new InvalidArgumentException('Invalid position: ' . $position);
        }

        $runtime = $this->resolveRuntime($sourceTemplateId);
        $sections = $this->sectionsFromRuntime($runtime);
        $resolvedPosition = $position;
        if ($hasSectionUuid) {
            $matches = [];
            foreach ($sections as $candidatePosition => $candidate) {
                $candidateUuid = trim((string) ($candidate['sectionUuid'] ?? $candidate['sectionUUID'] ?? ''));
                if ($candidateUuid === '') {
                    $candidateUuid = myronSectionUuid($candidate, (int) $candidatePosition);
                }
                if (hash_equals($candidateUuid, $sectionUuid)) {
                    $matches[] = (int) $candidatePosition;
                }
            }
            if (count($matches) !== 1) {
                throw new InvalidArgumentException(count($matches) === 0
                    ? 'Unknown sectionUuid: ' . $sectionUuid
                    : 'sectionUuid must identify exactly one section.');
            }
            $resolvedPosition = $matches[0];
        }
        if ($resolvedPosition === null || !isset($sections[$resolvedPosition])) {
            throw new InvalidArgumentException('Invalid position: ' . (string) $resolvedPosition);
        }

        $removed = $sections[$resolvedPosition];
        $removedSectionUuid = trim((string) ($removed['sectionUuid'] ?? $removed['sectionUUID'] ?? ''));
        if ($removedSectionUuid === '') {
            $removedSectionUuid = myronSectionUuid($removed, $resolvedPosition);
        }
        $removedInstanceKey = myronSectionInstanceKey($removed, $resolvedPosition);
        $schema = is_array($runtime['schema'] ?? null) ? $runtime['schema'] : [];
        $defaultContent = is_array($schema['defaultContent'] ?? null) ? $schema['defaultContent'] : [];
        $defaultSections = is_array($defaultContent['sections'] ?? null) ? $defaultContent['sections'] : [];

        array_splice($sections, $resolvedPosition, 1);
        unset($defaultSections[$removedInstanceKey]);
        $defaultContent['sections'] = $defaultSections;
        $defaultContent['_schemaVersion'] = 2;
        $this->repository->updateRuntimeTemplateComposition((string) $runtime['id'], $sections, $defaultContent);

        return [
            'sourceTemplateId' => $sourceTemplateId,
            'removedPosition' => $resolvedPosition,
            'removedSectionUuid' => $removedSectionUuid,
            'removedInstanceKey' => $removedInstanceKey,
            'remainingCount' => count($sections),
        ];
    }

    /**
     * Duplicate a section instance and insert the copy next to the source section.
     *
     * @return array{sourceTemplateId: string, position: int, newPosition: int, instanceKey: string}
     */
    public function duplicateSection(
        string $sourceTemplateId,
        int $position,
        ?int $insertAt,
        MyronActionCallerContext $caller
    ): array {
        $runtime = $this->resolveRuntime($sourceTemplateId);
        $sections = $this->sectionsFromRuntime($runtime);
        if (!isset($sections[$position])) {
            throw new InvalidArgumentException('Invalid position: ' . $position);
        }

        $schema = is_array($runtime['schema'] ?? null) ? $runtime['schema'] : [];
        $defaultContent = is_array($schema['defaultContent'] ?? null) ? $schema['defaultContent'] : [];
        $defaultSections = is_array($defaultContent['sections'] ?? null) ? $defaultContent['sections'] : [];
        [$sections, $defaultSections] = $this->stabilizeSectionInstanceKeys($sections, $defaultSections);

        $source = $sections[$position];
        $sourceInstanceKey = (string) ($source['instanceKey'] ?? myronSectionInstanceKey($source, $position));
        $copy = $source;
        unset($copy['sectionUuid'], $copy['sectionUUID']);
        $copy['source'] = 'runtime-template-duplicate';
        $copy['instanceKey'] = $this->uniqueInstanceKey($sections, $sourceInstanceKey . '-copy');

        $insertAt = $insertAt === null
            ? $position + 1
            : max(0, min(count($sections), $insertAt));
        array_splice($sections, $insertAt, 0, [$copy]);

        if (isset($defaultSections[$sourceInstanceKey]) && is_array($defaultSections[$sourceInstanceKey])) {
            $defaultSections[(string) $copy['instanceKey']] = $defaultSections[$sourceInstanceKey];
        }
        $defaultContent['sections'] = $defaultSections;
        $defaultContent['_schemaVersion'] = 2;

        $this->repository->updateRuntimeTemplateSections((string) $runtime['id'], $sections);
        $this->repository->updateRuntimeTemplateDefaultContent((string) $runtime['id'], $defaultContent);

        return [
            'sourceTemplateId' => $sourceTemplateId,
            'position'         => $position,
            'newPosition'      => $insertAt,
            'instanceKey'      => (string) $copy['instanceKey'],
        ];
    }

    /**
     * Reorder all section instances by their existing positions.
     *
     * @param list<int> $orderedPositions
     * @return array{sourceTemplateId: string, positions: list<int>}
     */
    public function reorderSections(string $sourceTemplateId, array $orderedPositions, MyronActionCallerContext $caller): array
    {
        $runtime = $this->resolveRuntime($sourceTemplateId);
        $sections = $this->sectionsFromRuntime($runtime);
        $count = count($sections);
        if ($count === 0) {
            throw new InvalidArgumentException('Runtime template has no sections to reorder.');
        }
        if (count($orderedPositions) !== $count) {
            throw new InvalidArgumentException('Section order must include every section exactly once.');
        }

        $expected = range(0, $count - 1);
        $normalized = array_values(array_map('intval', $orderedPositions));
        $sorted = $normalized;
        sort($sorted, SORT_NUMERIC);
        if ($sorted !== $expected) {
            throw new InvalidArgumentException('Section order contains missing, duplicate, or out-of-range positions.');
        }

        $schema = is_array($runtime['schema'] ?? null) ? $runtime['schema'] : [];
        $defaultContent = is_array($schema['defaultContent'] ?? null) ? $schema['defaultContent'] : [];
        $defaultSections = is_array($defaultContent['sections'] ?? null) ? $defaultContent['sections'] : [];
        [$sections, $defaultSections] = $this->stabilizeSectionInstanceKeys($sections, $defaultSections);

        $reordered = [];
        foreach ($normalized as $oldPosition) {
            $reordered[] = $sections[$oldPosition];
        }

        $defaultContent['sections'] = $defaultSections;
        $defaultContent['_schemaVersion'] = 2;

        $this->repository->updateRuntimeTemplateSections((string) $runtime['id'], $reordered);
        $this->repository->updateRuntimeTemplateDefaultContent((string) $runtime['id'], $defaultContent);

        return [
            'sourceTemplateId' => $sourceTemplateId,
            'positions'        => $normalized,
        ];
    }

    /**
     * @return array{sourceTemplateId: string, position: int, contentEditable: bool}
     */
    public function updateSectionEditability(
        string $sourceTemplateId,
        int $position,
        bool $contentEditable,
        MyronActionCallerContext $caller
    ): array {
        $runtime = $this->resolveRuntime($sourceTemplateId);
        $sections = $this->sectionsFromRuntime($runtime);
        if (!isset($sections[$position])) {
            throw new InvalidArgumentException('Invalid position: ' . $position);
        }
        $this->repository->updateRuntimeTemplateSectionEditability(
            (string) $runtime['id'],
            $position,
            $contentEditable
        );
        return [
            'sourceTemplateId' => $sourceTemplateId,
            'position'         => $position,
            'contentEditable'  => $contentEditable,
        ];
    }

    /**
     * @param array<string, mixed> $design
     * @return array{sourceTemplateId: string, position: int}
     */
    public function updateSectionDesign(
        string $sourceTemplateId,
        int $position,
        array $design,
        MyronActionCallerContext $caller
    ): array {
        $runtime = $this->resolveRuntime($sourceTemplateId);
        $sections = $this->sectionsFromRuntime($runtime);
        if (!isset($sections[$position])) {
            throw new InvalidArgumentException('Invalid position: ' . $position);
        }
        $componentId = (string) ($sections[$position]['componentId'] ?? '');
        $component = $this->components->findComponentById($componentId);
        $editableTokens = is_array($component) && is_array($component['editableTokens'] ?? null) ? $component['editableTokens'] : [];
        $mediaLayout = $this->normalizeHeroMediaLayout($design['mediaLayout'] ?? null);
        $isHeroSection = in_array($componentId, ['hero-simple', 'hero-with-cta'], true);
        if (!$isHeroSection && $mediaLayout !== 'auto') {
            throw new InvalidArgumentException('mediaLayout can only be set on hero sections.');
        }
        $patch = [
            'customCss'      => (string) ($design['customCss'] ?? ''),
            'tokenOverrides' => function_exists('myronNormalizeEditableTokenOverrideInput')
                ? myronNormalizeEditableTokenOverrideInput(is_array($design['tokenOverrides'] ?? null) ? $design['tokenOverrides'] : [], $editableTokens)
                : (is_array($design['tokenOverrides'] ?? null) ? (array) $design['tokenOverrides'] : []),
        ];
        if ($isHeroSection) {
            $patch['mediaLayout'] = $mediaLayout === 'auto' ? null : $mediaLayout;
        }
        $this->repository->updateRuntimeTemplateSectionDesign((string) $runtime['id'], $position, $patch);
        return [
            'sourceTemplateId' => $sourceTemplateId,
            'position'         => $position,
        ];
    }

    private function normalizeHeroMediaLayout(mixed $value): string
    {
        $layout = strtolower(trim((string) $value));
        $layout = preg_replace('/[^a-z0-9]+/', '-', $layout) ?? '';
        $layout = trim($layout, '-');

        return match ($layout) {
            'full-bleed', 'full-width', 'split' => $layout,
            '', 'auto' => 'auto',
            default => throw new InvalidArgumentException('mediaLayout must be auto, split, full-bleed, or full-width.'),
        };
    }

    /**
     * @param array<string, mixed> $contentByKey
     * @return array{sourceTemplateId: string, sectionsUpdated: int}
     */
    public function updateTemplateSectionContent(
        string $sourceTemplateId,
        array $contentByKey,
        MyronActionCallerContext $caller
    ): array {
        $runtime = $this->resolveRuntime($sourceTemplateId);
        $this->repository->updateRuntimeTemplateDefaultContent((string) $runtime['id'], $contentByKey);
        return [
            'sourceTemplateId' => $sourceTemplateId,
            'sectionsUpdated'  => count($contentByKey),
        ];
    }

    /**
     * @return array{variantId: string}
     */
    public function saveSectionAsVariant(
        string $sourceTemplateId,
        int $position,
        string $variantName,
        MyronActionCallerContext $caller
    ): array {
        $runtime = $this->resolveRuntime($sourceTemplateId);
        $sections = $this->sectionsFromRuntime($runtime);
        if (!isset($sections[$position])) {
            throw new InvalidArgumentException('Invalid position: ' . $position);
        }
        $schema = is_array($runtime['schema'] ?? null) ? $runtime['schema'] : [];
        $defaultContent = is_array($schema['defaultContent'] ?? null) ? $schema['defaultContent'] : [];
        $content = function_exists('myronSectionContentForInstance')
            ? (array) myronSectionContentForInstance($defaultContent, $sections[$position], $position)
            : $defaultContent;
        $userId = $caller->callerType === 'human-session' ? $caller->callerId : '';
        $saved = $this->variants->saveFromSection($variantName, $sections[$position], $content, $userId);
        return ['variantId' => (string) ($saved['id'] ?? '')];
    }

    /**
     * @return array{sourceTemplateId: string, position: int, appliedVariantId: string}
     */
    public function applySectionVariant(
        string $sourceTemplateId,
        int $position,
        string $variantId,
        MyronActionCallerContext $caller
    ): array {
        $runtime = $this->resolveRuntime($sourceTemplateId);
        $sections = $this->sectionsFromRuntime($runtime);
        if (!isset($sections[$position])) {
            throw new InvalidArgumentException('Invalid position: ' . $position);
        }
        $savedVariant = $this->variants->find($variantId);
        if (!is_array($savedVariant)) {
            throw new InvalidArgumentException('Saved variant not found: ' . $variantId);
        }
        $updated = $this->variants->applyToSection($sections[$position], $savedVariant);
        $sections[$position] = $updated;
        $this->repository->updateRuntimeTemplateSections((string) $runtime['id'], $sections);
        return [
            'sourceTemplateId' => $sourceTemplateId,
            'position'         => $position,
            'appliedVariantId' => $variantId,
        ];
    }

    /**
     * @return array<string, mixed>
     */
    private function resolveRuntime(string $sourceTemplateId): array
    {
        $runtime = $this->repository->findRuntimeTemplateByIdOrSourceTemplateId($sourceTemplateId);
        if (!is_array($runtime)) {
            throw new MyronActionPreconditionError('Create an editable runtime template draft before changing sections.');
        }
        return $runtime;
    }

    /**
     * @param array<string, mixed> $runtime
     */
    private function isCustomSiteTemplate(array $runtime): bool
    {
        if ((bool) ($runtime['is_custom_site_template'] ?? false)) {
            return true;
        }
        $schema = is_array($runtime['schema'] ?? null) ? $runtime['schema'] : [];
        $lifecycle = is_array($schema['templateLifecycle'] ?? null) ? $schema['templateLifecycle'] : [];
        return (string) ($lifecycle['kind'] ?? '') === 'custom_site_template';
    }

    /**
     * @param array<string, mixed> $runtime
     * @return list<array<string, mixed>>
     */
    private function sectionsFromRuntime(array $runtime): array
    {
        $schema = is_array($runtime['schema'] ?? null) ? $runtime['schema'] : [];
        return array_values(array_filter(
            is_array($schema['sections'] ?? null) ? $schema['sections'] : [],
            static fn (mixed $section): bool => is_array($section)
        ));
    }

    /**
     * @param list<array<string, mixed>> $sections
     * @param array<string, mixed> $defaultSections
     * @return array{0:list<array<string, mixed>>,1:array<string, mixed>}
     */
    private function stabilizeSectionInstanceKeys(array $sections, array $defaultSections): array
    {
        $stableSections = [];
        $stableContent = [];
        $usedKeys = [];

        foreach ($sections as $position => $section) {
            $oldKey = myronSectionInstanceKey($section, (int) $position);
            $explicit = trim((string) ($section['instanceKey'] ?? $section['slotKey'] ?? ''));
            $newKey = $explicit !== '' ? $explicit : $oldKey;
            $newKey = $this->uniqueInstanceKeyFromSet($usedKeys, $newKey);
            $usedKeys[$newKey] = true;

            $section['instanceKey'] = $newKey;
            $section['sectionUuid'] = myronSectionUuid($section, (int) $position);
            $stableSections[] = $section;

            if (isset($defaultSections[$oldKey]) && is_array($defaultSections[$oldKey])) {
                $stableContent[$newKey] = $defaultSections[$oldKey];
                continue;
            }
            if ($explicit !== '' && isset($defaultSections[$explicit]) && is_array($defaultSections[$explicit])) {
                $stableContent[$newKey] = $defaultSections[$explicit];
            }
        }

        foreach ($defaultSections as $key => $value) {
            if (!isset($stableContent[(string) $key]) && is_array($value)) {
                $stableContent[(string) $key] = $value;
            }
        }

        return [$stableSections, $stableContent];
    }

    /**
     * @param list<array<string, mixed>> $sections
     */
    private function uniqueInstanceKey(array $sections, string $candidate): string
    {
        $used = [];
        foreach ($sections as $section) {
            $key = trim((string) ($section['instanceKey'] ?? $section['slotKey'] ?? ''));
            if ($key !== '') {
                $used[$key] = true;
            }
        }

        return $this->uniqueInstanceKeyFromSet($used, $candidate);
    }

    /**
     * @param array<string, bool> $used
     */
    private function uniqueInstanceKeyFromSet(array $used, string $candidate): string
    {
        $base = myronNormalizeSlug($candidate);
        $base = $base !== '' ? $base : 'section-copy';
        $key = $base;
        $index = 2;
        while (isset($used[$key])) {
            $key = $base . '-' . $index;
            $index++;
        }

        return $key;
    }
}
