import { resolve, dirname, isAbsolute } from 'path';
import { fileURLToPath } from 'url';
import { writeFile, mkdir, readFile } from 'fs/promises';
import { existsSync } from 'fs';
import chokidar from 'chokidar';
import type { CompileOptions } from '../types.js';
import type {
  Logger,
  PromptScriptConfig,
  TargetEntry,
  TargetConfig,
  BuildProfileConfig,
  Lockfile,
} from '@promptscript/core';
import { isValidLockfile } from '@promptscript/core';

import type { CompileResult, FormatterOutput } from '@promptscript/compiler';
import { loadConfig, CONFIG_FILES } from '../config/loader.js';
import { resolvePrettierOptions } from '../prettier/loader.js';
import { postFormatWithPrettier } from '../prettier/post-format.js';
import { createSpinner, ConsoleOutput, isVerbose, isDebug } from '../output/console.js';
import { Compiler } from '@promptscript/compiler';
import { isTTY } from '../output/pager.js';
import { type CliServices, createDefaultServices } from '../services.js';
import { resolveRegistryPath } from '../utils/registry-resolver.js';
import { parse as parseYaml } from 'yaml';
import { stripMarkers } from '../utils/markers.js';
import { detectOutputConflicts } from '../utils/conflict-detector.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

/**
 * Resolve and read the bundled PromptScript SKILL.md.
 * Uses the same dual-candidate pattern as init.ts to handle both
 * development (packages/cli/skills/...) and bundled (dist/packages/cli/skills/...) modes.
 * Returns the content string, or undefined if the file is missing.
 */
async function loadBundledSkillContent(logger: Logger): Promise<string | undefined> {
  const skillRelPath = 'skills/promptscript/SKILL.md';
  const candidates = [
    resolve(__dirname, skillRelPath), // bundled: dist/packages/cli/skills/...
    resolve(__dirname, '..', '..', skillRelPath), // dev: packages/cli/skills/...
  ];

  for (const candidate of candidates) {
    if (existsSync(candidate)) {
      try {
        const content = await readFile(candidate, 'utf-8');
        logger.debug(
          `Loaded bundled PromptScript skill from ${candidate} (${content.length} bytes)`
        );
        return content;
      } catch {
        continue;
      }
    }
  }

  logger.verbose('Warning: Could not load bundled PromptScript SKILL.md — skill injection skipped');
  return undefined;
}

/**
 * Find a config file in the given directory by checking all known config file names.
 */
function findConfigInDir(dir: string): string | undefined {
  for (const file of CONFIG_FILES) {
    const fullPath = resolve(dir, file);
    if (existsSync(fullPath)) {
      return fullPath;
    }
  }
  return undefined;
}

/**
 * Markers that identify files generated by PromptScript.
 * We check the first 50 lines of a file for any of these markers.
 */
const PROMPTSCRIPT_MARKERS = [
  '<!-- PromptScript', // HTML comment marker (files without frontmatter)
  '# promptscript-generated:', // YAML comment marker (files with frontmatter)
  '> Auto-generated by PromptScript', // Legacy - for backwards compatibility
];

/**
 * Create a logger that outputs to ConsoleOutput based on log level.
 */
function createCliLogger(): Logger {
  return {
    verbose: (message: string) => {
      if (isVerbose() || isDebug()) {
        ConsoleOutput.verbose(message);
      }
    },
    debug: (message: string) => {
      if (isDebug()) {
        ConsoleOutput.debug(message);
      }
    },
    warn: (message: string) => {
      ConsoleOutput.warn(message);
    },
  };
}

/**
 * Parse target entries into compiler format.
 * Filters out targets with enabled: false.
 */
function parseTargets(targets: TargetEntry[]): { name: string; config?: TargetConfig }[] {
  return targets
    .map((entry) => {
      if (typeof entry === 'string') {
        return { name: entry };
      }
      // Object format: { github: { convention: 'xml' } }
      const entries = Object.entries(entry);
      if (entries.length === 0) {
        throw new Error('Empty target configuration');
      }
      const [name, config] = entries[0] as [string, TargetConfig | undefined];
      return { name, config };
    })
    .filter((target) => target.config?.enabled !== false);
}

function getBuildProfile(
  config: PromptScriptConfig,
  buildName: string | undefined
): BuildProfileConfig | undefined {
  if (!buildName) return undefined;

  const profile = config.builds?.[buildName];
  if (!profile) {
    const available = Object.keys(config.builds ?? {});
    const suffix =
      available.length > 0 ? ` Available build profiles: ${available.join(', ')}.` : '';
    throw new Error(`Unknown build profile: ${buildName}.${suffix}`);
  }

  return profile;
}

function resolveOutputBase(projectRoot: string, output: string | undefined): string {
  if (!output) return projectRoot;
  return isAbsolute(output) ? output : resolve(projectRoot, output);
}

/**
 * Check if a file was generated by PromptScript by looking for markers in the first 50 lines.
 * Extended from 20 to 50 to handle SKILL.md files with long YAML frontmatter
 * (compatibility lists, allowed-tools, etc. can push the marker past line 40).
 */
async function isPromptScriptGenerated(filePath: string): Promise<boolean> {
  try {
    const content = await readFile(filePath, 'utf-8');
    const lines = content.split('\n').slice(0, 50);
    return lines.some((line) => PROMPTSCRIPT_MARKERS.some((marker) => line.includes(marker)));
  } catch {
    // File doesn't exist or can't be read - treat as not generated
    return false;
  }
}

/**
 * User response when prompted about overwriting a file.
 */
type OverwriteResponse = 'yes' | 'no' | 'all';

/**
 * Prompt the user about overwriting a file.
 * Returns 'yes' to overwrite this file, 'no' to skip, 'all' to overwrite all remaining.
 */
async function promptForOverwrite(
  filePath: string,
  services: CliServices
): Promise<OverwriteResponse> {
  const response = await services.prompts.select({
    message: `File exists and was not generated by PromptScript: ${filePath}\nOverwrite?`,
    choices: [
      { name: 'No (skip this file)', value: 'no' as const },
      { name: 'Yes (overwrite this file)', value: 'yes' as const },
      { name: 'All (overwrite all remaining files)', value: 'all' as const },
    ],
    default: 'no' as const,
  });
  return response;
}

/**
 * Resolve universalDir config to NativeSkillOptions.
 * Default (undefined/true) → '.agents', false → disabled, string → custom path.
 */
function resolveUniversalDir(
  universalDir: string | boolean | undefined
): { universalDir: string } | undefined {
  if (universalDir === false) return undefined;
  if (typeof universalDir === 'string') return { universalDir };
  // Default: enabled with '.agents'
  return { universalDir: '.agents' };
}

/**
 * Result of writing outputs.
 */
interface WriteResult {
  written: string[];
  skipped: string[];
  unchanged: string[];
}

/**
 * Print compilation errors to console.
 */
function printErrors(errors: CompileResult['errors']): void {
  for (const err of errors) {
    ConsoleOutput.error(err.message);
    if (err.location) {
      ConsoleOutput.muted(`at ${err.location.file}:${err.location.line}:${err.location.column}`);
    }
  }
}

/**
 * Write output files to disk or show dry-run preview.
 * Handles overwrite protection for user files.
 */
async function writeOutputs(
  outputs: Map<string, FormatterOutput>,
  options: CompileOptions,
  _config: PromptScriptConfig,
  services: CliServices
): Promise<WriteResult> {
  const result: WriteResult = { written: [], skipped: [], unchanged: [] };
  let overwriteAll = false;
  const conflicts: string[] = [];
  const targetErrors: string[] = [];

  /** Write a file with graceful handling for ENOTDIR/EEXIST conflicts. */
  async function safeWrite(path: string, content: string): Promise<boolean> {
    try {
      await mkdir(dirname(path), { recursive: true });
      await writeFile(path, content, 'utf-8');
      return true;
    } catch (err: unknown) {
      const nodeErr = err as NodeJS.ErrnoException;
      if (nodeErr.code === 'ENOTDIR') {
        const conflictPath = nodeErr.path ?? dirname(path);
        const msg = `Cannot create directory because '${conflictPath}' already exists as a file. Remove it or disable this target.`;
        targetErrors.push(msg);
        ConsoleOutput.error(msg);
        return false;
      }
      if (nodeErr.code === 'EISDIR') {
        const msg = `Cannot write '${path}' because a directory exists at that path.`;
        targetErrors.push(msg);
        ConsoleOutput.error(msg);
        return false;
      }
      throw err;
    }
  }

  for (const output of outputs.values()) {
    const outputPath = options.output ? resolve(options.output, output.path) : resolve(output.path);

    // Check if file exists
    const fileExists = existsSync(outputPath);

    if (options.dryRun) {
      // In dry-run mode, show what would happen
      if (fileExists) {
        const isGenerated = await isPromptScriptGenerated(outputPath);
        if (isGenerated) {
          // Check if content would actually change
          const existingContent = await readFile(outputPath, 'utf-8');
          const existingWithoutMarker = stripMarkers(existingContent);
          const newWithoutMarker = stripMarkers(output.content);

          if (existingWithoutMarker === newWithoutMarker) {
            ConsoleOutput.dryRun(`Unchanged: ${outputPath}`);
            result.unchanged.push(outputPath);
          } else {
            ConsoleOutput.dryRun(`Would overwrite: ${outputPath}`);
            result.written.push(outputPath);
          }
        } else {
          // No marker — check raw content match (handles resource files)
          const existingContent = await readFile(outputPath, 'utf-8');
          if (existingContent === output.content) {
            ConsoleOutput.dryRun(`Unchanged: ${outputPath}`);
            result.unchanged.push(outputPath);
          } else {
            ConsoleOutput.warning(`Would conflict: ${outputPath} (not generated by PromptScript)`);
            result.written.push(outputPath);
          }
        }
      } else {
        ConsoleOutput.dryRun(`Would write: ${outputPath}`);
        result.written.push(outputPath);
      }
      continue;
    }

    // File doesn't exist - write normally
    if (!fileExists) {
      if (await safeWrite(outputPath, output.content)) {
        ConsoleOutput.success(outputPath);
        result.written.push(outputPath);
      }
      continue;
    }

    // File exists - check if it was generated by PromptScript
    const isGenerated = await isPromptScriptGenerated(outputPath);

    if (isGenerated) {
      // Compare content without markers to avoid unnecessary writes
      const existingContent = await readFile(outputPath, 'utf-8');
      const existingWithoutMarker = stripMarkers(existingContent);
      const newWithoutMarker = stripMarkers(output.content);

      if (!options.force && existingWithoutMarker === newWithoutMarker) {
        // Content unchanged - skip writing to preserve original timestamp
        ConsoleOutput.unchanged(outputPath);
        result.unchanged.push(outputPath);
        continue;
      }

      // Content changed - write with new marker
      if (await safeWrite(outputPath, output.content)) {
        ConsoleOutput.success(outputPath);
        result.written.push(outputPath);
      }
      continue;
    }

    // File exists without PromptScript marker. Check if the content matches
    // what we would write — this handles skill resource files (.py, .csv, .html)
    // that were written by a previous compilation and don't have markers.
    let contentMatches = false;
    try {
      const existingContent = await readFile(outputPath, 'utf-8');
      contentMatches = existingContent === output.content;
    } catch {
      // Can't read file — fall through to overwrite protection
    }

    if (!options.force && contentMatches) {
      ConsoleOutput.unchanged(outputPath);
      result.unchanged.push(outputPath);
      continue;
    }

    // Content differs or can't be read — could be a resource file we wrote before
    // or a user-created file (needs protection). Check for --force flag or overwriteAll.
    if (options.force || overwriteAll) {
      if (await safeWrite(outputPath, output.content)) {
        ConsoleOutput.success(outputPath);
        result.written.push(outputPath);
      }
      continue;
    }

    // Check if we're in an interactive terminal
    if (!isTTY()) {
      // Non-interactive mode - collect conflicts for error
      conflicts.push(outputPath);
      continue;
    }

    // Interactive mode - prompt user
    const response = await promptForOverwrite(outputPath, services);

    switch (response) {
      case 'yes':
        if (await safeWrite(outputPath, output.content)) {
          ConsoleOutput.success(outputPath);
          result.written.push(outputPath);
        }
        break;
      case 'no':
        ConsoleOutput.skipped(outputPath);
        result.skipped.push(outputPath);
        break;
      case 'all':
        overwriteAll = true;
        if (await safeWrite(outputPath, output.content)) {
          ConsoleOutput.success(outputPath);
          result.written.push(outputPath);
        }
        break;
    }
  }

  // If there were target-level filesystem errors, fail
  if (targetErrors.length > 0) {
    throw new Error(
      `Some targets failed to write output:\n${targetErrors.map((e) => `  - ${e}`).join('\n')}`
    );
  }

  // If there were conflicts in non-interactive mode, fail with error
  if (conflicts.length > 0) {
    const fileList = conflicts.map((f) => `  - ${f}`).join('\n');
    throw new Error(
      `Cannot overwrite files not generated by PromptScript in non-interactive mode:\n${fileList}\n\nUse --force to overwrite these files.`
    );
  }

  return result;
}

/**
 * Print compilation stats.
 */
function printStats(stats: CompileResult['stats']): void {
  ConsoleOutput.newline();
  ConsoleOutput.stats(
    `Stats: ${stats.totalTime}ms ` +
      `(resolve: ${stats.resolveTime}ms, ` +
      `validate: ${stats.validateTime}ms, ` +
      `format: ${stats.formatTime}ms)`
  );
}

/**
 * Print compilation warnings.
 */
function printWarnings(warnings: CompileResult['warnings']): void {
  if (warnings.length > 0) {
    ConsoleOutput.newline();
    console.log(`Warnings (${warnings.length}):`);
    for (const warn of warnings) {
      ConsoleOutput.warning(`${warn.ruleId}: ${warn.message}`);
    }
  }
}

/**
 * Compile PromptScript files to target formats.
 */
export async function compileCommand(
  options: CompileOptions,
  services: CliServices = createDefaultServices()
): Promise<void> {
  const spinner = createSpinner('Loading configuration...').start();
  const logger = createCliLogger();

  try {
    // Resolve project root from --cwd option or current working directory
    const projectRoot = options.cwd ? resolve(options.cwd) : process.cwd();

    logger.verbose('Loading configuration...');
    // When --cwd is set, resolve config relative to project root
    const configPath = options.config
      ? options.config
      : options.cwd
        ? findConfigInDir(projectRoot)
        : undefined;
    const config = await loadConfig(configPath);
    const buildProfile = getBuildProfile(config, options.build);

    // --format is an alias for --target
    const selectedTarget = options.target ?? options.format;
    const targetEntries = buildProfile?.targets ?? config.targets;
    const targets = selectedTarget ? [{ name: selectedTarget }] : parseTargets(targetEntries);

    // Detect output path conflicts before doing any work
    const conflicts = detectOutputConflicts(targets);
    if (conflicts.size > 0) {
      const lines = [...conflicts.entries()].map(
        ([path, names]) => `  ${path} <- ${names.join(', ')}`
      );
      if (options.strict) {
        spinner.fail('Output path conflict detected');
        ConsoleOutput.error(
          `Output path conflict detected:\n${lines.join('\n')}\n\nDisable one of the conflicting targets or assign a custom output path.`
        );
        process.exitCode = 1;
        return;
      }
      for (const line of lines) {
        ConsoleOutput.warning(`Output path conflict: ${line.trim()}. Last target processed wins.`);
      }
    }

    // Resolve registry path - use CLI flag override, or resolve from config (handles git registries)
    let registryPath: string;
    if (options.registry) {
      registryPath = options.registry;
    } else {
      spinner.text = 'Resolving registry...';
      const registry = await resolveRegistryPath(config);
      registryPath = registry.path;
      if (registry.isRemote) {
        logger.verbose(`Using cached git registry: ${registryPath}`);
      }
    }

    // Read lockfile if it exists
    let lockfile: Lockfile | undefined;
    const lockfilePath = resolve(projectRoot, 'promptscript.lock');
    try {
      const lockfileContent = await readFile(lockfilePath, 'utf-8');
      const parsed = parseYaml(lockfileContent, { maxAliasCount: 100 });
      if (isValidLockfile(parsed)) {
        lockfile = parsed;
        logger.verbose(`Loaded lockfile from ${lockfilePath}`);
      } else {
        logger.verbose(`Lockfile at ${lockfilePath} has invalid format — ignoring`);
      }
    } catch {
      // No lockfile present, that's fine
    }

    spinner.text = 'Compiling...';
    logger.verbose(`Registry: ${registryPath}`);
    logger.debug(`Config: ${JSON.stringify(config, null, 2)}`);

    // Resolve Prettier options from config
    const prettierOptions = await resolvePrettierOptions(config, projectRoot);

    // Load bundled PromptScript skill if not disabled.
    // Design decision: The CLI checks the config flag and decides whether to pass skillContent.
    // The Compiler simply checks if skillContent was provided — it does NOT read config.
    // This keeps the Compiler's API clean (provide content = inject, omit = skip).
    let skillContent: string | undefined;
    if (config.includePromptScriptSkill !== false) {
      skillContent = await loadBundledSkillContent(logger);
    }

    const localPath = resolve(projectRoot, '.promptscript');
    const compiler = new Compiler({
      resolver: {
        registryPath,
        localPath,
        projectRoot,
        skills: resolveUniversalDir(config.universalDir),
        registries: config.registries,
        lockfile,
        skillTargets: config.skillTargets,
      },
      validator: config.validation,
      formatters: targets,
      customConventions: config.customConventions,
      prettier: prettierOptions,
      logger,
      skillContent,
      ignoreHashes: options.ignoreHashes,
    });

    const entry = buildProfile?.entry ?? config.input?.entry;
    const entryPath = entry ? resolve(projectRoot, entry) : resolve(localPath, 'project.prs');

    if (!existsSync(entryPath)) {
      spinner.fail('Entry file not found');
      ConsoleOutput.error(`File not found: ${entryPath}`);
      ConsoleOutput.muted('Run: prs init');
      process.exitCode = 1;
      return;
    }

    const result = await compiler.compile(entryPath);

    if (!result.success) {
      spinner.fail('Compilation failed');
      ConsoleOutput.newline();
      printErrors(result.errors);
      process.exitCode = 1;
      return;
    }

    spinner.succeed('Compilation successful');
    ConsoleOutput.newline();

    // Hint about lockfile if registries are configured but no lockfile exists
    if (config.registries && Object.keys(config.registries).length > 0 && !lockfile) {
      logger.verbose('Tip: Run "prs lock" to pin remote dependencies for reproducible builds.');
    }

    const configuredOutput = options.output ?? buildProfile?.output ?? config.output?.baseDir;
    const effectiveOptions = {
      ...options,
      output: resolveOutputBase(projectRoot, configuredOutput),
    };
    await postFormatWithPrettier(result.outputs, projectRoot, logger);
    const writeResult = await writeOutputs(result.outputs, effectiveOptions, config, services);
    if (writeResult.unchanged.length > 0) {
      ConsoleOutput.muted(`Unchanged ${writeResult.unchanged.length} file(s)`);
    }
    if (writeResult.skipped.length > 0) {
      ConsoleOutput.muted(`Skipped ${writeResult.skipped.length} file(s)`);
    }
    printStats(result.stats);
    printWarnings(result.warnings);

    // Watch mode
    if (options.watch) {
      ConsoleOutput.newline();
      ConsoleOutput.info('Watching for changes... (Ctrl+C to stop)');
      watchForChanges(resolve(projectRoot, '.promptscript'), () =>
        compileCommand({ ...options, watch: false }, services)
      );
    }
  } catch (error) {
    if (error instanceof Error && error.name === 'ExitPromptError') {
      // User cancelled with Ctrl+C during prompt
      spinner.stop();
      ConsoleOutput.newline();
      ConsoleOutput.muted('Compilation cancelled');
      return;
    }
    spinner.fail('Error');
    ConsoleOutput.error(error instanceof Error ? error.message : String(error));
    process.exitCode = 1;
  }
}

/**
 * Watch directory for changes using chokidar and trigger recompilation.
 *
 * Uses chokidar for better cross-platform support and reliability compared
 * to native fs.watch.
 */
function watchForChanges(dir: string, callback: () => void): void {
  let debounceTimer: NodeJS.Timeout | null = null;
  const debounceMs = 100;

  const watcher = chokidar.watch(`${dir}/**/*.prs`, {
    persistent: true,
    ignoreInitial: true,
    awaitWriteFinish: {
      stabilityThreshold: 50,
      pollInterval: 10,
    },
  });

  watcher.on('change', (filename) => {
    // Debounce rapid changes
    if (debounceTimer) {
      clearTimeout(debounceTimer);
    }

    debounceTimer = setTimeout(() => {
      ConsoleOutput.newline();
      ConsoleOutput.info(`File changed: ${filename}`);
      callback();
    }, debounceMs);
  });

  watcher.on('add', (filename) => {
    ConsoleOutput.info(`File added: ${filename}`);
    callback();
  });

  watcher.on('unlink', (filename) => {
    ConsoleOutput.info(`File removed: ${filename}`);
  });

  watcher.on('error', (error: unknown) => {
    const errorMessage = error instanceof Error ? error.message : String(error);
    ConsoleOutput.error(`Watcher error: ${errorMessage}`);
  });
}
