import { noopLogger, type Logger, type PSError } from '@promptscript/core';
import { FormatterRegistry } from '@promptscript/formatters';
import { Resolver, type ResolvedAST } from '@promptscript/resolver';
import { Validator, type ValidatorConfig, type ValidationMessage } from '@promptscript/validator';
import { relative, isAbsolute } from 'path';
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- imported for upcoming formatter integration
import { verifyReferenceIntegrity } from './reference-verifier.js';
import type {
  CompilerOptions,
  CompileResult,
  CompileStats,
  CompileError,
  Formatter,
  FormatterOutput,
  FormatterConstructor,
  TargetConfig,
  WatchOptions,
  Watcher,
} from './types.js';

/**
 * Internal representation of a loaded formatter with its config.
 */
interface LoadedFormatter {
  formatter: Formatter;
  config?: TargetConfig;
}

/**
 * Generate HTML marker comment that identifies files generated by PromptScript.
 * Uses HTML comment to be invisible to LLMs and rendered output.
 */
function generateHtmlMarker(source: string, target: string): string {
  const timestamp = new Date().toISOString();
  return `<!-- PromptScript ${timestamp} | source: ${source} | target: ${target} - do not edit -->`;
}

/**
 * Generate YAML comment marker for use inside YAML frontmatter.
 * Some tools (e.g. Factory AI) cannot parse HTML comments after frontmatter,
 * so we embed the marker as a YAML comment inside the frontmatter block.
 */
function generateYamlMarker(source: string, target: string): string {
  const timestamp = new Date().toISOString();
  return `# promptscript-generated: ${timestamp} | source: ${source} | target: ${target}`;
}

/**
 * Add PromptScript marker to output content if not already present.
 *
 * For files with YAML frontmatter: inserts a YAML comment inside the frontmatter.
 * This avoids breaking tools like Factory AI that cannot parse HTML comments
 * between frontmatter and content body.
 *
 * For files without frontmatter: inserts an HTML comment at the top.
 */
function addMarkerToOutput(
  output: FormatterOutput,
  source: string,
  target: string
): FormatterOutput {
  const { content, path } = output;

  // Skip if marker already present (check for both HTML and YAML patterns)
  if (content.includes('<!-- PromptScript') || content.includes('# promptscript-generated:')) {
    return output;
  }

  // Only add to markdown files
  if (!path.endsWith('.md') && !path.endsWith('.mdc')) {
    return output;
  }

  // Check for YAML frontmatter (starts with ---)
  if (content.startsWith('---')) {
    // Find the closing ---
    const closingIndex = content.indexOf('---', 3);
    if (closingIndex !== -1) {
      // Insert YAML comment marker as first line inside frontmatter
      const afterOpening = content.slice(3); // skip opening ---
      const newContent = `---\n${generateYamlMarker(source, target)}${afterOpening}`;
      return { ...output, content: newContent };
    }
  }

  // No frontmatter - add HTML marker at the beginning (after any title)
  const marker = generateHtmlMarker(source, target);
  const lines = content.split('\n');
  if (lines[0]?.startsWith('# ')) {
    // Has title - insert after title
    lines.splice(1, 0, '', marker);
  } else {
    // No title - insert at beginning
    lines.unshift(marker, '');
  }
  return { ...output, content: lines.join('\n') };
}

function normalizeOutputDir(dir: string): string {
  return dir
    .replace(/\\/g, '/')
    .replace(/^\/+/, '')
    .replace(/\/+$/g, '')
    .split('/')
    .filter((segment) => segment.length > 0 && segment !== '.' && segment !== '..')
    .join('/');
}

function shouldIncludeSkill(name: string, config?: TargetConfig): boolean {
  const includeSkills = config?.includeSkills;
  if (includeSkills === false) return false;
  if (Array.isArray(includeSkills)) return includeSkills.includes(name);
  return true;
}

/**
 * Compiler that orchestrates the PromptScript compilation pipeline.
 *
 * Pipeline stages:
 * 1. Resolve - Parse and resolve inheritance/imports
 * 2. Validate - Check AST against validation rules
 * 3. Format - Generate output for target platforms
 *
 * @example
 * ```typescript
 * const compiler = new Compiler({
 *   resolver: { registryPath: './registry' },
 *   validator: { requiredGuards: ['@core/guards/compliance'] },
 *   formatters: [new GitHubFormatter()],
 * });
 *
 * const result = await compiler.compile('./project.prs');
 * if (result.success) {
 *   for (const [outputPath, output] of result.outputs) {
 *     console.log(`Generated: ${outputPath}`);
 *   }
 * }
 * ```
 */
export class Compiler {
  private readonly resolver: Resolver;
  private readonly validator: Validator;
  private readonly loadedFormatters: LoadedFormatter[];
  private readonly logger: Logger;

  constructor(private readonly options: CompilerOptions) {
    this.logger = options.logger ?? noopLogger;
    this.resolver = new Resolver({ ...options.resolver, logger: this.logger });
    this.validator = new Validator({ ...options.validator, logger: this.logger });
    this.loadedFormatters = this.loadFormatters(options.formatters);

    this.logger.debug(`Compiler initialized with ${this.loadedFormatters.length} formatters`);
  }

  /**
   * Compile a PromptScript file through the full pipeline.
   *
   * @param entryPath - Path to the entry file
   * @returns Compilation result with outputs, errors, and stats
   */
  async compile(entryPath: string): Promise<CompileResult> {
    this.logger.verbose(`Entry: ${entryPath}`);
    this.logger.verbose(
      `Targets: ${this.loadedFormatters.map((f) => f.formatter.name).join(', ')}`
    );

    const startTotal = Date.now();
    const stats: CompileStats = {
      resolveTime: 0,
      validateTime: 0,
      formatTime: 0,
      totalTime: 0,
    };

    // Compute a relative source label for markers so absolute user paths
    // are never leaked into generated output files.
    const sourceLabel = isAbsolute(entryPath) ? relative(process.cwd(), entryPath) : entryPath;

    // Stage 1: Resolve
    this.logger.verbose('=== Stage 1: Resolve ===');
    const startResolve = Date.now();
    let resolved: ResolvedAST;

    try {
      resolved = await this.resolver.resolve(entryPath);
    } catch (err) {
      stats.resolveTime = Date.now() - startResolve;
      stats.totalTime = Date.now() - startTotal;
      this.logger.verbose(`Resolve failed (${stats.resolveTime}ms)`);

      return {
        success: false,
        outputs: new Map(),
        errors: [
          this.toCompileError(
            err instanceof Error
              ? err
              : this.isPSErrorLike(err)
                ? (err as PSError)
                : new Error(String(err))
          ),
        ],
        warnings: [],
        stats,
      };
    }

    stats.resolveTime = Date.now() - startResolve;
    this.logger.verbose(`Resolve completed (${stats.resolveTime}ms)`);

    // Check for resolve errors
    if (resolved.errors.length > 0 || !resolved.ast) {
      stats.totalTime = Date.now() - startTotal;

      return {
        success: false,
        outputs: new Map(),
        errors: resolved.errors.map((e) => this.toCompileError(e)),
        warnings: [],
        stats,
      };
    }

    // Stage 1.5: Reference Integrity
    if (!this.options.ignoreHashes && this.options.resolver.lockfile?.references) {
      this.logger.verbose('=== Stage 1.5: Reference Integrity ===');
      // Collect registry reference paths for the validator
      const registryReferences = new Set<string>();

      for (const block of resolved.ast.blocks) {
        if (block.name !== 'skills' || block.content.type !== 'ObjectContent') continue;

        for (const [, skillValue] of Object.entries(block.content.properties)) {
          if (
            typeof skillValue !== 'object' ||
            skillValue === null ||
            !('references' in skillValue)
          )
            continue;

          const refs = (skillValue as Record<string, unknown>)['references'];
          if (!Array.isArray(refs)) continue;

          for (const ref of refs) {
            if (typeof ref !== 'string') continue;
            registryReferences.add(ref);
          }
        }
      }

      // Pass registry references to validator config for PS031
      if (this.options.validator) {
        this.options.validator.registryReferences = registryReferences;
        this.options.validator.lockfile = this.options.resolver.lockfile;
        this.options.validator.ignoreHashes = this.options.ignoreHashes;
      }
    } else if (this.options.ignoreHashes) {
      this.logger.verbose('--ignore-hashes is set: reference integrity verification is disabled');
      console.error('⚠ --ignore-hashes is set: reference integrity verification is disabled');
      if (this.options.validator) {
        this.options.validator.ignoreHashes = true;
      }
    }

    // Stage 2: Validate
    this.logger.verbose('=== Stage 2: Validate ===');
    const startValidate = Date.now();
    const validation = this.validator.validate(resolved.ast);
    stats.validateTime = Date.now() - startValidate;
    this.logger.verbose(`Validate completed (${stats.validateTime}ms)`);

    // Check for validation errors
    if (!validation.valid) {
      stats.totalTime = Date.now() - startTotal;

      return {
        success: false,
        outputs: new Map(),
        errors: validation.errors.map((e) => this.validationToCompileError(e)),
        warnings: validation.warnings,
        stats,
      };
    }

    // Stage 3: Format
    this.logger.verbose('=== Stage 3: Format ===');
    const startFormat = Date.now();
    const outputs = new Map<string, FormatterOutput>();
    const formatErrors: CompileError[] = [];
    const formatWarnings: ValidationMessage[] = [];
    const outputPathOwners = new Map<string, string>();

    for (const { formatter, config } of this.loadedFormatters) {
      const formatterStart = Date.now();
      this.logger.verbose(`Formatting for ${formatter.name}`);

      try {
        const formatOptions = this.getFormatOptionsForTarget(formatter.name, config);
        this.logger.debug(`  Convention: ${formatOptions.convention ?? 'default'}`);

        const output = formatter.format(resolved.ast, formatOptions);
        const formatterTime = Date.now() - formatterStart;

        this.logger.verbose(`  → ${output.path} (${formatterTime}ms)`);

        // Warn if multiple formatters target the same output path
        const existingOwner = outputPathOwners.get(output.path);
        if (existingOwner) {
          formatWarnings.push({
            ruleId: 'PS4001',
            ruleName: 'output-path-collision',
            severity: 'warning',
            message: `Output path '${output.path}' is written by both '${existingOwner}' and '${formatter.name}'. The latter will overwrite the former.`,
            suggestion: `Configure distinct output paths for these formatters, or disable one of them.`,
          });
        }
        outputPathOwners.set(output.path, formatter.name);

        // Add PromptScript marker to all outputs for overwrite detection
        outputs.set(output.path, addMarkerToOutput(output, sourceLabel, formatter.name));

        // Also add any additional files (e.g., .cursor/commands/, .github/prompts/)
        // Recursively collect nested additionalFiles (e.g., skill resource files)
        if (output.additionalFiles) {
          const queue = [...output.additionalFiles];
          while (queue.length > 0) {
            const additionalFile = queue.shift()!;
            this.logger.verbose(`  → ${additionalFile.path} (additional)`);

            // Check for path collisions with previously written outputs
            const existingAdditionalOwner = outputPathOwners.get(additionalFile.path);
            if (existingAdditionalOwner) {
              formatWarnings.push({
                ruleId: 'PS4001',
                ruleName: 'output-path-collision',
                severity: 'warning',
                message: `Output path '${additionalFile.path}' is written by both '${existingAdditionalOwner}' and '${formatter.name}'. The latter will overwrite the former.`,
                suggestion: `Configure distinct output paths for these formatters, or disable one of them.`,
              });
              // Skip writing this additional file — preserve the original owner's output
              if (additionalFile.additionalFiles) {
                queue.push(...additionalFile.additionalFiles);
              }
              continue;
            }
            outputPathOwners.set(additionalFile.path, formatter.name);

            outputs.set(
              additionalFile.path,
              addMarkerToOutput(additionalFile, sourceLabel, formatter.name)
            );
            if (additionalFile.additionalFiles) {
              queue.push(...additionalFile.additionalFiles);
            }
          }
        }
      } catch (err) {
        formatErrors.push({
          name: 'FormatterError',
          code: 'PS4000',
          message: `Formatter '${formatter.name}' failed: ${err instanceof Error ? err.message : String(err)}`,
        });
      }
    }

    // Inject PromptScript SKILL.md into each formatter's skill directory
    if (this.options.skillContent) {
      for (const { formatter, config } of this.loadedFormatters) {
        if (!shouldIncludeSkill('promptscript', config)) {
          this.logger.debug(
            `  Skipping skill injection for ${formatter.name} (promptscript not included)`
          );
          continue;
        }

        const skillBasePath = config?.skillBaseDir
          ? normalizeOutputDir(config.skillBaseDir)
          : formatter.getSkillBasePath();
        const skillFileName = formatter.getSkillFileName();
        if (!skillBasePath || !skillFileName) {
          this.logger.debug(`  Skipping skill injection for ${formatter.name} (no skill support)`);
          continue;
        }

        const skillPath = `${skillBasePath}/promptscript/${skillFileName}`;
        const existingOwner = outputPathOwners.get(skillPath);
        if (existingOwner) {
          // If the same formatter already output the promptscript skill (e.g. via
          // auto-discovered @skills block), silently skip — the content is identical.
          // Only warn when a *different* formatter caused the collision.
          if (existingOwner !== formatter.name) {
            formatWarnings.push({
              ruleId: 'PS4001',
              ruleName: 'output-path-collision',
              severity: 'warning',
              message: `Output path '${skillPath}' is already written by '${existingOwner}'. Skipping auto-injected PromptScript skill for '${formatter.name}'.`,
              suggestion: `The user-defined skill takes precedence. To use the bundled skill, remove the custom one or rename it.`,
            });
          } else {
            this.logger.debug(
              `  Skill path '${skillPath}' already output by '${formatter.name}' (auto-discovered). Skipping auto-injection.`
            );
          }
          continue;
        }
        outputPathOwners.set(skillPath, `${formatter.name}:promptscript-skill`);

        const skillOutput: FormatterOutput = {
          path: skillPath,
          content: formatter.transformInjectedSkillContent
            ? formatter.transformInjectedSkillContent(this.options.skillContent)
            : this.options.skillContent,
        };
        outputs.set(skillPath, addMarkerToOutput(skillOutput, sourceLabel, 'promptscript'));
        this.logger.verbose(`  → ${skillPath} (auto-injected promptscript skill)`);
      }
    }

    stats.formatTime = Date.now() - startFormat;
    stats.totalTime = Date.now() - startTotal;
    this.logger.verbose(`Format completed (${stats.formatTime}ms)`);

    const allWarnings = [...validation.warnings, ...formatWarnings];

    if (formatErrors.length > 0) {
      return {
        success: false,
        outputs,
        errors: formatErrors,
        warnings: allWarnings,
        stats,
      };
    }

    return {
      success: true,
      outputs,
      errors: [],
      warnings: allWarnings,
      stats,
    };
  }

  /**
   * Get the configured formatters.
   */
  getFormatters(): readonly Formatter[] {
    return this.loadedFormatters.map((lf) => lf.formatter);
  }

  /**
   * Compile a PromptScript file from a file path.
   * This is an alias for compile() for consistency with the documented API.
   *
   * @param filePath - Path to the PromptScript file
   * @returns Compilation result
   */
  async compileFile(filePath: string): Promise<CompileResult> {
    return this.compile(filePath);
  }

  /**
   * Compile to all registered formatters.
   * Useful when you want to ensure all formatters are used regardless of config.
   *
   * @param entryPath - Path to the entry file
   * @returns Compilation result with all formatter outputs
   */
  async compileAll(entryPath: string): Promise<CompileResult> {
    // Get all registered formatters
    const allFormatters = FormatterRegistry.list();

    // Create a new compiler with all formatters
    const compiler = new Compiler({
      ...this.options,
      formatters: allFormatters,
    });

    return compiler.compile(entryPath);
  }

  /**
   * Watch for file changes and recompile automatically.
   *
   * @param entryPath - Path to the entry file to compile
   * @param options - Watch options
   * @returns Watcher handle to control the watch process
   *
   * @example
   * ```typescript
   * const watcher = await compiler.watch('./project.prs', {
   *   onCompile: (result) => {
   *     if (result.success) {
   *       console.log('Compiled successfully');
   *     }
   *   },
   * });
   *
   * // Later, stop watching
   * await watcher.close();
   * ```
   */
  async watch(entryPath: string, options: WatchOptions = {}): Promise<Watcher> {
    // Dynamic import of chokidar to avoid bundling issues
    const { default: chokidar } = await import('chokidar');
    const { dirname, resolve } = await import('path');

    const baseDir = dirname(resolve(entryPath));
    const includePatterns = options.include ?? ['**/*.prs'];
    const excludePatterns = options.exclude ?? ['**/node_modules/**'];
    const debounceMs = options.debounce ?? 300;

    // Build watch patterns
    const watchPatterns = includePatterns.map((p) => resolve(baseDir, p));

    let debounceTimer: ReturnType<typeof setTimeout> | null = null;
    let pendingChanges: string[] = [];

    const handleChange = async (changedFiles: string[]): Promise<void> => {
      try {
        const result = await this.compile(entryPath);
        options.onCompile?.(result, changedFiles);
      } catch (error) {
        options.onError?.(error instanceof Error ? error : new Error(String(error)));
      }
    };

    const watcher = chokidar.watch(watchPatterns, {
      ignored: excludePatterns,
      persistent: true,
      ignoreInitial: true,
    });

    const scheduleRecompile = (path: string): void => {
      pendingChanges.push(path);

      if (debounceTimer) {
        clearTimeout(debounceTimer);
      }

      debounceTimer = setTimeout(() => {
        const files = [...pendingChanges];
        pendingChanges = [];
        handleChange(files);
      }, debounceMs);
    };

    watcher.on('change', scheduleRecompile);
    watcher.on('add', scheduleRecompile);

    watcher.on('error', (error: unknown) => {
      options.onError?.(error instanceof Error ? error : new Error(String(error)));
    });

    return {
      close: async () => {
        if (debounceTimer) {
          clearTimeout(debounceTimer);
        }
        await watcher.close();
      },
    };
  }

  /**
   * Get format options for a specific target.
   */
  private getFormatOptionsForTarget(
    _targetName: string,
    config?: TargetConfig
  ): import('./types.js').FormatOptions {
    const customConventions = this.options.customConventions;
    const prettierOptions = this.options.prettier;

    const options: import('./types.js').FormatOptions = {
      outputPath: config?.output,
      version: config?.version,
      prettier: prettierOptions,
      targetConfig: config,
    };

    const conventionName = config?.convention;

    // Check if it's a custom convention
    if (conventionName && customConventions?.[conventionName]) {
      options.convention = customConventions[conventionName];
    } else if (conventionName) {
      options.convention = conventionName;
    }

    return options;
  }

  /**
   * Load and instantiate formatters from options.
   */
  private loadFormatters(formatters: CompilerOptions['formatters']): LoadedFormatter[] {
    return formatters.map((f) => {
      // String name
      if (typeof f === 'string') {
        return { formatter: this.loadFormatterByName(f) };
      }

      // Check if it's a constructor (function)
      if (typeof f === 'function') {
        const Ctor = f as FormatterConstructor;
        return { formatter: new Ctor() };
      }

      // Object with name and config (not a Formatter instance)
      if ('name' in f && typeof f.name === 'string' && !('format' in f)) {
        const configObj = f as { name: string; config?: TargetConfig };
        return {
          formatter: this.loadFormatterByName(configObj.name),
          config: configObj.config,
        };
      }

      // Already a Formatter instance
      return { formatter: f as Formatter };
    });
  }

  /**
   * Dynamically load a formatter by name.
   *
   * Uses the FormatterRegistry to look up registered formatters.
   */
  private loadFormatterByName(name: string): Formatter {
    const formatter = FormatterRegistry.get(name);
    if (formatter) {
      return formatter;
    }
    throw new Error(
      `Unknown formatter: '${name}'. Available formatters: ${FormatterRegistry.list().join(', ')}`
    );
  }

  /**
   * Check if a value looks like a PSError (has code, message, and format).
   */
  private isPSErrorLike(err: unknown): err is PSError {
    return (
      typeof err === 'object' &&
      err !== null &&
      'message' in err &&
      'code' in err &&
      typeof (err as PSError).message === 'string'
    );
  }

  /**
   * Convert any error to a CompileError.
   */
  private toCompileError(err: Error | PSError): CompileError {
    const psError = err as PSError;

    return {
      name: err.name,
      code: psError.code ?? 'PS0000',
      message: err.message,
      location: psError.location
        ? {
            file: psError.location.file,
            line: psError.location.line,
            column: psError.location.column,
          }
        : undefined,
      format: psError.format ? () => psError.format() : undefined,
    };
  }

  /**
   * Convert a validation message to a CompileError.
   */
  private validationToCompileError(
    msg: import('@promptscript/validator').ValidationMessage
  ): CompileError {
    return {
      name: 'ValidationError',
      code: msg.ruleId,
      message: msg.message,
      location: msg.location
        ? {
            file: msg.location.file,
            line: msg.location.line,
            column: msg.location.column,
          }
        : undefined,
    };
  }
}

/**
 * Create a new compiler instance.
 *
 * @param options - Compiler options
 * @returns New Compiler instance
 */
export function createCompiler(options: CompilerOptions): Compiler {
  return new Compiler(options);
}

/**
 * Options for the standalone compile function.
 */
export interface CompileOptions {
  /**
   * Resolver options for resolving imports and inheritance.
   * If not provided, defaults to current working directory.
   */
  resolver?: {
    /** Base path for registry lookups (@namespace/...). Defaults to cwd. */
    registryPath?: string;
    /** Base path for local/relative file resolution. Defaults to cwd. */
    localPath?: string;
    /** Whether to cache resolved ASTs. Defaults to true. */
    cache?: boolean;
  };
  /**
   * Validator configuration.
   */
  validator?: ValidatorConfig;
  /**
   * Formatters to use. If not specified, all built-in formatters are used.
   */
  formatters?: CompilerOptions['formatters'];
  /**
   * Custom conventions for formatters.
   */
  customConventions?: CompilerOptions['customConventions'];
  /**
   * Prettier formatting options for markdown output.
   */
  prettier?: CompilerOptions['prettier'];
  /**
   * Content of the PromptScript SKILL.md to inject into compilation output.
   */
  skillContent?: string;
}

/**
 * Compile a PromptScript file using default or specified options.
 *
 * This is a convenience function that creates a Compiler instance
 * and runs the compilation pipeline. For repeated compilations,
 * consider creating a Compiler instance directly for better performance.
 *
 * @param entryPath - Path to the entry PromptScript file
 * @param options - Optional compilation options
 * @returns Compilation result with outputs, errors, and stats
 *
 * @example
 * ```typescript
 * import { compile } from '@promptscript/compiler';
 *
 * // Simple usage with defaults
 * const result = await compile('./project.prs');
 *
 * // With custom options
 * const result = await compile('./project.prs', {
 *   formatters: ['github', 'claude'],
 *   resolver: { registryPath: './registry' },
 * });
 *
 * if (result.success) {
 *   for (const [path, output] of result.outputs) {
 *     console.log(`Generated: ${path}`);
 *   }
 * }
 * ```
 */
export async function compile(
  entryPath: string,
  options: CompileOptions = {}
): Promise<CompileResult> {
  // Get default formatters from registry if not specified
  const formatters: CompilerOptions['formatters'] = options.formatters ?? FormatterRegistry.list();

  // Build resolver options with sensible defaults
  const resolverOptions = {
    registryPath: options.resolver?.registryPath ?? process.cwd(),
    localPath: options.resolver?.localPath,
    cache: options.resolver?.cache,
  };

  const compiler = new Compiler({
    resolver: resolverOptions,
    validator: options.validator,
    formatters,
    customConventions: options.customConventions,
    prettier: options.prettier,
    skillContent: options.skillContent,
  });

  return compiler.compile(entryPath);
}
