<?php

declare(strict_types=1);

/**
 * MyronFooterContentSeedService — APO-179
 *
 * Loads per-variant generic footer content seeds and per-site-type overlay seeds
 * from app/data/runtime-definitions/footer-content-seeds/, then merges them so a
 * fresh seeded site renders a complete-looking footer the operator iterates on
 * rather than authors from scratch.
 *
 * Seed files:
 *   - Generic:  footer.{variant}.json                     (one per of 10 variants)
 *   - Overlay:  footer.{variant}.{site-type-suffix}.json  (one per (variant, site-type) default pair, 13 total)
 *
 * Audience-first class: this service is Class 2 (runtime substrate; ships; AI-accessible
 * via service locator). The seed files it loads are Class 1 (operator-actionable runtime
 * data; they ship; the operator iterates on the values via the Configure side sheet).
 *
 * Token interpolation note: this service is NOT responsible for token interpolation.
 * It returns the raw merged content with literal tokens like {{site_settings.site_title}}
 * preserved. Render-time interpolation lives in DefinitionTemplateRenderer where the
 * site settings + current year context are already resolved.
 */
final class MyronFooterContentSeedService
{
    private string $seedsDir;

    public function __construct(?string $seedsDir = null)
    {
        $this->seedsDir = $seedsDir
            ?? dirname(__DIR__, 3) . '/data/runtime-definitions/footer-content-seeds';
    }

    /**
     * Returns the merged zone-content map for a (siteType, variant) pair.
     *
     * Merge rules:
     *   1. Load per-variant generic seed (if it exists). If not present, return [].
     *   2. If the variant is the site-type's default variant AND an overlay exists,
     *      apply the overlay on top of generic with field-level merge (per zone).
     *   3. Non-default variants: return generic only — no overlay applied (D1 contract).
     *
     * @return array<string, array<string, mixed>>
     */
    public function seedContentForSiteTypeAndVariant(string $siteTypeId, string $variantId): array
    {
        $generic = $this->loadGenericSeedFor($variantId);
        $overlay = $this->loadOverlaySeedFor($variantId, $siteTypeId);

        if ($overlay === null) {
            return $generic;
        }

        return $this->mergeOverlayIntoGeneric($generic, $overlay);
    }

    /**
     * Loads a per-variant generic seed file's zoneContent (or [] if missing).
     *
     * @return array<string, array<string, mixed>>
     */
    public function loadGenericSeedFor(string $variantId): array
    {
        $variantId = trim($variantId);
        if ($variantId === '') {
            return [];
        }

        $path = $this->seedsDir . '/' . $variantId . '.json';
        $payload = $this->readSeedFile($path);
        if ($payload === null) {
            return [];
        }

        if (($payload['scope'] ?? null) !== 'variant-generic') {
            return [];
        }

        $zoneContent = $payload['zoneContent'] ?? null;
        return is_array($zoneContent) ? $this->normalizeZoneContent($zoneContent) : [];
    }

    /**
     * Loads the (variant, site-type) overlay seed file's zoneContent (or null if no
     * overlay exists for this pair).
     *
     * Overlay file naming: footer.{variant}.{site-type-suffix}.json
     * where site-type-suffix is the last segment of the site-type id (e.g. 'biz' for
     * 'site-type.biz'). This keeps the on-disk filenames short while preserving an
     * unambiguous one-to-one mapping.
     *
     * @return array<string, array<string, mixed>>|null
     */
    public function loadOverlaySeedFor(string $variantId, string $siteTypeId): ?array
    {
        $variantId  = trim($variantId);
        $siteTypeId = trim($siteTypeId);
        if ($variantId === '' || $siteTypeId === '') {
            return null;
        }

        $suffix = $this->siteTypeSuffix($siteTypeId);
        if ($suffix === '') {
            return null;
        }

        $path    = $this->seedsDir . '/' . $variantId . '.' . $suffix . '.json';
        $payload = $this->readSeedFile($path);
        if ($payload === null) {
            return null;
        }

        if (($payload['scope'] ?? null) !== 'site-type-overlay') {
            return null;
        }
        if (($payload['variantId'] ?? null) !== $variantId) {
            return null;
        }
        if (($payload['siteTypeId'] ?? null) !== $siteTypeId) {
            return null;
        }

        $zoneContent = $payload['zoneContent'] ?? null;
        return is_array($zoneContent) ? $this->normalizeZoneContent($zoneContent) : [];
    }

    /**
     * Lists every seed file present in the seeds directory, parsed and grouped by
     * scope. Used by the verifier and for diagnostic listing.
     *
     * @return array{generic:array<string,array<string,mixed>>,overlays:list<array<string,mixed>>}
     */
    public function inventory(): array
    {
        $generic  = [];
        $overlays = [];

        if (!is_dir($this->seedsDir)) {
            return ['generic' => $generic, 'overlays' => $overlays];
        }

        $entries = scandir($this->seedsDir) ?: [];
        foreach ($entries as $entry) {
            if (!str_ends_with($entry, '.json')) {
                continue;
            }
            $payload = $this->readSeedFile($this->seedsDir . '/' . $entry);
            if ($payload === null) {
                continue;
            }

            $scope = $payload['scope'] ?? null;
            if ($scope === 'variant-generic') {
                $variant = (string) ($payload['variantId'] ?? '');
                if ($variant !== '') {
                    $generic[$variant] = $payload;
                }
            } elseif ($scope === 'site-type-overlay') {
                $overlays[] = $payload;
            }
        }

        return ['generic' => $generic, 'overlays' => $overlays];
    }

    public function seedsDirectory(): string
    {
        return $this->seedsDir;
    }

    /**
     * APO-180 Card 6 — fast detection for whether the install has already
     * been backfilled. Used by the bootstrap hook to short-circuit on every
     * boot without paying the full backfill cost. Returns true if any active
     * zone of the default footer variant is currently empty.
     */
    public function needsBackfill(
        MyronFooterDesignRepository $footerRepo,
        MyronFooterVariantCompositionService $composition
    ): bool {
        try {
            $default = $footerRepo->getDefault();
        } catch (Throwable) {
            return false;
        }
        $variantId = (string) ($default['variant_id'] ?? '');
        if ($variantId === '') {
            return false;
        }
        $activeZones = $composition->orderedZonesFor($variantId);
        if ($activeZones === []) {
            return false;
        }
        $content = is_array($default['zone_content'] ?? null) ? $default['zone_content'] : [];
        foreach ($activeZones as $zoneId) {
            $zone = is_array($content[$zoneId] ?? null) ? $content[$zoneId] : [];
            if (!$this->zoneHasContent($zone)) {
                return true;
            }
        }
        return false;
    }

    /**
     * APO-180 Card 6 — pre-existing install seed application.
     *
     * For every footer_designs row, fill any zone in the variant's activeZones
     * list whose current content is empty. Operator-edited zones (any zone with
     * non-empty fields) are left untouched. Field-level granularity within a
     * zone is also preserved: if zone.brand has site_name set but tagline empty,
     * only tagline is filled from the seed.
     *
     * Returns the number of zones (across all variants) that were filled.
     * Idempotent — running twice on a fully-seeded state returns 0.
     */
    public function backfillEmptyZonesForActiveSiteType(
        string $siteTypeId,
        MyronFooterDesignRepository $footerRepo,
        MyronFooterVariantCompositionService $composition,
        string $updatedByUserId = ''
    ): int {
        $siteTypeId = trim($siteTypeId);
        if ($siteTypeId === '') {
            return 0;
        }

        $filledCount = 0;
        foreach ($footerRepo->validVariants() as $variantId) {
            $merged = $this->seedContentForSiteTypeAndVariant($siteTypeId, $variantId);
            if ($merged === []) {
                continue;
            }
            $activeZones = $composition->orderedZonesFor($variantId);
            if ($activeZones === []) {
                continue;
            }

            $row = $footerRepo->getByVariantId($variantId);
            $existingContent = is_array($row['zone_content'] ?? null) ? $row['zone_content'] : [];
            $existingVisibility = is_array($row['zone_visibility'] ?? null) ? $row['zone_visibility'] : [];

            $merged = $this->applyFieldLevelFallthrough($existingContent, $merged, $activeZones, $filledCount);

            if ($filledCount === 0 && $existingContent === $merged) {
                // Nothing to write for this variant.
                continue;
            }

            $footerRepo->saveConfiguration($variantId, $existingVisibility, $merged, $updatedByUserId);
        }

        return $filledCount;
    }

    /**
     * APO-180 Card 6 — overwrite a single zone's content with the merged seed.
     * Operator-facing "Apply defaults" affordance per zone.
     */
    public function applyDefaultsForZone(
        string $siteTypeId,
        string $variantId,
        string $zoneId,
        MyronFooterDesignRepository $footerRepo,
        string $updatedByUserId = ''
    ): bool {
        $siteTypeId = trim($siteTypeId);
        $zoneId = trim($zoneId);
        if ($zoneId === '') {
            return false;
        }
        $merged = $this->seedContentForSiteTypeAndVariant($siteTypeId, $variantId);
        if (!is_array($merged[$zoneId] ?? null)) {
            return false;
        }

        $row = $footerRepo->getByVariantId($variantId);
        $existingContent = is_array($row['zone_content'] ?? null) ? $row['zone_content'] : [];
        $existingVisibility = is_array($row['zone_visibility'] ?? null) ? $row['zone_visibility'] : [];

        $existingContent[$zoneId] = $merged[$zoneId];
        $footerRepo->saveConfiguration($variantId, $existingVisibility, $existingContent, $updatedByUserId);
        return true;
    }

    /**
     * Field-level fallthrough merge: for every zone in $activeZones, if a field
     * in the seed is missing from the existing content, copy it in. Existing
     * fields with non-empty values are preserved (operator-edit protection).
     *
     * Increments $filledCount per zone-field actually populated from the seed.
     *
     * @param array<string, array<string, mixed>> $existing
     * @param array<string, array<string, mixed>> $seed
     * @param list<string> $activeZones
     * @return array<string, array<string, mixed>>
     */
    private function applyFieldLevelFallthrough(array $existing, array $seed, array $activeZones, int &$filledCount): array
    {
        $merged = $existing;
        foreach ($activeZones as $zoneId) {
            if (!is_array($seed[$zoneId] ?? null)) {
                continue;
            }
            $existingZone = is_array($merged[$zoneId] ?? null) ? $merged[$zoneId] : [];
            $seedZone = $seed[$zoneId];

            // If the existing zone is entirely empty (no non-empty fields), apply
            // the whole seeded zone and count one fill for the zone — but only
            // when the seed itself contributes meaningful content. If both the
            // existing zone and the seed are effectively empty (e.g. a zone whose
            // only field is `platforms: []`), this is a no-op and we don't count
            // a fill (preserves idempotency).
            if (!$this->zoneHasContent($existingZone)) {
                if (!$this->zoneHasContent($seedZone)) {
                    // Still record the empty seed structure if existing has no
                    // entry for this zone at all (so the zone surfaces in the
                    // configure side sheet) but do not count a fill.
                    if (!isset($merged[$zoneId])) {
                        $merged[$zoneId] = $seedZone;
                    }
                    continue;
                }
                $merged[$zoneId] = $seedZone;
                $filledCount++;
                continue;
            }

            // Otherwise field-level fallthrough. Only fill from a meaningful
            // seed value — if both the existing and seed values are empty,
            // skip (this preserves idempotency: a second backfill is a no-op
            // because there's nothing left to fill).
            $zoneChanged = false;
            foreach ($seedZone as $fieldKey => $seedValue) {
                $existingFieldValue = $existingZone[$fieldKey] ?? null;
                $existingEmpty = $existingFieldValue === null || $this->fieldIsEmpty($existingFieldValue);
                $seedEmpty = $this->fieldIsEmpty($seedValue);
                if ($existingEmpty && !$seedEmpty) {
                    $existingZone[$fieldKey] = $seedValue;
                    $zoneChanged = true;
                }
            }
            if ($zoneChanged) {
                $merged[$zoneId] = $existingZone;
                $filledCount++;
            }
        }
        return $merged;
    }

    /**
     * @param array<string, mixed> $zone
     */
    private function zoneHasContent(array $zone): bool
    {
        if ($zone === []) {
            return false;
        }
        foreach ($zone as $value) {
            if (is_array($value)) {
                if ($value !== []) {
                    return true;
                }
                continue;
            }
            if (trim((string) $value) !== '') {
                return true;
            }
        }
        return false;
    }

    private function fieldIsEmpty(mixed $value): bool
    {
        if (is_array($value)) {
            return $value === [];
        }
        return trim((string) $value) === '';
    }

    /**
     * @return array<string, mixed>|null
     */
    private function readSeedFile(string $path): ?array
    {
        if (!is_file($path)) {
            return null;
        }
        $raw = (string) @file_get_contents($path);
        if ($raw === '') {
            return null;
        }
        $decoded = json_decode($raw, true);
        return is_array($decoded) ? $decoded : null;
    }

    private function siteTypeSuffix(string $siteTypeId): string
    {
        $segments = explode('.', $siteTypeId);
        $suffix   = trim((string) end($segments));
        return $suffix !== $siteTypeId ? $suffix : '';
    }

    /**
     * @param array<string, array<string, mixed>> $generic
     * @param array<string, array<string, mixed>> $overlay
     * @return array<string, array<string, mixed>>
     */
    private function mergeOverlayIntoGeneric(array $generic, array $overlay): array
    {
        $merged = $generic;
        foreach ($overlay as $zoneId => $overlayZoneContent) {
            if (!is_array($overlayZoneContent)) {
                continue;
            }
            $base = is_array($merged[$zoneId] ?? null) ? $merged[$zoneId] : [];
            // Field-level shallow merge within zone: overlay's fields replace generic's
            // fields by key. Arrays at the field level (links, badges, platforms, hours,
            // methods) replace wholesale rather than concatenate, which matches operator
            // intent ("the BIZ nav column has these links, not the generic ones plus mine").
            foreach ($overlayZoneContent as $fieldKey => $fieldValue) {
                $base[(string) $fieldKey] = $fieldValue;
            }
            $merged[$zoneId] = $base;
        }
        return $merged;
    }

    /**
     * @param array<string, mixed> $zoneContent
     * @return array<string, array<string, mixed>>
     */
    private function normalizeZoneContent(array $zoneContent): array
    {
        $normalized = [];
        foreach ($zoneContent as $zoneId => $value) {
            if (is_array($value)) {
                $normalized[trim((string) $zoneId)] = $value;
            }
        }
        return $normalized;
    }
}
