/**
 * Unified fill pipeline — shared orchestration for all three fill paths
 * (bundled templates, external/vendored, and recipes).
 *
 * Each path is now a thin wrapper that builds PipelineOptions and calls
 * runFillPipeline(). The data-prep and fill steps come from fill-pipeline.ts;
 * this module handles the surrounding orchestration: temp dir, copy, clean→patch,
 * selections, fill, verify, cleanup.
 */

import { readFileSync, writeFileSync, copyFileSync, mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import AdmZip from 'adm-zip';
import { DOMParser, XMLSerializer } from '@xmldom/xmldom';
import { getParagraphText, replaceParagraphTextRange } from '@usejunior/docx-core';
import { prepareFillData, fillDocx, type ConfirmClauseDescriptor } from './fill-pipeline.js';
import { cleanDocument } from './recipe/cleaner.js';
import { patchDocument } from './recipe/patcher.js';
import { applySelections } from './selector.js';
import { enumerateTextParts, getGeneralTextPartNames } from './recipe/ooxml-parts.js';
import type { FieldDefinition, CleanConfig } from './metadata.js';
import type { SelectionsConfig } from './selector.js';
import type { VerifyResult } from './recipe/types.js';

const W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';

export interface PipelineOptions {
  inputPath: string;                    // Source .docx
  outputPath: string;                   // Final output .docx
  values: Record<string, unknown>;
  fields: FieldDefinition[];
  priorityFieldNames?: string[];

  // Clean/Patch — omit to skip (templates without replacements.json)
  cleanPatch?: {
    cleanConfig: CleanConfig;
    replacements: Record<string, string | { value: string; format?: Record<string, unknown> }>;
  };

  // Selections — omit to skip (only templates with selections.json)
  selectionsConfig?: SelectionsConfig;

  // prepareFillData options
  coerceBooleans?: boolean;             // default: false
  computeDisplayFields?: (data: Record<string, unknown>) => void;
  signingTagDefaults?: Record<string, string>;  // defaults for {sig_*} tags
  confirmClauses?: ConfirmClauseDescriptor[];    // confirm= clauses → any_confirmation_pending

  // fillDocx options
  fixSmartQuotes?: boolean;             // default: false

  // Verification callback — each path passes its own verifier
  verify: (outputPath: string) => VerifyResult | Promise<VerifyResult>;
  postProcess?: (outputPath: string) => void | Promise<void>;

  // Debugging
  keepIntermediate?: boolean;           // default: false
}

export interface PipelineResult {
  outputPath: string;
  fieldsUsed: string[];
  stages?: { clean: string; patch: string; fill: string };
}

/**
 * Run the unified fill pipeline:
 * 1. Create temp dir
 * 2. Copy inputPath → temp source.docx
 * 3. If cleanPatch: clean → patch
 * 4. prepareFillData (useBlankPlaceholder: true)
 * 5. If selectionsConfig: apply selections
 * 6. fillDocx → temp filled.docx
 * 7. Copy filled.docx → outputPath
 * 8. Run verify(outputPath), warn on failures
 * 9. Cleanup temp dir (unless keepIntermediate)
 */
export async function runFillPipeline(options: PipelineOptions): Promise<PipelineResult> {
  const {
    inputPath,
    outputPath,
    values,
    fields,
    priorityFieldNames = [],
    cleanPatch,
    selectionsConfig,
    coerceBooleans = false,
    computeDisplayFields,
    fixSmartQuotes = false,
    verify,
    postProcess,
    keepIntermediate = false,
  } = options;

  const tempDir = mkdtempSync(join(tmpdir(), 'fill-pipeline-'));
  const syntheticFieldKeys = new Set(
    fields
      .filter((field) => field.type === 'multiselect' && field.derive_booleans === true)
      .flatMap((field) => (field.options ?? []).map((option) => `${option}_enabled`))
  );
  // The cover-notice derived boolean is synthetic — exclude it from fieldsUsed.
  if (options.confirmClauses && options.confirmClauses.length > 0) {
    syntheticFieldKeys.add('any_confirmation_pending');
  }
  let stages: PipelineResult['stages'] | undefined;

  try {
    // Step 2: Copy source to temp dir
    const sourcePath = join(tempDir, 'source.docx');
    copyFileSync(inputPath, sourcePath);

    // Step 3: Clean → Patch (if configured)
    let currentPath = sourcePath;
    if (cleanPatch) {
      const cleanedPath = join(tempDir, 'cleaned.docx');
      await cleanDocument(sourcePath, cleanedPath, cleanPatch.cleanConfig);

      const patchedPath = join(tempDir, 'patched.docx');
      const patchResult = await patchDocument(cleanedPath, patchedPath, cleanPatch.replacements);

      // Suppress zero-match warnings for keys whose search text appears in
      // cleaned content (removeRanges, removeParagraphPatterns, removeBeforePattern).
      // These keys target text that was intentionally removed by clean.json.
      const cleanPatterns = [
        ...cleanPatch.cleanConfig.removeParagraphPatterns,
        ...cleanPatch.cleanConfig.removeRanges.flatMap(r => [r.start, r.end]),
        ...(cleanPatch.cleanConfig.removeBeforePattern ? [cleanPatch.cleanConfig.removeBeforePattern] : []),
      ];
      const unexpectedZeroMatch = patchResult.zeroMatchKeys.filter(key => {
        // Extract just the search text portion (after > for context keys)
        const sepIdx = key.indexOf(' > ');
        const searchText = sepIdx !== -1 ? key.slice(sepIdx + 3) : key;
        // Suppress if any clean pattern relates to this key's search text
        return !cleanPatterns.some(p => p.includes(searchText) || searchText.includes(p));
      });

      if (unexpectedZeroMatch.length > 0) {
        console.warn(
          `Note: ${unexpectedZeroMatch.length} replacement key(s) had zero matches: ${unexpectedZeroMatch.join(', ')}`
        );
      }

      currentPath = patchedPath;
      stages = { clean: cleanedPath, patch: patchedPath, fill: '' };
    }

    // Step 4: Prepare fill data
    const data = prepareFillData({
      values,
      fields,
      priorityFieldNames,
      useBlankPlaceholder: true,
      coerceBooleans,
      computeDisplayFields,
      signingTagDefaults: options.signingTagDefaults,
      confirmClauses: options.confirmClauses,
    });

    // Step 5: Read current buffer; apply selections if configured
    let templateBuf: Buffer = readFileSync(currentPath);
    if (selectionsConfig) {
      const preSelectPath = join(tempDir, 'pre-select.docx');
      const postSelectPath = join(tempDir, 'post-select.docx');
      writeFileSync(preSelectPath, templateBuf);
      await applySelections(preSelectPath, postSelectPath, selectionsConfig, data);
      templateBuf = readFileSync(postSelectPath);
    }

    // Step 6: Fill
    const filledBuf = await fillDocx({
      templateBuffer: templateBuf,
      data,
      fixSmartQuotes,
    });

    const filledPath = join(tempDir, 'filled.docx');
    writeFileSync(filledPath, filledBuf);
    if (stages) {
      stages.fill = filledPath;
    }

    // Collapse double spaces left by empty field substitutions (e.g. {initial_word_lower} → "")
    await collapseDoubleSpacesInDocx(filledPath);

    // Step 7: Copy to output
    copyFileSync(filledPath, outputPath);

    // Optional post-processing for recipe-specific normalization.
    if (postProcess) {
      await postProcess(outputPath);
    }

    // Step 8: Verify
    const verifyResult = await verify(outputPath);
    if (!verifyResult.passed) {
      const failures = verifyResult.checks
        .filter((c) => !c.passed)
        .map((c) => `${c.name}: ${c.details ?? 'failed'}`)
        .join('; ');
      console.warn(`Warning: verification issues: ${failures}`);
    }

    if (keepIntermediate) {
      console.log(`Intermediate files preserved at: ${tempDir}`);
    }

    return {
      outputPath,
      fieldsUsed: Object.keys(data).filter((key) => !syntheticFieldKeys.has(key)),
      stages,
    };
  } finally {
    // Step 9: Cleanup
    if (!keepIntermediate) {
      rmSync(tempDir, { recursive: true, force: true });
    }
  }
}

/**
 * Collapse runs of 2+ spaces into a single space within all text paragraphs.
 * Fixes double spaces left when empty field substitutions (e.g. {initial_word_lower} → "")
 * leave adjacent spaces in the DOCX.
 *
 * Paragraphs containing Word field characters (w:fldChar) are skipped because
 * getParagraphText treats field-code runs as empty, so "Page " + PAGE field +
 * " of " looks like "Page  of " and the replacement would delete the field run
 * that sits between the two space-bearing runs.
 */
async function collapseDoubleSpacesInDocx(docxPath: string): Promise<void> {
  const zip = new AdmZip(docxPath);
  const parser = new DOMParser();
  const serializer = new XMLSerializer();
  const parts = enumerateTextParts(zip);
  const partNames = getGeneralTextPartNames(parts);
  let anyModified = false;

  for (const partName of partNames) {
    const entry = zip.getEntry(partName);
    if (!entry) continue;
    const doc = parser.parseFromString(entry.getData().toString('utf-8'), 'text/xml');
    const paragraphs = doc.getElementsByTagNameNS(W_NS, 'p');
    let partModified = false;

    for (let i = 0; i < paragraphs.length; i++) {
      const para = paragraphs[i] as unknown as globalThis.Element;
      if (para.getElementsByTagNameNS(W_NS, 'fldChar').length > 0) {
        continue;
      }
      let text = getParagraphText(para);
      let match: RegExpExecArray | null;
      while ((match = / {2,}/.exec(text)) !== null) {
        try {
          replaceParagraphTextRange(para, match.index, match.index + match[0].length, ' ');
          partModified = true;
        } catch {
          break;
        }
        text = getParagraphText(para);
      }
    }

    if (partModified) {
      zip.updateFile(partName, Buffer.from(serializer.serializeToString(doc), 'utf-8'));
      anyModified = true;
    }
  }

  if (anyModified) {
    writeFileSync(docxPath, zip.toBuffer());
  }
}
