import { spawn } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';

import { readRegistryCredential } from './auth-store.js';

export const MATRIX_NPM_REGISTRY_SCOPES = ['@open-matrix', '@matrix', '@omega'] as const;

export interface INpmCommandResult {
  readonly exitCode: number | null;
  readonly stdout: string;
  readonly stderr: string;
}

export interface INpmRegistrySearchResult {
  readonly packageName: string;
  readonly version: string;
  readonly description?: string;
  readonly keywords: readonly string[];
}

export function isHttpRegistryUrl(value: string | undefined): value is string {
  if (!value) {
    return false;
  }
  try {
    const parsed = new URL(value);
    return parsed.protocol === 'http:' || parsed.protocol === 'https:';
  } catch {
    return false;
  }
}

export function npmPackageSpecifier(packageName: string, version: string | undefined): string {
  return version ? `${packageName}@${version}` : packageName;
}

export async function prepareNpmRegistryTarball(
  specifier: string,
  registryUrl: string,
  cwd: string,
  credentialsPath: string | undefined,
): Promise<{ readonly tarballPath: string; readonly cleanupDir: string }> {
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-install-npm-registry-'));
  const credential = readRegistryCredential(cwd, {
    registry: registryUrl,
    credentialsPath,
  }).credential;
  const userConfig = writeTemporaryNpmrc(tempDir, registryUrl, credential?.token ?? null);
  const result = await runNpmCommand([
    'pack',
    specifier,
    '--pack-destination',
    tempDir,
    '--registry',
    registryUrl,
    '--userconfig',
    userConfig,
  ], {
    cwd: tempDir,
    registryUrl,
    userConfig,
    cacheDir: path.join(tempDir, '.npm-cache'),
  });

  if (result.exitCode !== 0) {
    fs.rmSync(tempDir, { recursive: true, force: true });
    const output = [result.stderr, result.stdout]
      .filter((value) => typeof value === 'string' && value.trim().length > 0)
      .join('\n')
      .trim();
    throw new Error(
      `MX_REGISTRY_FETCH_FAILED: failed to fetch ${specifier} from ${registryUrl}`
      + (output ? `\n${output}` : ''),
    );
  }

  const tarballs = fs.readdirSync(tempDir)
    .filter((entry) => entry.endsWith('.tgz') || entry.endsWith('.tar.gz'));
  if (tarballs.length === 0) {
    fs.rmSync(tempDir, { recursive: true, force: true });
    throw new Error(`MX_REGISTRY_FETCH_FAILED: npm pack produced no tarball for ${specifier}`);
  }

  return {
    tarballPath: path.join(tempDir, tarballs[0]!),
    cleanupDir: tempDir,
  };
}

export async function packNpmPublishTarball(
  packageDir: string,
  outDir: string,
): Promise<{ readonly tarballPath: string; readonly stdout: string; readonly stderr: string }> {
  fs.mkdirSync(outDir, { recursive: true });
  const result = await runNpmCommand([
    'pack',
    packageDir,
    '--pack-destination',
    outDir,
  ], {
    cwd: packageDir,
    cacheDir: path.join(outDir, '.npm-cache'),
  });
  if (result.exitCode !== 0) {
    const output = [result.stderr, result.stdout]
      .filter((value) => typeof value === 'string' && value.trim().length > 0)
      .join('\n')
      .trim();
    throw new Error(`MX_REGISTRY_PACK_FAILED: npm pack failed${output ? `\n${output}` : ''}`);
  }

  const stdoutTarball = result.stdout
    .split(/\r?\n/)
    .map((line) => line.trim())
    .reverse()
    .find((line) => line.endsWith('.tgz') || line.endsWith('.tar.gz'));
  if (stdoutTarball) {
    const tarballPath = path.join(outDir, path.basename(stdoutTarball));
    if (fs.existsSync(tarballPath)) {
      return {
        tarballPath,
        stdout: result.stdout,
        stderr: result.stderr,
      };
    }
  }

  const tarball = fs.readdirSync(outDir)
    .filter((entry) => entry.endsWith('.tgz') || entry.endsWith('.tar.gz'))
    .map((entry) => ({
      entry,
      mtimeMs: fs.statSync(path.join(outDir, entry)).mtimeMs,
    }))
    .sort((left, right) => left.mtimeMs - right.mtimeMs)
    .at(-1);
  if (!tarball) {
    throw new Error(`MX_REGISTRY_PACK_FAILED: npm pack produced no tarball under ${outDir}`);
  }
  return {
    tarballPath: path.join(outDir, tarball.entry),
    stdout: result.stdout,
    stderr: result.stderr,
  };
}

export async function publishNpmTarball(
  tarballPath: string,
  registryUrl: string,
  cwd: string,
  credentialsPath: string | undefined,
): Promise<INpmCommandResult> {
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-publish-npm-registry-'));
  try {
    const credential = readRegistryCredential(cwd, {
      registry: registryUrl,
      credentialsPath,
    }).credential;
    const userConfig = writeTemporaryNpmrc(tempDir, registryUrl, credential?.token ?? null);
    const result = await runNpmCommand([
      'publish',
      tarballPath,
      '--registry',
      registryUrl,
      '--userconfig',
      userConfig,
    ], {
      cwd,
      registryUrl,
      userConfig,
      cacheDir: path.join(tempDir, '.npm-cache'),
    });
    return result;
  } finally {
    fs.rmSync(tempDir, { recursive: true, force: true });
  }
}

export async function searchNpmRegistry(
  query: string,
  registryUrl: string,
  cwd: string,
  credentialsPath: string | undefined,
  limit: number,
): Promise<readonly INpmRegistrySearchResult[]> {
  const trimmedQuery = query.trim();
  if (!trimmedQuery) {
    throw new Error('MX_SEARCH_INVALID: query is required');
  }
  const searchUrl = new URL('-/v1/search', registryUrl.endsWith('/') ? registryUrl : `${registryUrl}/`);
  searchUrl.searchParams.set('text', trimmedQuery);
  searchUrl.searchParams.set('size', String(limit));

  const credential = readRegistryCredential(cwd, {
    registry: registryUrl,
    credentialsPath,
  }).credential;
  const response = await fetch(searchUrl, {
    headers: {
      accept: 'application/json',
      ...(credential?.token ? { authorization: `Bearer ${credential.token}` } : {}),
    },
  });
  if (!response.ok) {
    const body = await response.text();
    throw new Error(
      `MX_REGISTRY_SEARCH_FAILED: registry search failed with HTTP ${response.status}`
      + (body.trim() ? `\n${body.trim()}` : ''),
    );
  }
  const parsed = asRecord(await response.json() as unknown);
  const objects = Array.isArray(parsed?.objects) ? parsed.objects : [];
  const results: INpmRegistrySearchResult[] = [];
  for (const object of objects) {
    const packageRecord = asRecord(asRecord(object)?.package);
    const packageName = readNpmSearchPackageName(packageRecord);
    const version = readString(packageRecord?.version);
    if (!packageName || !version) {
      continue;
    }
    results.push({
      packageName,
      version,
      ...(readString(packageRecord?.description) ? { description: readString(packageRecord?.description)! } : {}),
      keywords: readStringArray(packageRecord?.keywords),
    });
  }
  return results;
}

function readNpmSearchPackageName(packageRecord: Record<string, unknown> | null): string | undefined {
  const name = readString(packageRecord?.name);
  if (!name) {
    return undefined;
  }
  if (name.startsWith('@')) {
    return name;
  }
  const scope = readString(packageRecord?.scope);
  if (!scope) {
    return name;
  }
  return `${scope.startsWith('@') ? scope : `@${scope}`}/${name}`;
}

function asRecord(value: unknown): Record<string, unknown> | null {
  return value !== null && typeof value === 'object' && !Array.isArray(value)
    ? value as Record<string, unknown>
    : null;
}

function readString(value: unknown): string | undefined {
  if (typeof value !== 'string') {
    return undefined;
  }
  const trimmed = value.trim();
  return trimmed.length > 0 ? trimmed : undefined;
}

function readStringArray(value: unknown): readonly string[] {
  if (!Array.isArray(value)) {
    return [];
  }
  return value
    .filter((entry): entry is string => typeof entry === 'string')
    .map((entry) => entry.trim())
    .filter((entry) => entry.length > 0);
}

function registryAuthConfigKey(registryUrl: string): string {
  const parsed = new URL(registryUrl);
  const pathname = parsed.pathname.endsWith('/') ? parsed.pathname : `${parsed.pathname}/`;
  return `//${parsed.host}${pathname}:_authToken`;
}

function writeTemporaryNpmrc(
  tempDir: string,
  registryUrl: string,
  token: string | null,
): string {
  const npmrcPath = path.join(tempDir, '.npmrc');
  const lines = [
    `registry=${registryUrl}`,
  ];
  for (const scope of MATRIX_NPM_REGISTRY_SCOPES) {
    lines.push(`${scope}:registry=${registryUrl}`);
  }
  if (token) {
    lines.push('always-auth=true');
    lines.push(`${registryAuthConfigKey(registryUrl)}=${token}`);
  }
  fs.writeFileSync(npmrcPath, `${lines.join('\n')}\n`, 'utf8');
  return npmrcPath;
}

async function runNpmCommand(
  args: readonly string[],
  options: {
    readonly cwd: string;
    readonly registryUrl?: string;
    readonly userConfig?: string;
    readonly cacheDir: string;
  },
): Promise<INpmCommandResult> {
  const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
  fs.mkdirSync(options.cacheDir, { recursive: true });
  const globalConfig = path.join(options.cacheDir, 'empty-global-npmrc');
  if (!fs.existsSync(globalConfig)) {
    fs.writeFileSync(globalConfig, '', 'utf8');
  }
  return await new Promise((resolve, reject) => {
    const child = spawn(npmCommand, [...args], {
      cwd: options.cwd,
      env: {
        ...process.env,
        ...(options.userConfig ? {
          NPM_CONFIG_USERCONFIG: options.userConfig,
          npm_config_userconfig: options.userConfig,
        } : {}),
        NPM_CONFIG_GLOBALCONFIG: globalConfig,
        npm_config_globalconfig: globalConfig,
        ...(options.registryUrl ? { npm_config_registry: options.registryUrl } : {}),
        npm_config_cache: options.cacheDir,
      },
      shell: false,
      stdio: ['ignore', 'pipe', 'pipe'],
      windowsHide: true,
    });
    const stdout: string[] = [];
    const stderr: string[] = [];
    child.stdout.setEncoding('utf8');
    child.stderr.setEncoding('utf8');
    child.stdout.on('data', (chunk: string) => stdout.push(chunk));
    child.stderr.on('data', (chunk: string) => stderr.push(chunk));
    child.on('error', reject);
    child.on('close', (exitCode) => {
      resolve({
        exitCode,
        stdout: stdout.join(''),
        stderr: stderr.join(''),
      });
    });
  });
}
