import { spawn } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import { createReadStream, createWriteStream } from 'node:fs';
import https from 'node:https';
import os from 'node:os';
import path from 'node:path';

/**
 * Installs the versioned local server runtime used by CloudCLI Desktop.
 *
 * Server bundles are cached under:
 *   ~/.cloudcli/server/<version>/dist-server/server/index.js
 */

const DEFAULT_INSTALL_ROOT = path.join(os.homedir(), '.cloudcli', 'server');
const DEFAULT_BUNDLE_BASE_URL = 'https://github.com/siteboon/claudecodeui/releases/download';
const MAX_REDIRECTS = 5;
const LOCAL_DOWNLOAD_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);

function mapArch(arch = process.arch) {
  return arch === 'arm64' ? 'arm64' : 'x64';
}

function mapPlatform(platform = process.platform) {
  if (platform === 'darwin') return 'mac';
  if (platform === 'win32') return 'win';
  return 'linux';
}

export class ServerInstaller {
  constructor({
    version,
    platform = process.platform,
    arch = process.arch,
    installRoot = process.env.CLOUDCLI_SERVER_DIR || DEFAULT_INSTALL_ROOT,
    bundleBaseUrl = process.env.CLOUDCLI_SERVER_BUNDLE_URL || DEFAULT_BUNDLE_BASE_URL,
    bundleReleaseTag = process.env.CLOUDCLI_SERVER_BUNDLE_RELEASE_TAG || '',
    onLog,
  } = {}) {
    if (!version) throw new Error('ServerInstaller requires the app version');
    this.version = version;
    this.platform = mapPlatform(platform);
    this.arch = mapArch(arch);
    this.installRoot = installRoot;
    this.bundleBaseUrl = bundleBaseUrl.replace(/\/+$/, '');
    this.bundleReleaseTag = bundleReleaseTag || `v${this.version}`;
    this.onLog = typeof onLog === 'function' ? onLog : () => {};
  }

  /** Directory the current version's server is (or will be) installed in. */
  getVersionDir() {
    return path.join(this.installRoot, this.version);
  }

  /** Absolute path to the server entry once installed. */
  getServerEntry() {
    return path.join(this.getVersionDir(), 'dist-server', 'server', 'index.js');
  }

  getBundleName() {
    return `cloudcli-local-server-${this.version}-${this.platform}-${this.arch}.tar.gz`;
  }

  getBundleUrl() {
    const url = new URL(`${this.bundleBaseUrl}/${this.bundleReleaseTag}/${this.getBundleName()}`);
    if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LOCAL_DOWNLOAD_HOSTS.has(url.hostname))) {
      throw new Error(`Refusing unsupported server bundle URL: ${url.toString()}`);
    }
    return url.toString();
  }

  log(line) {
    this.onLog(String(line));
  }

  async isInstalled() {
    try {
      const marker = JSON.parse(
        await fs.readFile(path.join(this.getVersionDir(), '.installed.json'), 'utf8'),
      );
      if (marker.version !== this.version) return false;
      await fs.access(this.getServerEntry());
      return true;
    } catch {
      return false;
    }
  }

  /**
   * Ensures the server for this version is installed, downloading + extracting
   * it if needed. Returns the resolved server entry path.
   */
  async ensureInstalled() {
    if (await this.isInstalled()) {
      this.log(`Local server ${this.version} already installed.`);
      return this.getServerEntry();
    }

    const versionDir = this.getVersionDir();
    const tmpDir = path.join(this.installRoot, `.tmp-${this.version}-${process.pid}`);
    const archivePath = path.join(tmpDir, this.getBundleName());

    await fs.mkdir(tmpDir, { recursive: true });
    try {
      const url = this.getBundleUrl();
      this.log(`Downloading local server bundle…`);
      this.log(url);
      await this.#download(url, archivePath);
      await this.#verifyChecksum(url, archivePath);

      this.log('Extracting local server…');
      await fs.rm(versionDir, { recursive: true, force: true });
      await fs.mkdir(versionDir, { recursive: true });
      await this.#validateArchive(archivePath);
      await this.#extract(archivePath, versionDir);

      const entry = this.getServerEntry();
      await fs.access(entry);

      await fs.writeFile(
        path.join(versionDir, '.installed.json'),
        JSON.stringify({ version: this.version, installedAt: new Date().toISOString() }, null, 2),
        'utf8',
      );
      this.log(`Local server ${this.version} installed.`);
      return entry;
    } catch (error) {
      await fs.rm(versionDir, { recursive: true, force: true }).catch(() => {});
      throw new Error(`Failed to install local server: ${error.message}`);
    } finally {
      await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
    }
  }

  #download(url, destPath, redirectsLeft = MAX_REDIRECTS) {
    return new Promise((resolve, reject) => {
      const req = https.get(url, (res) => {
        const { statusCode, headers } = res;

        if (statusCode >= 300 && statusCode < 400 && headers.location) {
          res.resume();
          if (redirectsLeft <= 0) {
            reject(new Error('Too many redirects'));
            return;
          }
          const next = new URL(headers.location, url).toString();
          resolve(this.#download(next, destPath, redirectsLeft - 1));
          return;
        }

        if (statusCode !== 200) {
          res.resume();
          reject(new Error(`Download failed with HTTP ${statusCode}`));
          return;
        }

        const total = Number(headers['content-length']) || 0;
        let received = 0;
        let lastPct = -1;
        const out = createWriteStream(destPath);

        res.on('data', (chunk) => {
          received += chunk.length;
          if (total) {
            const pct = Math.floor((received / total) * 100);
            if (pct !== lastPct && pct % 10 === 0) {
              lastPct = pct;
              this.log(`Downloading… ${pct}%`);
            }
          }
        });
        res.pipe(out);
        out.on('finish', () => out.close(resolve));
        out.on('error', reject);
        res.on('error', reject);
      });
      req.on('error', reject);
    });
  }

  async #verifyChecksum(url, archivePath) {
    let expected;
    try {
      expected = (await this.#fetchText(`${url}.sha256`)).trim().split(/\s+/)[0];
    } catch (error) {
      throw new Error(`Could not verify server bundle checksum: ${error.message}`);
    }
    const actual = await this.#sha256(archivePath);
    if (expected.toLowerCase() !== actual.toLowerCase()) {
      throw new Error('Checksum mismatch — refusing to install');
    }
    this.log('Checksum verified.');
  }

  #fetchText(url, redirectsLeft = MAX_REDIRECTS) {
    return new Promise((resolve, reject) => {
      https
        .get(url, (res) => {
          const { statusCode, headers } = res;
          if (statusCode >= 300 && statusCode < 400 && headers.location) {
            res.resume();
            if (redirectsLeft <= 0) return reject(new Error('Too many redirects'));
            return resolve(this.#fetchText(new URL(headers.location, url).toString(), redirectsLeft - 1));
          }
          if (statusCode !== 200) {
            res.resume();
            return reject(new Error(`HTTP ${statusCode}`));
          }
          let body = '';
          res.setEncoding('utf8');
          res.on('data', (c) => (body += c));
          res.on('end', () => resolve(body));
          res.on('error', reject);
        })
        .on('error', reject);
    });
  }

  #sha256(filePath) {
    return new Promise((resolve, reject) => {
      const hash = crypto.createHash('sha256');
      const stream = createReadStream(filePath);
      stream.on('data', (c) => hash.update(c));
      stream.on('end', () => resolve(hash.digest('hex')));
      stream.on('error', reject);
    });
  }

  #extract(archivePath, destDir) {
    return new Promise((resolve, reject) => {
      const child = spawn('tar', ['-xzf', archivePath, '-C', destDir], {
        stdio: ['ignore', 'ignore', 'pipe'],
        windowsHide: true,
      });
      let stderr = '';
      child.stderr?.on('data', (c) => (stderr += c));
      child.once('error', reject);
      child.once('exit', (code) => {
        if (code === 0) resolve();
        else reject(new Error(`tar exited with code ${code}: ${stderr.trim()}`));
      });
    });
  }

  #validateArchive(archivePath) {
    return new Promise((resolve, reject) => {
      const child = spawn('tar', ['-tzf', archivePath], {
        stdio: ['ignore', 'pipe', 'pipe'],
        windowsHide: true,
      });
      let stdout = '';
      let stderr = '';
      child.stdout?.on('data', (c) => { stdout += c; });
      child.stderr?.on('data', (c) => { stderr += c; });
      child.once('error', reject);
      child.once('exit', (code) => {
        if (code !== 0) {
          reject(new Error(`tar list exited with code ${code}: ${stderr.trim()}`));
          return;
        }
        for (const entry of stdout.split(/\r?\n/).filter(Boolean)) {
          const normalized = entry.replace(/\\/g, '/');
          if (
            path.isAbsolute(normalized)
            || /^[a-zA-Z]:\//.test(normalized)
            || normalized.split('/').includes('..')
          ) {
            reject(new Error(`Refusing unsafe archive entry: ${entry}`));
            return;
          }
        }
        resolve();
      });
    });
  }
}
