import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { platform } from 'node:os';
import type { WizardPrompter } from '../prompter/interface.js';
import type { ConfigService } from '../config/config-service.js';
import type { WizardState } from '../types.js';
import { getShellProfilePath } from '../platform/detect.js';
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
import { ManifestError } from '../framework/manifest.js';
import {
  getDefaultSkillPaths,
  syncClaudeSkills,
  type SkillSyncResult as ClaudeSkillSyncResult,
} from '../commands/skill.js';

function frameworkScriptPath(mosaicHome: string, name: string): string {
  const currentPath = join(mosaicHome, 'tools', '_scripts', name);
  if (existsSync(currentPath)) return currentPath;

  // Backward-compatible fallback for pre-migration installs that still have bin/.
  const legacyPath = join(mosaicHome, 'bin', name);
  if (existsSync(legacyPath)) return legacyPath;

  // Return the current expected path so user-facing errors point at the layout
  // installed by packages/mosaic/framework/install.sh.
  return currentPath;
}

/** Link runtime assets and surface a non-zero install-ordering guard outcome. */
function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): string | undefined {
  const script = frameworkScriptPath(mosaicHome, 'mosaic-link-runtime-assets');
  if (!existsSync(script)) return undefined;
  try {
    const result = spawnSync('bash', [script], {
      timeout: 30000,
      stdio: 'pipe',
      encoding: 'utf-8',
      env: {
        ...process.env,
        ...(skipClaudeHooks ? { MOSAIC_SKIP_CLAUDE_HOOKS: '1' } : {}),
      },
    });
    if (result.status !== 0) {
      const stderr = (result.stderr ?? '').trim();
      return (
        stderr || 'Runtime asset linking reported a non-zero exit (see mosaic doctor for details).'
      );
    }
  } catch {
    // Non-fatal: wizard continues
  }
  return undefined;
}

interface SyncSkillsResult {
  success: boolean;
  installedCount: number;
  failureReason?: string;
}

/**
 * Sync skills from the catalog and link only the user-selected subset.
 *
 * When `selectedSkills` is non-empty the script receives the list via
 * `MOSAIC_INSTALL_SKILLS` (colon-separated) so it can skip unlisted skills
 * during the linking phase.  An empty selection is a no-op.
 *
 * Failure modes surfaced here:
 *   - Script not found → tells the user explicitly
 *   - Script exits non-zero → stderr is captured and reported
 *   - Catalog directory missing → detected before exec, reported clearly
 */
function syncSkills(mosaicHome: string, selectedSkills: string[]): SyncSkillsResult {
  if (selectedSkills.length === 0) {
    return { success: true, installedCount: 0 };
  }

  const script = frameworkScriptPath(mosaicHome, 'mosaic-sync-skills');
  if (!existsSync(script)) {
    return {
      success: false,
      installedCount: 0,
      failureReason: `Skills sync script not found at ${script} — run 'mosaic sync' after installation.`,
    };
  }

  try {
    const result = spawnSync('bash', [script], {
      timeout: 60000,
      stdio: 'pipe',
      encoding: 'utf-8',
      env: {
        ...process.env,
        MOSAIC_HOME: mosaicHome,
        MOSAIC_INSTALL_SKILLS: selectedSkills.join(':'),
      },
    });

    if (result.status !== 0) {
      const stderr = (result.stderr ?? '').trim();
      return {
        success: false,
        installedCount: 0,
        failureReason: stderr
          ? `Skills sync failed: ${stderr}`
          : `Skills sync script exited with code ${(result.status ?? 'unknown').toString()}`,
      };
    }

    return { success: true, installedCount: selectedSkills.length };
  } catch (err) {
    return {
      success: false,
      installedCount: 0,
      failureReason: `Skills sync threw: ${err instanceof Error ? err.message : String(err)}`,
    };
  }
}

interface DoctorResult {
  warnings: number;
  output: string;
}

function runDoctor(mosaicHome: string): DoctorResult {
  const script = frameworkScriptPath(mosaicHome, 'mosaic-doctor');
  if (!existsSync(script)) {
    return { warnings: 0, output: 'mosaic-doctor not found' };
  }

  try {
    const result = spawnSync('bash', [script], {
      timeout: 30000,
      encoding: 'utf-8',
      stdio: 'pipe',
    });
    const output = result.stdout ?? '';
    const warnings = (output.match(/WARN/g) ?? []).length;
    return { warnings, output };
  } catch {
    return { warnings: 1, output: 'Doctor check failed' };
  }
}

type PathAction = 'already' | 'added' | 'skipped';

const PATH_BLOCK_BEGIN = '# >>> mosaic begin >>>';
const PATH_BLOCK_END = '# <<< mosaic end <<<';
const PATH_BLOCK_NOTE = '# Managed by the Mosaic installer; this block is rewritten on install.';

/**
 * The managed PATH block written into the operator's shell profile.
 *
 * The block is delimited by begin/end sentinels so any number of installs,
 * against any homes, collapse to exactly one block: the writer replaces the
 * region between the sentinels instead of appending a second copy (#1327).
 */
export function managedBlockFor(binDir: string, isWindows: boolean): string {
  const exportLine = isWindows
    ? `$env:Path = "${binDir};$env:Path"`
    : `export PATH="${binDir}:$PATH"`;
  return `${PATH_BLOCK_BEGIN}\n${PATH_BLOCK_NOTE}\n${exportLine}\n${PATH_BLOCK_END}\n`;
}

/**
 * Remove legacy unmarked `# Mosaic` PATH pairs appended by pre-#1327
 * installs. Only the exact two-line shape this installer used to write is
 * removed; any other `# Mosaic` comment line is left alone.
 */
export function stripLegacyPathBlocks(content: string, isWindows: boolean): string {
  const legacyExport = isWindows ? /^\$env:Path = ".*;\$env:Path"$/ : /^export PATH=".*:\$PATH"$/;
  const lines = content.split('\n');
  const kept: string[] = [];
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i] ?? '';
    const next = i + 1 < lines.length ? lines[i + 1] : undefined;
    if (line === '# Mosaic' && next !== undefined && legacyExport.test(next)) {
      i += 1;
      continue;
    }
    kept.push(line);
  }
  return kept.join('\n');
}

/** Drop the region between the managed-block sentinels, first occurrence. */
function withoutManagedBlock(content: string): string {
  const beginIdx = content.indexOf(PATH_BLOCK_BEGIN);
  if (beginIdx < 0) return content;
  const endIdx = content.indexOf(PATH_BLOCK_END, beginIdx);
  if (endIdx < 0) return content;
  return content.slice(0, beginIdx) + content.slice(endIdx + PATH_BLOCK_END.length);
}

export function setupPath(mosaicHome: string, resolvedDefaultHome: string): PathAction {
  // Never write outside the home under test (#1327 S2): a wizard run against
  // a non-default home (test harnesses, throwaway installs) must not mutate
  // the operator's real shell profile.
  if (mosaicHome !== resolvedDefaultHome) {
    return 'skipped';
  }

  const binDir = join(mosaicHome, 'bin');
  const profilePath = getShellProfilePath();
  if (!profilePath) return 'skipped';

  const isWindows = platform() === 'win32';
  const block = managedBlockFor(binDir, isWindows);

  let content = '';
  if (existsSync(profilePath)) {
    content = readFileSync(profilePath, 'utf-8');
  }

  // Migration (#1327 S4): legacy unmarked blocks collapse into the managed
  // block, and an existing managed block is rewritten in place rather than
  // appended beside itself (S1/S3).
  const base = stripLegacyPathBlocks(withoutManagedBlock(content), isWindows);
  const trimmed = base.replace(/\n+$/, '');
  const next = trimmed.length === 0 ? block : `${trimmed}\n${block}`;

  if (next === content) {
    return 'already';
  }

  try {
    writeFileSync(profilePath, next, 'utf-8');
    return 'added';
  } catch {
    return 'skipped';
  }
}

export interface FinalizeStageOptions {
  /**
   * Defer the success summary/outro so callers can run downstream readiness
   * gates (gateway health/bootstrap) before claiming Mosaic is ready.
   */
  deferSummary?: boolean;
}

export interface FinalizeStageResult {
  showSummary: () => void;
}

export async function finalizeStage(
  p: WizardPrompter,
  state: WizardState,
  config: ConfigService,
  options: FinalizeStageOptions = {},
): Promise<FinalizeStageResult> {
  p.separator();

  const spin = p.spinner();

  // 1. Sync framework files (before config writes so identity files aren't overwritten)
  spin.update('Syncing framework files...');
  try {
    await config.syncFramework(state.installAction);
  } catch (err) {
    // Stop the spinner loudly and re-raise so the process exits non-zero — never
    // fall through to "Installation complete" on an aborted sync (#791 B2).
    // A ManifestError is a PRE-sync validation abort: the manifest is loaded and
    // validated before any file is written, so nothing was touched. Any other
    // error can surface AFTER files were partially copied, so we must NOT claim
    // "no files were changed" for it — that would misdirect recovery (#791 blocker-C).
    if (err instanceof ManifestError) {
      spin.stop(
        'Framework sync aborted — the framework manifest is missing, empty, or malformed; no files were changed.',
      );
    } else {
      spin.stop(
        'Framework sync aborted — the update did not complete and may be partially applied; see the error below.',
      );
    }
    throw err;
  }

  // 2. Write config files (after sync so they aren't overwritten by source templates)
  if (state.installAction !== 'keep') {
    spin.update('Writing configuration files...');
    await config.writeSoul(state.soul);
    await config.writeUser(state.user);
    await config.writeTools(state.tools);
  }

  // 3. Link runtime assets
  // Honor the hooks-preview decision: when the user declined hooks, pass
  // MOSAIC_SKIP_CLAUDE_HOOKS=1 to the linker so hooks-config.json is not
  // copied into ~/.claude/ while still linking the other runtime files.
  spin.update('Linking runtime assets...');
  const skipClaudeHooks = state.hooks?.accepted === false;
  const linkWarning = linkRuntimeAssets(state.mosaicHome, skipClaudeHooks);

  // 4. Sync skills (only installs the user-selected subset)
  let skillsResult: SyncSkillsResult = { success: true, installedCount: 0 };
  if (state.selectedSkills.length > 0) {
    spin.update(`Installing ${state.selectedSkills.length.toString()} selected skill(s)...`);
    skillsResult = syncSkills(state.mosaicHome, state.selectedSkills);
  }

  // 5. Reconcile every canonical Mosaic skill into Claude Code. This is
  // intentionally independent of the first-run selected-skill fetch above:
  // framework installs/upgrades must also register skills added after setup.
  spin.update('Registering Mosaic skills with Claude Code...');
  let bridgeResult: ClaudeSkillSyncResult = {
    registered: [],
    repaired: [],
    unchanged: [],
    conflicts: [],
  };
  let bridgeFailure: string | undefined;
  try {
    bridgeResult = syncClaudeSkills({
      mosaicSkillsDir: join(state.mosaicHome, 'skills'),
      claudeSkillsDir: getDefaultSkillPaths().claudeSkillsDir,
    });
  } catch (error: unknown) {
    bridgeFailure = error instanceof Error ? error.message : String(error);
  }

  // 6. Run doctor
  spin.update('Running health audit...');
  const doctorResult = runDoctor(state.mosaicHome);

  spin.stop('Installation complete');

  // Surface the install-ordering guard's outcome (#869 Point-1 C2) — never
  // silent, even though the wizard continues either way.
  if (linkWarning) p.warn(linkWarning);

  // Report skill install failure clearly (non-fatal but user should know)
  if (!skillsResult.success && skillsResult.failureReason) {
    p.warn(skillsResult.failureReason);
    p.warn("Run 'mosaic sync' manually after installation to install skills.");
  }

  if (bridgeFailure) p.warn(`Claude skill reconciliation skipped: ${bridgeFailure}`);
  for (const conflict of bridgeResult.conflicts) {
    p.warn(`Skill registration skipped for ${conflict.name}: ${conflict.reason}`);
  }

  // 7. PATH setup
  const pathAction = setupPath(state.mosaicHome, DEFAULT_MOSAIC_HOME);

  let summaryShown = false;
  const showSummary = () => {
    if (summaryShown) return;
    summaryShown = true;

    // 7. Summary
    const skillsSummary = skillsResult.success
      ? skillsResult.installedCount > 0
        ? `${skillsResult.installedCount.toString()} installed`
        : 'none selected'
      : `install failed — ${skillsResult.failureReason ?? 'unknown error'}`;

    const summary: string[] = [
      `Agent: ${state.soul.agentName ?? 'Assistant'}`,
      `Style: ${state.soul.communicationStyle ?? 'direct'}`,
      `Runtimes: ${state.runtimes.detected.join(', ') || 'none detected'}`,
      `Skills: ${skillsSummary}`,
      `Config: ${state.mosaicHome}`,
    ];

    if (doctorResult.warnings > 0) {
      summary.push(
        `Health: ${doctorResult.warnings.toString()} warning(s) — run 'mosaic doctor' for details`,
      );
    } else {
      summary.push('Health: all checks passed');
    }

    p.note(summary.join('\n'), 'Installation Summary');

    // 8. Next steps
    const nextSteps: string[] = [];
    if (pathAction === 'added') {
      const profilePath = getShellProfilePath();
      nextSteps.push(`Reload shell: source ${profilePath ?? '~/.profile'}`);
    }
    if (state.runtimes.detected.length === 0) {
      nextSteps.push('Install at least one runtime (claude, codex, or opencode)');
    }
    nextSteps.push("Launch with 'mosaic claude' (or codex/opencode)");
    nextSteps.push('Edit identity files directly in ~/.config/mosaic/ for fine-tuning');

    p.note(nextSteps.map((s, i) => `${(i + 1).toString()}. ${s}`).join('\n'), 'Next Steps');

    p.outro('Mosaic is ready.');
  };

  if (!options.deferSummary) {
    showSummary();
  }

  return { showSummary };
}
