import { parseFrame } from '../media/imageFrame';

export interface SerializedContent {
    sections?: Array<{
        id: string;
        type: string;
        fields: Record<string, unknown>;
    }>;
    [key: string]: unknown;
}

export class ContentSerializer {
    /**
     * `[data-section="header"]` alone must not force the sectioned path: every template
     * that reuses `components/_page_hero.html.twig` for its title gets that attribute
     * unconditionally (needed by `extractTitle()` below), including otherwise-flat
     * templates like About/Expertise. `serializeSectioned()` already excludes `header`
     * from the sections it builds, so a page whose *only* `[data-section]` element is the
     * header has no real sections and must fall back to `serializeFlat()` — otherwise its
     * flat fields (title aside) are silently dropped, saved as `{sections: []}`.
     */
    public serialize(): SerializedContent {
        const hasSections = document.querySelectorAll('[data-section]:not([data-section="header"])').length > 0;

        if (hasSections) {
            return this.serializeSectioned();
        } else {
            return this.serializeFlat();
        }
    }

    /**
     * Extracts the page title from the `header` section's editable title field, for
     * templates (like Actualité/ProjectDetail) where the title lives in a fixed
     * `data-section="header"` section rather than the repeatable `blocks` list. Returns
     * `null` if no such field exists (non-sectioned templates), so callers can omit `title`
     * from the save request rather than sending an empty string.
     */
    public extractTitle(): string | null {
        const headerSection = document.querySelector<HTMLElement>('[data-section="header"]');
        if (!headerSection) {
            return null;
        }

        // The title field convention: the `[data-section="header"]` element usually *is*
        // the editable title field itself (see templates/pages/actualite.html.twig, where
        // `<h1>` carries both `data-section="header"` and `data-editable="true"` directly),
        // but fall back to a descendant match for robustness against future markup changes.
        const titleField = this.matchesEditableTitle(headerSection)
            ? headerSection
            : headerSection.querySelector<HTMLElement>('[data-editable="true"][data-field="title"]');

        return titleField ? titleField.innerHTML.trim() : null;
    }

    private matchesEditableTitle(element: HTMLElement): boolean {
        return element.dataset.editable === 'true' && element.dataset.field === 'title';
    }

    /**
     * Walks the LIVE current DOM list of `[data-section]` block elements — including
     * client-added blocks (BlockListEditor clones) and excluding removed ones, in current
     * DOM order — and serializes each into `{id, type, fields}`.
     *
     * Two families of `[data-section]` elements are distinguished, by DOM position rather
     * than by a hardcoded list of "known" block types (so this stays valid for every
     * template reusing the "Repeatable Block Templates" pattern — Actualité, ProjectDetail,
     * any future one — without editing this file per template):
     * - `header` (fixed, single, not repeatable): excluded from `sections`/`blocks`, its
     *   title is surfaced via `extractTitle()` instead.
     * - Any `[data-section]` element that lives INSIDE the page's
     *   `[data-blocks-container="true"]` wrapper (`#actualite-blocks` / `#project-detail-blocks`,
     *   see BlockListEditor.ts): treated as a repeatable content block, included under
     *   `blocks`, in DOM order.
     * - Any other `[data-section]` element (e.g. a fixed `related-posts` footer section):
     *   kept in the legacy `sections` array, which is only actually returned when there is
     *   no blocks container on the page (non-repeatable sectioned templates, e.g. List) —
     *   unchanged pre-existing behavior for those templates.
     */
    private serializeSectioned(): SerializedContent {
        const blocksContainer = document.querySelector<HTMLElement>('[data-blocks-container="true"]');

        const blocks: NonNullable<SerializedContent['sections']> = [];
        const otherSections: NonNullable<SerializedContent['sections']> = [];

        document.querySelectorAll('[data-section]').forEach(section => {
            const htmlSection = section as HTMLElement;
            const sectionId = htmlSection.dataset.section!;

            if (sectionId === 'header') {
                // Fixed title section, not part of the repeatable blocks list.
                return;
            }

            const sectionType = htmlSection.dataset.sectionType || 'text_section';
            const fields = this.collectFields(htmlSection);

            if (sectionType === 'text-image-left' || sectionType === 'text-image-right') {
                fields.mobileImagePosition = htmlSection.dataset.mobileImagePosition || 'before';
            }

            const entry = { id: sectionId, type: sectionType, fields };

            if (blocksContainer && blocksContainer.contains(htmlSection)) {
                blocks.push(entry);
            } else {
                otherSections.push(entry);
            }
        });

        if (blocksContainer) {
            // Repeatable-blocks templates (Actualité's `{mainImage, blocks}`, ProjectDetail's
            // `{featuredImage, client, description, blocks}`, ...) also carry a handful of
            // top-level fields OUTSIDE the blocks container and outside any `[data-section]`
            // wrapper (e.g. Actualité's `mainImage`, ProjectDetail's `featuredImage`/`client`/
            // `description`/`category`) — collected generically here rather than hardcoded
            // per template.
            const topLevelFields = this.collectFields(document.body, { excludeWithinSection: true });

            return { ...topLevelFields, blocks };
        }

        // Non-repeatable sectioned templates (e.g. List): unchanged legacy shape.
        return { sections: otherSections };
    }

    /**
     * Matches the indexed-bracket "repeatable object list" field-name convention —
     * `name[0][label]`, `name[1][value]`, etc. — parsed into `fields.name = [{label: ...,
     * value: ...}, ...]` by array index (see decision (b) in
     * spec-driven-development/project-detail-redesign.md and
     * assets/admin/editor/RepeatableObjectListEditor.ts, which maintains the index
     * contiguity this parsing relies on). Unlike the `name[]` flat-array convention below
     * (order-independent "collect all scalars"), this one is order-*dependent*: index must
     * stay 0..N-1 contiguous, which RepeatableObjectListEditor guarantees by re-writing
     * every following item's `data-field` index on add/remove.
     */
    /**
     * `introImage` + `Dark` -> `introImageDark` ; `images[]` + `Dark` -> `imagesDark[]` ;
     * `items[2][image]` + `Dark` -> `items[2][imageDark]` (suffix goes inside the last bracket
     * so the result still matches `INDEXED_OBJECT_FIELD_PATTERN`).
     */
    private static suffixedFieldName(fieldName: string, suffix: string): string {
        if (fieldName.endsWith('[]')) return `${fieldName.slice(0, -2)}${suffix}[]`;
        if (ContentSerializer.INDEXED_OBJECT_FIELD_PATTERN.test(fieldName)) return `${fieldName.slice(0, -1)}${suffix}]`;
        return `${fieldName}${suffix}`;
    }

    private static readonly INDEXED_OBJECT_FIELD_PATTERN = /^([A-Za-z0-9_]+)\[(\d+)\]\[([A-Za-z0-9_]+)\]$/;

    /**
     * Assigns one collected `(fieldName, value)` pair into `fields`, dispatching on the
     * field-name convention:
     * - `name[index][subfield]` → `fields.name = [{subfield: value, ...}, ...]` (see
     *   `INDEXED_OBJECT_FIELD_PATTERN` above).
     * - `name[]` → `fields.name = [value, ...]` (flat scalar array, collects every element
     *   sharing this field name — see the gallery block / hero tag pills doc below).
     * - anything else → `fields.name = value` (plain scalar).
     */
    private assignField(fields: Record<string, unknown>, fieldName: string, value: unknown): void {
        const objectMatch = fieldName.match(ContentSerializer.INDEXED_OBJECT_FIELD_PATTERN);
        if (objectMatch) {
            const [, baseName, indexStr, subfield] = objectMatch;
            const index = Number(indexStr);

            const existing = fields[baseName!];
            const list: Array<Record<string, unknown>> = Array.isArray(existing) ? existing : [];
            list[index] = { ...(list[index] || {}), [subfield!]: value };
            fields[baseName!] = list;
            return;
        }

        if (fieldName.endsWith('[]')) {
            const key = fieldName.slice(0, -2);
            const existing = fields[key];
            const list: unknown[] = Array.isArray(existing) ? existing : [];
            list.push(value);
            fields[key] = list;
            return;
        }

        fields[fieldName] = value;
    }

    /**
     * Collects editable field values from within `scope` (a section element, or
     * `document.body` for top-level fields).
     *
     * `data-field="fieldName[]"` (trailing `[]`) means "collect every element sharing this
     * field name into a single array under `fieldName`" — used by
     * `data-editable-image="true"` fields (each also emits its `…Dark` Hiver variant from
     * `data-media-id-dark`, and its cadrage, see `suffixedFieldName()`): the ProjectDetail gallery block
     * (`components/_project-detail-block-gallery.html.twig`), which renders N independent
     * single-image picker slots (MediaSelectorModal has no native multi-select) rather than
     * a single bulk-select field.
     *
     * This keeps the resulting array regardless of N (including 0 or 1), unlike a
     * slot-count heuristic which would be ambiguous at N=1. See `assignField()` for the
     * `name[index][subfield]` convention (decision (b)), also dispatched through here.
     */
    private collectFields(
        scope: ParentNode,
        options: { excludeWithinSection?: boolean } = {}
    ): Record<string, unknown> {
        const fields: Record<string, unknown> = {};
        const isEligible = (el: Element): boolean => {
            // `data-standalone="true"` fields persist themselves immediately via their own
            // endpoint (see MediaSelectorModal.ts) and belong to a different entity than the
            // current page - e.g. the burger menu's nav photo, which lives on every page but
            // saves to MenuConfig, not this page's content. Never fold them into page content.
            if ((el as HTMLElement).dataset.standalone === 'true') {
                return false;
            }

            if (!options.excludeWithinSection) {
                return true;
            }
            // Top-level scope: skip anything living inside a `[data-section]` wrapper
            // (including elements that carry `data-section` on themselves), already
            // collected (or intentionally excluded) by the per-section pass above.
            return el.closest('[data-section]') === null;
        };

        scope.querySelectorAll<HTMLElement>('[data-editable="true"]').forEach(field => {
            if (!isEligible(field)) return;
            this.assignField(fields, field.dataset.field!, field.innerHTML);
        });

        scope.querySelectorAll<HTMLElement>('[data-editable-image="true"]').forEach(field => {
            if (!isEligible(field)) return;
            const mediaId = field.dataset.mediaId ?? '';
            const mediaIdDark = field.dataset.mediaIdDark ?? '';
            if (!mediaId && !mediaIdDark) return;

            const fieldName = field.dataset.field!;
            // `name[]` slots keep both lists index-aligned ('' = no image for that theme).
            const isList = fieldName.endsWith('[]');

            if (mediaId || isList) {
                this.assignField(fields, fieldName, mediaId);
            }
            // Hiver variant (`xxxDark`, `images[]` -> `imagesDark[]`); only themable slots carry the attribute.
            if (field.dataset.mediaIdDark !== undefined && (mediaIdDark || isList)) {
                this.assignField(fields, ContentSerializer.suffixedFieldName(fieldName, 'Dark'), mediaIdDark);
            }

            // Cadrage (`xxxFrame` / `xxxDarkFrame` / `xxxRatio`, lists stay index-aligned with null);
            // only slots rendered with `image_frame_attrs()` carry `data-image-frame`.
            if (field.dataset.imageFrame !== undefined) {
                this.assignField(fields, ContentSerializer.suffixedFieldName(fieldName, 'Frame'), parseFrame(field.dataset.imageFrame));
                if (field.dataset.mediaIdDark !== undefined) {
                    this.assignField(fields, ContentSerializer.suffixedFieldName(fieldName, 'DarkFrame'), parseFrame(field.dataset.imageFrameDark));
                }
                if (field.dataset.frameRatio !== 'false') {
                    this.assignField(fields, ContentSerializer.suffixedFieldName(fieldName, 'Ratio'), field.dataset.imageRatio || null);
                }
            }
        });

        // Non-visual value carried through the DOM (e.g. a repeatable item's stable `id`).
        scope.querySelectorAll<HTMLElement>('[data-editable-value="true"]').forEach(field => {
            if (!isEligible(field)) return;
            this.assignField(fields, field.dataset.field!, field.dataset.value ?? '');
        });

        scope.querySelectorAll<HTMLAnchorElement>('[data-editable-href="true"]').forEach(field => {
            if (!isEligible(field)) return;
            const fieldName = field.dataset.field!;
            fields[fieldName] = field.getAttribute('href') || '';

            const labelField = field.dataset.labelField;
            if (labelField) {
                fields[labelField] = field.dataset.labelValue || '';
            }
        });

        scope.querySelectorAll<HTMLSelectElement>('[data-editable-select="true"]').forEach(field => {
            if (!isEligible(field)) return;
            fields[field.dataset.field!] = field.value;
        });

        return fields;
    }

    /**
     * Delegates to `collectFields()` (no `excludeWithinSection` restriction — every
     * `[data-editable*]` element in the document is eligible) rather than duplicating its
     * four field-kind loops. This also means flat templates get the `name[]` / `name[index][subfield]`
     * conventions (decision (b)) "for free" if a future flat template ever needs them,
     * without any change here.
     */
    private serializeFlat(): SerializedContent {
        return this.collectFields(document.body);
    }
}
