import { buildMergedCliPath } from '@main/utils/cliPathMerge';
import { getClaudeBasePath } from '@main/utils/pathDecoder';
import { getShellPreferredHome, resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
import * as fs from 'fs';
import * as path from 'path';

import { getDoctorInvokedCandidates } from './ClaudeDoctorProbe';
import { getConfiguredCliFlavor } from './cliFlavor';

export interface ClaudeBinaryResolveProgress {
  phase: string;
  message: string;
}

export interface ClaudeBinaryResolveOptions {
  onProgress?: (progress: ClaudeBinaryResolveProgress) => void;
}

function emitProgress(
  options: ClaudeBinaryResolveOptions | undefined,
  phase: string,
  message: string
): void {
  options?.onProgress?.({ phase, message });
}

async function isExecutable(filePath: string): Promise<boolean> {
  if (process.platform === 'win32') {
    try {
      const stat = await fs.promises.stat(filePath);
      return stat.isFile();
    } catch {
      return false;
    }
  }

  try {
    await fs.promises.access(filePath, fs.constants.X_OK);
    return true;
  } catch {
    return false;
  }
}

function stripSurroundingQuotes(value: string): string {
  const trimmed = value.trim();
  if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
    return trimmed.slice(1, -1);
  }
  return trimmed;
}

function getWindowsExecutableExtensions(): string[] {
  const raw = process.env.PATHEXT;
  if (!raw) {
    return ['.exe', '.cmd', '.bat', '.com'];
  }

  const exts = raw
    .split(';')
    .map((ext) => ext.trim())
    .filter((ext) => ext.length > 0)
    .map((ext) => (ext.startsWith('.') ? ext : `.${ext}`))
    .map((ext) => ext.toLowerCase());

  return Array.from(new Set(exts));
}

function expandWindowsBinaryNames(binaryName: string): string[] {
  const trimmed = binaryName.trim();
  if (!trimmed) {
    return [];
  }

  const ext = path.extname(trimmed);
  if (ext) {
    return [trimmed];
  }

  const exts = getWindowsExecutableExtensions();
  const withExt = exts.map((e) => `${trimmed}${e}`);
  return [...withExt, trimmed];
}

async function collectNvmCandidates(): Promise<string[]> {
  if (process.platform === 'win32') {
    return collectNvmWindowsCandidates();
  }

  const nvmNodeRoot = path.join(getShellPreferredHome(), '.nvm', 'versions', 'node');
  let versions: string[];
  try {
    versions = await fs.promises.readdir(nvmNodeRoot);
  } catch {
    return [];
  }

  return versions
    .map((version) => path.join(nvmNodeRoot, version, 'bin', 'claude'))
    .sort((a, b) => a.localeCompare(b))
    .reverse();
}

/**
 * Collect NVM for Windows (nvm-windows) candidates.
 * nvm-windows stores Node versions under %APPDATA%\nvm\<version>\.
 */
async function collectNvmWindowsCandidates(): Promise<string[]> {
  const appdata = process.env.APPDATA;
  if (!appdata) return [];

  const nvmRoot = path.join(appdata, 'nvm');
  let versions: string[];
  try {
    versions = await fs.promises.readdir(nvmRoot);
  } catch {
    return [];
  }

  const exts = getWindowsExecutableExtensions();
  return versions
    .toSorted((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' }))
    .flatMap((version) => exts.map((ext) => path.join(nvmRoot, version, `claude${ext}`)));
}

async function resolveFromPathEnv(binaryName: string, pathEnv?: string): Promise<string | null> {
  // TODO: Consider migrating this PATH candidate collection to runtimePathBinaryResolver once
  // Claude-specific executable checks, Windows PATHEXT handling, and parallel stat behavior
  // can be preserved exactly.
  const rawPath = pathEnv && pathEnv.length > 0 ? pathEnv : process.env.PATH;
  if (!rawPath) {
    return null;
  }

  const pathParts = rawPath.split(path.delimiter);
  const binaryNames =
    process.platform === 'win32' ? expandWindowsBinaryNames(binaryName) : [binaryName];

  // Check all PATH directories in parallel. Each directory checks all extension
  // variants concurrently. This turns N_dirs × N_exts sequential stat() calls
  // into a single parallel batch, dramatically reducing startup time on Windows.
  const dirResults = await Promise.all(
    pathParts.map(async (part) => {
      if (!part) return null;
      const cleanedPart = stripSurroundingQuotes(part);
      if (!cleanedPart) return null;

      const candidates = binaryNames.map((name) => path.join(cleanedPart, name));
      const results = await Promise.all(
        candidates.map(async (candidate) => ({
          path: candidate,
          ok: await isExecutable(candidate),
        }))
      );
      // Return the first matching extension variant within this directory
      return results.find((r) => r.ok)?.path ?? null;
    })
  );

  // Return first non-null result, preserving PATH priority order
  return dirResults.find((r) => r !== null) ?? null;
}

async function resolveFromExplicitPath(inputPath: string): Promise<string | null> {
  const trimmed = inputPath.trim();
  if (!trimmed) {
    return null;
  }

  if (process.platform === 'win32' && !path.extname(trimmed)) {
    for (const ext of getWindowsExecutableExtensions()) {
      const candidate = `${trimmed}${ext}`;
      if (await isExecutable(candidate)) {
        return candidate;
      }
    }
  }

  if (await isExecutable(trimmed)) {
    return trimmed;
  }

  return null;
}

async function resolveFromCandidateList(candidates: string[]): Promise<string | null> {
  for (const candidate of candidates) {
    if (await isExecutable(candidate)) {
      return candidate;
    }
  }
  return null;
}

async function resolveFromDoctorFallback(commandName: string): Promise<string | null> {
  const candidates = await getDoctorInvokedCandidates(commandName);
  for (let index = candidates.length - 1; index >= 0; index -= 1) {
    const candidate = candidates[index];
    if (!candidate) {
      continue;
    }
    const resolved = await resolveFromExplicitPath(candidate);
    if (resolved) {
      return resolved;
    }
  }
  return null;
}

async function resolveBundledOrchestratorBinary(): Promise<string | null> {
  const resourcesPath = process.resourcesPath?.trim();
  if (!resourcesPath) {
    return null;
  }

  const binaryName = process.platform === 'win32' ? 'claude-multimodel.exe' : 'claude-multimodel';
  return resolveFromCandidateList([path.join(resourcesPath, 'runtime', binaryName)]);
}

function getConfiguredRuntimeOverrideRaw(flavor: 'claude' | 'agent_teams_orchestrator'): string {
  return (
    (flavor === 'agent_teams_orchestrator'
      ? (process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() ??
        process.env.CLAUDE_CLI_PATH?.trim())
      : process.env.CLAUDE_CLI_PATH?.trim()) ?? ''
  );
}

function looksLikeExplicitPath(value: string): boolean {
  return path.isAbsolute(value) || value.includes('\\') || value.includes('/');
}

let cachedPath: string | null | undefined;

/** Timestamp of last successful cache verification (ms). */
let cacheVerifiedAt = 0;

/** Re-verify cached binary at most once per 30 seconds. */
const CACHE_VERIFY_TTL_MS = 30_000;

/** Coalesce concurrent first resolves so `cachedPath` is not torn by parallel scans. */
let resolveInFlight: Promise<string | null> | null = null;

export class ClaudeBinaryResolver {
  /**
   * Clear the cached binary path.
   * Call after CLI install/update so the next resolve() picks up the new location.
   */
  static clearCache(): void {
    cachedPath = undefined;
    cacheVerifiedAt = 0;
  }

  static async resolve(options: ClaudeBinaryResolveOptions = {}): Promise<string | null> {
    if (cachedPath !== undefined) {
      const now = Date.now();
      // Re-verify the cached binary still exists, but at most once per TTL
      if (cachedPath !== null && now - cacheVerifiedAt > CACHE_VERIFY_TTL_MS) {
        emitProgress(options, 'cache-verify', 'Verifying cached runtime...');
        if (await isExecutable(cachedPath)) {
          cacheVerifiedAt = now;
          emitProgress(options, 'cache-hit', 'Using cached runtime...');
          return cachedPath;
        }
        cachedPath = undefined;
        cacheVerifiedAt = 0;
        // Fall through to full resolution below
      } else {
        emitProgress(
          options,
          cachedPath ? 'cache-hit' : 'cache-miss',
          'Using cached runtime status...'
        );
        return cachedPath;
      }
    }
    if (!resolveInFlight) {
      resolveInFlight = ClaudeBinaryResolver.runResolve(options).finally(() => {
        resolveInFlight = null;
      });
    } else {
      emitProgress(options, 'in-flight', 'Waiting for runtime lookup...');
    }
    return resolveInFlight;
  }

  private static async runResolve(options: ClaudeBinaryResolveOptions): Promise<string | null> {
    const flavor = getConfiguredCliFlavor();
    emitProgress(options, 'flavor', `Using ${flavor} runtime mode...`);

    const overrideRaw = getConfiguredRuntimeOverrideRaw(flavor);
    const overrideIsExplicitPath = overrideRaw ? looksLikeExplicitPath(overrideRaw) : false;
    if (overrideRaw && overrideIsExplicitPath) {
      emitProgress(options, 'configured-path', 'Checking configured runtime path...');
      const resolvedOverride = await resolveFromExplicitPath(overrideRaw);

      if (resolvedOverride) {
        cachedPath = resolvedOverride;
        cacheVerifiedAt = Date.now();
        emitProgress(options, 'configured-path-found', 'Using configured runtime path...');
        return cachedPath;
      }
    }

    const shouldTryBundledOrchestratorBeforeShell =
      flavor === 'agent_teams_orchestrator' && (!overrideRaw || overrideIsExplicitPath);
    if (shouldTryBundledOrchestratorBeforeShell) {
      emitProgress(options, 'bundled-runtime', 'Checking bundled Agent Teams runtime...');
      const bundledBinary = await resolveBundledOrchestratorBinary();
      if (bundledBinary) {
        cachedPath = bundledBinary;
        cacheVerifiedAt = Date.now();
        emitProgress(options, 'bundled-runtime-found', 'Using bundled Agent Teams runtime...');
        return cachedPath;
      }
    }

    await resolveInteractiveShellEnvBestEffort({
      timeoutMs: 1_500,
      fallbackEnv: process.env,
      background: false,
      onProgress: (progress) => emitProgress(options, progress.phase, progress.message),
    });
    const enrichedPath = buildMergedCliPath(null);

    if (overrideRaw && !overrideIsExplicitPath) {
      emitProgress(options, 'configured-path', 'Checking configured runtime path...');
      const resolvedOverride = await resolveFromPathEnv(overrideRaw, enrichedPath);

      if (resolvedOverride) {
        cachedPath = resolvedOverride;
        cacheVerifiedAt = Date.now();
        emitProgress(options, 'configured-path-found', 'Using configured runtime path...');
        return cachedPath;
      }
    }

    if (flavor === 'agent_teams_orchestrator') {
      if (!shouldTryBundledOrchestratorBeforeShell) {
        emitProgress(options, 'bundled-runtime', 'Checking bundled Agent Teams runtime...');
        const bundledBinary = await resolveBundledOrchestratorBinary();
        if (bundledBinary) {
          cachedPath = bundledBinary;
          cacheVerifiedAt = Date.now();
          emitProgress(options, 'bundled-runtime-found', 'Using bundled Agent Teams runtime...');
          return cachedPath;
        }
      }

      // Keep agent_teams_orchestrator resolution generic. Dev flows should
      // inject an explicit CLI path, while non-dev setups can expose
      // claude-multimodel on PATH without making this resolver guess a sibling
      // repo name or folder.
      const orchestratorBinaryName = 'claude-multimodel';
      emitProgress(options, 'path-runtime', 'Searching PATH for Agent Teams runtime...');
      const fromPath = await resolveFromPathEnv(orchestratorBinaryName, enrichedPath);
      if (fromPath) {
        cachedPath = fromPath;
        cacheVerifiedAt = Date.now();
        emitProgress(options, 'path-runtime-found', 'Using Agent Teams runtime from PATH...');
        return cachedPath;
      }

      emitProgress(options, 'doctor-runtime', 'Checking runtime diagnostics fallback...');
      const fromDoctor = await resolveFromDoctorFallback(orchestratorBinaryName);
      if (fromDoctor) {
        cachedPath = fromDoctor;
        cacheVerifiedAt = Date.now();
        emitProgress(options, 'doctor-runtime-found', 'Using runtime from diagnostics fallback...');
        return cachedPath;
      }

      // agent_teams_orchestrator mode is explicit. If the configured local
      // runtime is missing, fail closed instead of silently falling back to a
      // different CLI.
      return null;
    }

    const baseBinaryName = 'claude';
    emitProgress(options, 'path-claude', 'Searching PATH for Claude CLI...');
    const fromPath = await resolveFromPathEnv(baseBinaryName, enrichedPath);
    if (fromPath) {
      cachedPath = fromPath;
      cacheVerifiedAt = Date.now();
      emitProgress(options, 'path-claude-found', 'Using Claude CLI from PATH...');
      return cachedPath;
    }

    const platformBinaryNames =
      process.platform === 'win32' ? expandWindowsBinaryNames(baseBinaryName) : [baseBinaryName];

    const home = getShellPreferredHome();
    const vendorBinDir = path.join(getClaudeBasePath(), 'local', 'node_modules', '.bin');
    const candidateDirs: string[] =
      process.platform === 'win32'
        ? [
            // Windows: Claude npm-local vendor install
            vendorBinDir,
            // Windows: npm global install
            path.join(home, 'AppData', 'Roaming', 'npm'),
            // Windows: scoop, chocolatey, and other package managers
            path.join(home, 'scoop', 'shims'),
            // Windows: Local programs
            ...(process.env.LOCALAPPDATA
              ? [path.join(process.env.LOCALAPPDATA, 'Programs', 'claude')]
              : []),
            // Windows: Program Files
            ...(process.env.ProgramFiles ? [path.join(process.env.ProgramFiles, 'claude')] : []),
          ]
        : [
            // Unix: Claude npm-local vendor install
            vendorBinDir,
            // Unix: native binary installation path (claude install)
            path.join(home, '.local', 'bin'),
            path.join(home, '.npm-global', 'bin'),
            path.join(home, '.npm', 'bin'),
            '/usr/local/bin',
            '/opt/homebrew/bin',
          ];

    const candidates = candidateDirs.flatMap((dir) =>
      platformBinaryNames.map((name) => path.join(dir, name))
    );

    emitProgress(options, 'standard-locations', 'Checking standard Claude install locations...');
    const nvmCandidates = await collectNvmCandidates();
    if (nvmCandidates.length > 0) {
      emitProgress(options, 'nvm-locations', 'Checking nvm-managed Claude installs...');
    }
    const allCandidates = [...candidates, ...nvmCandidates];

    // Check all fallback candidates in parallel for speed
    const results = await Promise.all(
      allCandidates.map(async (candidate) => ({
        path: candidate,
        ok: await isExecutable(candidate),
      }))
    );
    // Return first match, preserving candidate priority order
    const found = results.find((r) => r.ok);
    if (found) {
      cachedPath = found.path;
      cacheVerifiedAt = Date.now();
      emitProgress(
        options,
        'fallback-location-found',
        'Using Claude CLI from install locations...'
      );
      return cachedPath;
    }

    emitProgress(options, 'doctor-claude', 'Checking Claude diagnostics fallback...');
    const fromDoctor = await resolveFromDoctorFallback(baseBinaryName);
    if (fromDoctor) {
      cachedPath = fromDoctor;
      cacheVerifiedAt = Date.now();
      emitProgress(options, 'doctor-claude-found', 'Using Claude CLI from diagnostics fallback...');
      return cachedPath;
    }

    // Don't cache null — CLI may be installed later without app restart
    emitProgress(
      options,
      'not-found',
      'Runtime not found. Continuing with limited launch support...'
    );
    return null;
  }
}
