/**
 * @file tagged-merge.ts
 * @description Copy strategy for JSON files using comment-based tags for section-level control
 * @module strategies
 * @remarks
 * This strategy solves a limitation of the regular merge strategy: the inability to
 * force certain values while allowing project overrides in other sections of the same file.
 * By using comment tags as section markers, Lisa can govern critical configs (CI/CD scripts,
 * required dependencies) while projects retain freedom to customize other areas.
 */
/* eslint-disable max-lines -- Large complex strategy with many helper methods */
import * as fse from "fs-extra";
import { copyFile } from "node:fs/promises";
import { ensureParentDir } from "../utils/file-operations.js";
import { readJson, writeJson } from "../utils/json-utils.js";
import { JsonMergeError } from "../errors/index.js";
/**
 * Implements tagged merge strategy for JSON files with comment-based section control
 * @remarks
 * Supports three tag behaviors:
 * - `//lisa-force-*`: Lisa replaces entire section, project changes ignored (for governance)
 * - `//lisa-defaults-*`: Lisa provides values, project can override (for sensible defaults)
 * - `//lisa-merge-*`: For arrays - combine Lisa's + project's items deduplicated (for shared lists)
 *
 * Tag format: `//lisa-<behavior>-<category>` with matching `//end-lisa-<behavior>-<category>`
 *
 * Order preservation: JSON key ordering is maintained throughout the merge process,
 * ensuring deterministic output and minimal diff noise when configs change.
 */
export class TaggedMergeStrategy {
    name = "tagged-merge";
    /**
     * Produce the exact tagged-merge result without filesystem access.
     * @param source - Lisa tagged template object
     * @param destination - Existing host object
     * @returns Merged object preserving host-owned sections
     */
    mergeJson(source, destination) {
        return this.mergeWithTags(source, destination);
    }
    /**
     * Regex pattern for parsing opening tags
     * @remarks Captures behavior (force|defaults|merge) and category name for routing to appropriate merge logic
     */
    tagPattern = /^\/\/lisa-(force|defaults|merge)-(.+)$/;
    /**
     * Apply tagged merge strategy to a JSON file
     * @param sourcePath - Absolute path to Lisa template JSON file with tags
     * @param destPath - Absolute path to project JSON file to merge into
     * @param relativePath - Project-relative path for logging
     * @param context - Strategy context with config and backup functions
     * @returns Operation result indicating copied, skipped, or merged action
     * @throws JsonMergeError if source or destination JSON cannot be parsed
     * @remarks
     * When destination doesn't exist, copies source directly. When destination exists,
     * performs tagged merge preserving project customizations outside tagged sections.
     */
    async apply(sourcePath, destPath, relativePath, context) {
        const { config, backupFile } = context;
        const destExists = await fse.pathExists(destPath);
        if (!destExists) {
            if (!config.dryRun) {
                await ensureParentDir(destPath);
                await copyFile(sourcePath, destPath);
            }
            return { relativePath, strategy: this.name, action: "copied" };
        }
        const sourceJson = await readJson(sourcePath).catch(() => {
            throw new JsonMergeError(relativePath, `Failed to parse source: ${sourcePath}`);
        });
        const destJson = await readJson(destPath).catch(() => {
            throw new JsonMergeError(relativePath, `Failed to parse destination: ${destPath}`);
        });
        const merged = this.mergeJson(sourceJson, destJson);
        const normalizedDest = JSON.stringify(destJson, null, 2);
        const normalizedMerged = JSON.stringify(merged, null, 2);
        if (normalizedDest === normalizedMerged) {
            return { relativePath, strategy: this.name, action: "skipped" };
        }
        if (!config.dryRun) {
            await backupFile(destPath);
            await writeJson(destPath, merged);
        }
        return { relativePath, strategy: this.name, action: "merged" };
    }
    /**
     * Merge source and destination JSON using tagged sections
     * @param source - Lisa template JSON containing tag markers and governed content
     * @param dest - Project JSON with potential customizations to preserve
     * @returns Merged JSON with tagged sections applied according to their behavior types
     * @remarks
     * Processing supports tags at any nesting level. For each tagged section (whether at
     * top level or nested in an object), applies its behavior rules. Order of processing:
     * 1) Process all tagged sections from source recursively, applying their behaviors
     * 2) Add unprocessed content from destination (preserves project customizations)
     * 3) Add any remaining unprocessed tags from source
     * This ensures governance while preserving project-specific additions outside tagged regions.
     */
    mergeWithTags(source, dest) {
        const sourceTags = this.parseTaggedSections(source);
        const result = {};
        const processed = new Set();
        const processedTags = new Set();
        // First, process top-level tags
        for (const key in source) {
            if (this.isOpeningTag(key)) {
                this.processTaggedSection(key, source, dest, sourceTags, result, processed, processedTags);
            }
        }
        // Second, process unprocessed objects that might have nested tags
        for (const key in source) {
            if (!processed.has(key) && !this.isTagKey(key)) {
                this.processNestedTaggedObject(key, source, dest, result, processed);
            }
        }
        this.addUnprocessedContent(result, dest, processed, processedTags);
        this.addUnprocessedTags(result, source, processedTags, processed);
        return result;
    }
    /**
     * Process an object that might contain nested tags
     * @param key - The key of the object to process
     * @param source - Source object containing the nested object
     * @param dest - Destination object containing potential counterpart
     * @param result - Result object being built
     * @param processed - Set of processed keys
     */
    processNestedTaggedObject(key, source, dest, result, processed) {
        const sourceValue = source[key];
        if (!this.isPlainObject(sourceValue)) {
            return;
        }
        const sourceObj = sourceValue;
        if (!this.hasNestedTags(sourceObj)) {
            return;
        }
        const destObj = this.getDestinationObject(dest, key);
        const mergedObj = this.mergeObjectWithNestedTags(sourceObj, destObj);
        result[key] = mergedObj;
        processed.add(key);
    }
    /**
     * Check if a value is a plain object (not array, null, or primitive)
     * @param value - Value to check
     * @returns True if value is a plain object
     */
    isPlainObject(value) {
        return typeof value === "object" && value !== null && !Array.isArray(value);
    }
    /**
     * Check if an object contains any nested tags
     * @param obj - Object to check
     * @returns True if object has opening tags as properties
     */
    hasNestedTags(obj) {
        return Object.keys(obj).some(k => this.isOpeningTag(k));
    }
    /**
     * Get the destination object for a key, or empty object if not found
     * @param dest - Destination object
     * @param key - Key to look for
     * @returns Destination object or empty object
     */
    getDestinationObject(dest, key) {
        const destValue = dest[key];
        return this.isPlainObject(destValue)
            ? destValue
            : {};
    }
    /**
     * Merge an object that may contain nested tags
     * @param source - Source object potentially containing tags
     * @param dest - Destination object to merge with
     * @returns Merged object with tags applied appropriately
     */
    mergeObjectWithNestedTags(source, dest) {
        const sourceTags = this.parseTaggedSections(source);
        const result = {};
        const processed = new Set();
        const processedTags = new Set();
        // Process tagged sections within this object
        for (const key in source) {
            if (this.isOpeningTag(key)) {
                this.processTaggedSection(key, source, dest, sourceTags, result, processed, processedTags);
            }
        }
        this.addUnprocessedContent(result, dest, processed, processedTags);
        // Add remaining tags from source
        for (const key in source) {
            if (this.isTagKey(key) &&
                !processedTags.has(key) &&
                !processed.has(key)) {
                result[key] = source[key];
            }
        }
        return result;
    }
    /**
     * Preserve project customizations by adding untagged destination content to result
     * @param result - Result object being built
     * @param dest - Project JSON containing potential customizations
     * @param processed - Keys already processed via tagged sections (to avoid duplicates)
     * @param processedTags - Tag keys already processed (to avoid duplicates)
     * @remarks
     * This is the key step that preserves project freedom: any content outside tagged
     * regions is copied from the project's file unchanged, allowing custom scripts,
     * dependencies, or configs that Lisa doesn't govern.
     */
    addUnprocessedContent(result, dest, processed, processedTags) {
        for (const key in dest) {
            if (!processed.has(key) &&
                !processedTags.has(key) &&
                !this.isTagKey(key)) {
                result[key] = dest[key];
            }
        }
    }
    /**
     * Add any remaining tag keys from source that weren't part of complete tag pairs
     * @param result - Result object being built
     * @param source - Lisa template JSON
     * @param processedTags - Tag keys already processed as part of complete sections
     * @param processed - Content keys already processed
     * @remarks
     * Handles edge case of orphaned tags (e.g., opening tag without closing tag)
     * by preserving them in output rather than silently dropping them.
     */
    addUnprocessedTags(result, source, processedTags, processed) {
        for (const key in source) {
            if (this.isTagKey(key) &&
                !processedTags.has(key) &&
                !processed.has(key)) {
                result[key] = source[key];
            }
        }
    }
    /**
     * Process a single tagged section, routing to appropriate behavior handler
     * @param key - Opening tag key (e.g., `//lisa-force-scripts`)
     * @param source - Lisa template JSON
     * @param dest - Project JSON
     * @param sourceTags - Map of all parsed tag sections from source
     * @param result - Result object being built
     * @param processed - Set tracking processed content keys
     * @param processedTags - Set tracking processed tag keys
     * @remarks
     * Routes to applyForceSection, applyDefaultsSection, or applyMergeSection
     * based on the behavior extracted from the tag. All keys within the section
     * (including opening/closing tags) are marked as processed to prevent
     * duplicate handling in subsequent steps.
     */
    processTaggedSection(key, source, dest, sourceTags, result, processed, processedTags) {
        const tagMatch = this.tagPattern.exec(key);
        if (!tagMatch || !tagMatch[2])
            return;
        const behavior = tagMatch[1];
        const category = tagMatch[2];
        const section = sourceTags.get(key);
        if (!section)
            return;
        processedTags.add(key);
        processedTags.add(section.closeKey);
        for (const contentKey of section.contentKeys) {
            processedTags.add(contentKey);
        }
        if (behavior === "force") {
            this.applyForceSection(result, source, section);
        }
        else if (behavior === "defaults") {
            this.applyDefaultsSection(result, source, dest, section, category);
        }
        else if (behavior === "merge") {
            this.applyMergeSection(result, source, dest, section, category);
        }
        for (const contentKey of section.contentKeys) {
            processed.add(contentKey);
        }
        processed.add(key);
        processed.add(section.closeKey);
    }
    /**
     * Parse JSON object to extract all tagged sections with their boundaries
     * @param obj - JSON object to parse for tag markers
     * @returns Map of opening tag keys to section info (behavior, category, content keys, close key)
     * @remarks
     * Relies on JSON key ordering being preserved (standard in modern JS engines).
     * Skips sections without matching closing tags to handle malformed input gracefully.
     */
    parseTaggedSections(obj) {
        const sections = new Map();
        const keys = Object.keys(obj);
        keys.forEach((key, i) => {
            if (!this.isOpeningTag(key))
                return;
            const match = this.tagPattern.exec(key);
            if (!match || !match[2])
                return;
            const behavior = match[1];
            const category = match[2];
            const closeKey = `//end-lisa-${behavior}-${category}`;
            const closeIndex = keys.indexOf(closeKey);
            if (closeIndex === -1)
                return;
            const contentKeys = keys.slice(i + 1, closeIndex);
            sections.set(key, { behavior, category, contentKeys, closeKey });
        });
        return sections;
    }
    /**
     * Apply force behavior: Lisa's section replaces project's entirely
     * @param result - Result object being built
     * @param source - Lisa template containing the authoritative values
     * @param section - Section info with content keys to copy
     * @remarks
     * Use force for governance-critical configs that projects must not override,
     * such as CI/CD scripts, required linting commands, or mandatory dev dependencies.
     * Project changes within force-tagged sections are discarded during merge.
     */
    applyForceSection(result, source, section) {
        const openKey = `//lisa-${section.behavior}-${section.category}`;
        result[openKey] = source[openKey];
        for (const key of section.contentKeys) {
            result[key] = source[key];
        }
        result[section.closeKey] = source[section.closeKey];
    }
    /**
     * Apply defaults behavior: project's values take precedence if they exist
     * @param result - Result object being built
     * @param source - Lisa template with default values
     * @param dest - Project JSON with potential overrides
     * @param section - Section info from source
     * @param category - Tag category name for looking up destination section
     * @remarks
     * Use defaults for sensible starting values that projects may legitimately need
     * to customize, such as engine versions or optional configurations. If project
     * has the same tagged section, its content is used; otherwise Lisa's defaults apply.
     */
    applyDefaultsSection(result, source, dest, section, category) {
        const openKey = `//lisa-${section.behavior}-${category}`;
        result[openKey] = source[openKey];
        this.addDefaultsContent(result, source, dest, section, category);
        result[section.closeKey] = source[section.closeKey];
    }
    /**
     * Add defaults section content, preferring project's version if it has content
     * @param result - Result object being built
     * @param source - Lisa template with fallback values
     * @param dest - Project JSON with potential overrides
     * @param section - Section info from source
     * @param category - Tag category name
     */
    addDefaultsContent(result, source, dest, section, category) {
        const destSection = this.findAndExtractDestinationSection(dest, section, category);
        if (destSection && destSection.contentKeys.length > 0) {
            for (const key of destSection.contentKeys) {
                if (key in dest) {
                    result[key] = dest[key];
                }
            }
        }
        else {
            this.addSourceContent(result, source, section.contentKeys);
        }
    }
    /**
     * Locate and extract the matching tagged section from destination JSON
     * @param dest - Project JSON to search for matching tags
     * @param section - Source section info containing behavior type
     * @param category - Tag category name to match
     * @returns Extracted section info if found, undefined if project lacks the section
     */
    findAndExtractDestinationSection(dest, section, category) {
        const destSectionStart = this.findSectionStart(dest, section.behavior, category);
        return destSectionStart
            ? this.extractSectionContent(dest, destSectionStart, section.behavior, category)
            : undefined;
    }
    /**
     * Copy content keys from source template to result
     * @param result - Result object being built
     * @param source - Lisa template containing values to copy
     * @param contentKeys - Keys to copy from source
     */
    addSourceContent(result, source, contentKeys) {
        for (const key of contentKeys) {
            result[key] = source[key];
        }
    }
    /**
     * Apply merge behavior: combine arrays from source and dest without duplicates
     * @param result - Result object being built
     * @param source - Lisa template with base array items
     * @param dest - Project JSON with additional array items
     * @param section - Section info with content keys
     * @param category - Tag category name
     * @remarks
     * Use merge for shared lists where both Lisa and projects contribute items,
     * such as trustedDependencies. Lisa's items come first, then project's unique
     * additions. Deduplication uses JSON.stringify for value equality comparison.
     */
    applyMergeSection(result, source, dest, section, category) {
        const openKey = `//lisa-${section.behavior}-${category}`;
        result[openKey] = source[openKey];
        for (const key of section.contentKeys) {
            const sourceValue = source[key];
            const destValue = dest[key];
            if (Array.isArray(sourceValue) && Array.isArray(destValue)) {
                result[key] = this.deduplicateArrays(sourceValue, destValue);
            }
            else if (sourceValue !== undefined) {
                result[key] = sourceValue;
            }
            else if (destValue !== undefined) {
                result[key] = destValue;
            }
        }
        result[section.closeKey] = source[section.closeKey];
    }
    /**
     * Combine two arrays, removing duplicates while preserving order
     * @param sourceArray - Lisa template array (items added first)
     * @param destArray - Project array (unique items added after)
     * @returns Combined array with source items first, then project's unique additions
     * @remarks
     * Uses JSON.stringify for value equality, so objects/arrays with same content
     * are considered duplicates. This allows deduplicating complex items like
     * package names with version specifiers.
     */
    deduplicateArrays(sourceArray, destArray) {
        const seen = new Set();
        const result = [];
        for (const item of sourceArray) {
            const key = JSON.stringify(item);
            if (!seen.has(key)) {
                seen.add(key);
                result.push(item);
            }
        }
        for (const item of destArray) {
            const key = JSON.stringify(item);
            if (!seen.has(key)) {
                seen.add(key);
                result.push(item);
            }
        }
        return result;
    }
    /**
     * Test if a key is an opening tag (matches `//lisa-<behavior>-<category>` format)
     * @param key - JSON key to test
     * @returns True if key matches the opening tag pattern
     */
    isOpeningTag(key) {
        return this.tagPattern.test(key);
    }
    /**
     * Test if a key is any tag-like comment key (starts with `//`)
     * @param key - JSON key to test
     * @returns True if key is a JSON comment (convention used for documentation/tags)
     */
    isTagKey(key) {
        return key.startsWith("//");
    }
    /**
     * Find opening tag key for a given behavior and category in an object
     * @param obj - Object to search for the tag
     * @param behavior - Tag behavior type (force, defaults, merge)
     * @param category - Tag category name
     * @returns Tag key string if found, undefined if object lacks this tag
     */
    findSectionStart(obj, behavior, category) {
        const tagKey = `//lisa-${behavior}-${category}`;
        return tagKey in obj ? tagKey : undefined;
    }
    /**
     * Extract section boundaries and content keys between opening and closing tags
     * @param obj - Object containing the tagged section
     * @param startKey - Opening tag key that marks section start
     * @param behavior - Tag behavior type (for constructing close key)
     * @param category - Tag category name (for constructing close key)
     * @returns Complete section info including content keys between tags
     */
    extractSectionContent(obj, startKey, behavior, category) {
        const closeKey = `//end-lisa-${behavior}-${category}`;
        const keys = Object.keys(obj);
        const startIndex = keys.indexOf(startKey);
        const closeIndex = keys.indexOf(closeKey);
        const contentKeys = this.extractContentKeysFromRange(startIndex, closeIndex, keys);
        return {
            behavior,
            category,
            contentKeys,
            closeKey,
        };
    }
    /**
     * Extract keys between opening and closing tag indices
     * @param startIndex - Index of opening tag in keys array
     * @param closeIndex - Index of closing tag in keys array
     * @param keys - Ordered array of all keys in the object
     * @returns Array of content keys between the tags, empty if indices invalid
     */
    extractContentKeysFromRange(startIndex, closeIndex, keys) {
        if (startIndex === -1 || closeIndex === -1 || startIndex >= closeIndex) {
            return [];
        }
        return keys.slice(startIndex + 1, closeIndex);
    }
}
/* eslint-enable max-lines -- End of TaggedMergeStrategy */
//# sourceMappingURL=tagged-merge.js.map