import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { fileURLToPath } from 'node:url';
import { spawnSync, execFileSync } from 'node:child_process';
import yaml from 'js-yaml';
import { log } from './logger.js';
import { EXIT_CODES, getErrorMessage } from './errors.js';

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

export interface ExternalCliInstall {
  mac?: string;
  linux?: string;
  windows?: string;
  default?: string;
}

export interface ExternalCliConfig {
  /** User-facing OpenCLI subcommand and, by default, the executable name. */
  name: string;
  binary: string;
  /**
   * Display alias rendered alongside `name` in help/listing as `name(package)`.
   * Use either the upstream distribution/project name (e.g. `tg-cli`, `discord-cli`)
   * or a human-readable brand label (e.g. `notion`, `企业微信`) when the bare
   * executable name is ambiguous.
   */
  package?: string;
  description?: string;
  homepage?: string;
  tags?: string[];
  install?: ExternalCliInstall;
}

function getUserRegistryPath(): string {
  const home = os.homedir();
  return path.join(home, '.opencli', 'external-clis.yaml');
}

let _cachedExternalClis: ExternalCliConfig[] | null = null;

export function loadExternalClis(): ExternalCliConfig[] {
  if (_cachedExternalClis) return _cachedExternalClis;
  const configs = new Map<string, ExternalCliConfig>();

  // 1. Load built-in
  const builtinPath = path.resolve(__dirname, 'external-clis.yaml');
  try {
    if (fs.existsSync(builtinPath)) {
      const raw = fs.readFileSync(builtinPath, 'utf8');
      const parsed = (yaml.load(raw) || []) as ExternalCliConfig[];
      for (const item of parsed) configs.set(item.name, item);
    }
  } catch (err) {
    log.warn(`Failed to parse built-in external-clis.yaml: ${getErrorMessage(err)}`);
  }

  // 2. Load user custom
  const userPath = getUserRegistryPath();
  try {
    if (fs.existsSync(userPath)) {
      const raw = fs.readFileSync(userPath, 'utf8');
      const parsed = (yaml.load(raw) || []) as ExternalCliConfig[];
      for (const item of parsed) {
        configs.set(item.name, item); // Overwrite built-in if duplicated
      }
    }
  } catch (err) {
    log.warn(`Failed to parse user external-clis.yaml: ${getErrorMessage(err)}`);
  }

  _cachedExternalClis = Array.from(configs.values()).sort((a, b) => a.name.localeCompare(b.name));
  return _cachedExternalClis;
}

export function isBinaryInstalled(binary: string): boolean {
  try {
    const isWindows = os.platform() === 'win32';
    execFileSync(isWindows ? 'where' : 'which', [binary], { stdio: 'ignore' });
    return true;
  } catch {
    return false;
  }
}

export function getInstallCmd(installConfig?: ExternalCliInstall): string | null {
  if (!installConfig) return null;
  const platform = os.platform();
  if (platform === 'darwin' && installConfig.mac) return installConfig.mac;
  if (platform === 'linux' && installConfig.linux) return installConfig.linux;
  if (platform === 'win32' && installConfig.windows) return installConfig.windows;
  if (installConfig.default) return installConfig.default;
  return null;
}

export function formatExternalCliLabel(cli: ExternalCliConfig): string {
  return cli.package && cli.package !== cli.name ? `${cli.name}(${cli.package})` : cli.name;
}

/**
 * Safely parses a command string into a binary and argument list.
 * Rejects commands containing shell operators (&&, ||, |, ;, >, <, `) that
 * cannot be safely expressed as execFileSync arguments.
 *
 * Args:
 *   cmd: Raw command string from YAML config (e.g. "brew install gh")
 *
 * Returns:
 *   Object with `binary` and `args` fields, or throws on unsafe input.
 */
export function parseCommand(cmd: string): { binary: string; args: string[] } {
  const shellOperators = /&&|\|\|?|;|[><`$#\n\r]|\$\(/;
  if (shellOperators.test(cmd)) {
    throw new Error(
      `Install command contains unsafe shell operators and cannot be executed securely: "${cmd}". ` +
        `Please install the tool manually.`
    );
  }

  // Tokenise respecting single- and double-quoted segments (no variable expansion).
  const tokens: string[] = [];
  const re = /(?:"([^"]*)")|(?:'([^']*)')|(\S+)/g;
  let match: RegExpExecArray | null;
  while ((match = re.exec(cmd)) !== null) {
    tokens.push(match[1] ?? match[2] ?? match[3]);
  }

  if (tokens.length === 0) {
    throw new Error(`Install command is empty.`);
  }

  const [binary, ...args] = tokens;
  return { binary, args };
}

function shouldRetryWithCmdShim(binary: string, err: unknown): boolean {
  const code = err instanceof Error ? (err as NodeJS.ErrnoException).code : undefined;
  return os.platform() === 'win32' && !path.extname(binary) && code === 'ENOENT';
}

function runInstallCommand(cmd: string): void {
  const { binary, args } = parseCommand(cmd);

  try {
    execFileSync(binary, args, { stdio: 'inherit' });
  } catch (err) {
    if (shouldRetryWithCmdShim(binary, err)) {
      execFileSync(`${binary}.cmd`, args, { stdio: 'inherit' });
      return;
    }
    throw err;
  }
}

export function installExternalCli(cli: ExternalCliConfig): boolean {
  if (!cli.install) {
    log.error(`No auto-install command configured for '${cli.name}'.`);
    log.info(`Please install '${cli.binary}' manually.`);
    return false;
  }

  const cmd = getInstallCmd(cli.install);
  if (!cmd) {
    log.error(`No install command for your platform (${os.platform()}) for '${cli.name}'.`);
    if (cli.homepage) log.info(`See: ${cli.homepage}`);
    return false;
  }

  log.info(`'${cli.name}' is not installed. Auto-installing...`);
  log.verbose(`$ ${cmd}`);
  try {
    runInstallCommand(cmd);
    log.success(`Installed '${cli.name}' successfully.`);
    return true;
  } catch (err) {
    log.error(`Failed to install '${cli.name}': ${getErrorMessage(err)}`);
    return false;
  }
}

export function executeExternalCli(name: string, args: string[], preloaded?: ExternalCliConfig[]): void {
  const configs = preloaded ?? loadExternalClis();
  const cli = configs.find((c) => c.name === name);
  if (!cli) {
    throw new Error(`External CLI '${name}' not found in registry.`);
  }

  // 1. Check if installed
  if (!isBinaryInstalled(cli.binary)) {
    // 2. Try to auto install
    const success = installExternalCli(cli);
    if (!success) {
      process.exitCode = EXIT_CODES.SERVICE_UNAVAIL;
      return;
    }
  }

  // 3. Passthrough execution with stdio inherited
  const result = spawnSync(cli.binary, args, { stdio: 'inherit' });
  if (result.error) {
    log.error(`Failed to execute '${cli.binary}': ${result.error.message}`);
    process.exitCode = EXIT_CODES.GENERIC_ERROR;
    return;
  }
  
  if (result.status !== null) {
    process.exitCode = result.status;
  }
}

export interface RegisterOptions {
  binary?: string;
  install?: string;
  description?: string;
}

export function registerExternalCli(name: string, opts?: RegisterOptions): void {
  const userPath = getUserRegistryPath();
  const configDir = path.dirname(userPath);

  if (!fs.existsSync(configDir)) {
    fs.mkdirSync(configDir, { recursive: true });
  }

  let items: ExternalCliConfig[] = [];
  if (fs.existsSync(userPath)) {
    try {
      const raw = fs.readFileSync(userPath, 'utf8');
      items = (yaml.load(raw) || []) as ExternalCliConfig[];
    } catch {
      // Ignore
    }
  }

  const existingIndex = items.findIndex((c) => c.name === name);
  
  const newItem: ExternalCliConfig = {
    name,
    binary: opts?.binary || name,
  };
  if (opts?.description) newItem.description = opts.description;
  if (opts?.install) newItem.install = { default: opts.install };

  if (existingIndex >= 0) {
    items[existingIndex] = { ...items[existingIndex], ...newItem };
    log.success(`Updated '${name}' in user registry.`);
  } else {
    items.push(newItem);
    log.success(`Registered '${name}' in user registry.`);
  }

  const dump = yaml.dump(items, { indent: 2, sortKeys: true });
  fs.writeFileSync(userPath, dump, 'utf8');
  _cachedExternalClis = null; // Invalidate cache so next load reflects the change
  log.verbose(userPath);
}
