import events from 'node:events';
import {JWProxy, PROTOCOLS} from '@appium/base-driver';
import {logger, util} from '@appium/support';
import {SubProcess, exec} from 'teen_process';
import {getChromedriverDir, generateLogPrefix} from './utils';
import {ChromedriverStorageClient} from './storage-client/storage-client';
import {CHROMEDRIVER_EVENTS, CHROMEDRIVER_STATES} from './constants';
import {
  getDriversMapping,
  getChromedrivers,
  updateDriversMapping,
  getCompatibleChromedriver,
  initChromedriverPath,
} from './commands/binary';
import {getChromeVersionForAutodetection} from './commands/version';
import {buildChromedriverArgs, waitForOnline, getStatus, killAll} from './commands/process';
import {
  syncProtocol,
  startSession,
  changeState,
  getCapValue,
  type SessionCapabilities,
} from './commands/session';
import type {ADB} from 'appium-adb';
import type {ProxyOptions, HTTPMethod, HTTPBody} from '@appium/types';
import type {Request, Response} from 'express';
import type {ChromedriverOpts} from './types';

// Keep this import marked as used at runtime when it is otherwise only referenced in type positions.
void PROTOCOLS;
export type ChromedriverState = (typeof CHROMEDRIVER_STATES)[keyof typeof CHROMEDRIVER_STATES];

const DEFAULT_HOST = '127.0.0.1';
const DEFAULT_PORT = 9515;
// consider chromedriver ready once startup banner appears
const chromedriverStdoutStartDetector = (stdout: string): boolean => stdout.startsWith('Starting ');
type ChromedriverEventMap = {
  [CHROMEDRIVER_EVENTS.ERROR]: [Error];
  [CHROMEDRIVER_EVENTS.CHANGED]: [{state: ChromedriverState}];
};
type WebviewVersionCapture = {version?: string};

export class Chromedriver extends events.EventEmitter<ChromedriverEventMap> {
  static readonly EVENT_ERROR = CHROMEDRIVER_EVENTS.ERROR;
  static readonly EVENT_CHANGED = CHROMEDRIVER_EVENTS.CHANGED;
  static readonly STATE_STOPPED = CHROMEDRIVER_STATES.STOPPED;
  static readonly STATE_STARTING = CHROMEDRIVER_STATES.STARTING;
  static readonly STATE_ONLINE = CHROMEDRIVER_STATES.ONLINE;
  static readonly STATE_STOPPING = CHROMEDRIVER_STATES.STOPPING;
  static readonly STATE_RESTARTING = CHROMEDRIVER_STATES.RESTARTING;

  readonly proxyPort: number;
  readonly adb?: ADB;
  readonly cmdArgs?: string[];
  proc: SubProcess | null;
  readonly useSystemExecutable: boolean;
  chromedriver?: string;
  readonly executableDir: string;
  readonly mappingPath?: string;
  bundleId?: string;
  executableVerified: boolean;
  state: string;
  _execFunc: typeof exec;
  jwproxy: JWProxy;
  readonly isCustomExecutableDir: boolean;
  readonly verbose?: boolean;
  readonly logPath?: string;
  readonly disableBuildCheck: boolean;
  readonly storageClient: ChromedriverStorageClient | null;
  readonly details?: ChromedriverOpts['details'];
  capabilities: SessionCapabilities;
  _desiredProtocol: keyof typeof PROTOCOLS | null;
  _driverVersion: string | null;
  _onlineStatus: Record<string, any> | null;

  private readonly _log: any;
  private readonly proxyHost: string;

  private buildChromedriverArgs = buildChromedriverArgs;
  private getDriversMapping = getDriversMapping;
  private getChromedrivers = getChromedrivers;
  private updateDriversMapping = updateDriversMapping;
  private getCompatibleChromedriver = getCompatibleChromedriver;
  private initChromedriverPath = initChromedriverPath;
  private getChromeVersion = getChromeVersionForAutodetection;
  private syncProtocol = syncProtocol;
  private waitForOnline = waitForOnline;
  private getStatus = getStatus;
  private killAll = killAll;
  private changeState = changeState;
  private startSession = startSession;

  constructor(args: ChromedriverOpts = {}) {
    super();
    const {
      host = DEFAULT_HOST,
      port = DEFAULT_PORT,
      useSystemExecutable = false,
      executable,
      executableDir,
      bundleId,
      mappingPath,
      cmdArgs,
      adb,
      verbose,
      logPath,
      disableBuildCheck,
      details,
      isAutodownloadEnabled = false,
      reqBasePath,
    } = args;
    this._log = logger.getLogger(generateLogPrefix(this));
    this.proxyHost = host;
    this.proxyPort = parseInt(String(port), 10);
    this.adb = adb;
    this.cmdArgs = cmdArgs;
    this.proc = null;
    this.useSystemExecutable = useSystemExecutable;
    this.chromedriver = executable;
    this.mappingPath = mappingPath;
    this.bundleId = bundleId;
    this.executableVerified = false;
    this.state = Chromedriver.STATE_STOPPED;
    this._execFunc = exec;

    const proxyOpts: ProxyOptions = {server: this.proxyHost, port: this.proxyPort, log: this._log};
    if (reqBasePath) {
      proxyOpts.reqBasePath = reqBasePath;
    }
    this.jwproxy = new JWProxy(proxyOpts);
    if (executableDir) {
      this.executableDir = executableDir;
      this.isCustomExecutableDir = true;
    } else {
      this.executableDir = getChromedriverDir();
      this.isCustomExecutableDir = false;
    }
    this.verbose = verbose;
    this.logPath = logPath;
    this.disableBuildCheck = !!disableBuildCheck;
    this.storageClient = isAutodownloadEnabled
      ? new ChromedriverStorageClient({chromedriverDir: this.executableDir})
      : null;
    this.details = details;
    this.capabilities = {};
    this._desiredProtocol = null;
    this._driverVersion = null;
    this._onlineStatus = null;
  }

  get log() {
    return this._log;
  }

  get driverVersion(): string | null {
    return this._driverVersion;
  }

  /**
   * Starts a new Chromedriver session with the given capabilities.
   *
   * @param caps - Capabilities passed to Chromedriver session creation.
   * @param emitStartingState - Whether to emit the `starting` state transition.
   * @returns Session capabilities returned by Chromedriver.
   */
  async start(caps: SessionCapabilities, emitStartingState = true): Promise<SessionCapabilities> {
    this.capabilities = this.prepareCapabilitiesForSessionStart(caps);
    if (emitStartingState) {
      this.changeState(Chromedriver.STATE_STARTING);
    }

    const args = this.buildChromedriverArgs();
    const webviewVersionHolder: WebviewVersionCapture = {};

    try {
      await this.launchChromedriverProcess(args, webviewVersionHolder);
      this.syncProtocol();
      return await this.startSession();
    } catch (e) {
      return await this.handleChromedriverStartFailure(e as Error, webviewVersionHolder.version);
    }
  }

  /**
   * Gets active Chromedriver session id if the driver is online.
   *
   * @returns The session id or `null` when driver is not online.
   */
  sessionId(): string | null {
    return this.state === Chromedriver.STATE_ONLINE ? this.jwproxy.sessionId : null;
  }

  /**
   * Restarts current Chromedriver session with previously stored capabilities.
   *
   * @returns Session capabilities returned by the restarted session.
   */
  async restart(): Promise<SessionCapabilities> {
    this.log.info('Restarting chromedriver');
    if (this.state !== Chromedriver.STATE_ONLINE) {
      throw new Error("Can't restart when we're not online");
    }
    this.changeState(Chromedriver.STATE_RESTARTING);
    await this.stop(false);
    return await this.start(this.capabilities, false);
  }

  /**
   * Stops the current Chromedriver session and underlying subprocess.
   *
   * @param emitStates - Whether to emit stopping/stopped state transitions.
   */
  async stop(emitStates = true): Promise<void> {
    if (emitStates) {
      this.changeState(Chromedriver.STATE_STOPPING);
    }
    const runSafeStep = async (f: () => Promise<any> | any): Promise<void> => {
      try {
        return await f();
      } catch (e) {
        const err = e as Error;
        this.log.warn(err.message);
        this.log.debug(err.stack);
      }
    };
    await runSafeStep(() => this.jwproxy.command('', 'DELETE'));
    await runSafeStep(async () => {
      await this.proc?.stop('SIGTERM', 20000);
      this.proc?.removeAllListeners();
      this.proc = null;
    });
    this.log.prefix = generateLogPrefix(this);
    if (emitStates) {
      this.changeState(Chromedriver.STATE_STOPPED);
    }
  }

  /**
   * Sends a direct command to Chromedriver through the JSONWP/W3C proxy.
   *
   * @param url - Chromedriver endpoint path.
   * @param method - HTTP method used for the command.
   * @param body - Optional request payload.
   * @returns Command response payload.
   */
  async sendCommand(url: string, method: HTTPMethod, body: HTTPBody = null): Promise<HTTPBody> {
    return await this.jwproxy.command(url, method, body);
  }

  /**
   * Proxies an incoming Express request/response pair to Chromedriver.
   *
   * @param req - Incoming request object.
   * @param res - Outgoing response object.
   */
  async proxyReq(req: Request, res: Response): Promise<void> {
    await this.jwproxy.proxyReqRes(req, res);
  }

  /**
   * Checks whether the active webview connection is currently responsive.
   *
   * @returns `true` if `/url` command succeeds, otherwise `false`.
   */
  async hasWorkingWebview(): Promise<boolean> {
    try {
      await this.jwproxy.command('/url', 'GET');
      return true;
    } catch {
      return false;
    }
  }

  private prepareCapabilitiesForSessionStart(caps: SessionCapabilities): SessionCapabilities {
    const capabilities = structuredClone(caps);
    // set the logging preferences to ALL browser console logs by default
    capabilities.loggingPrefs = structuredClone(getCapValue(caps, 'loggingPrefs', {}));
    if (util.isEmpty(capabilities.loggingPrefs.browser)) {
      capabilities.loggingPrefs.browser = 'ALL';
    }
    return capabilities;
  }

  private attachChromedriverProcessListeners(webviewVersionHolder: WebviewVersionCapture): void {
    const proc = this.proc;
    if (!proc) {
      throw new Error('Chromedriver subprocess must be assigned before attaching listeners');
    }
    for (const streamName of ['stderr', 'stdout'] as const) {
      proc.on(`line-${streamName}`, (line: string) => {
        // if chromedriver does not print explicit Chrome version support,
        // infer webview version from DevTools banner for better errors
        if (!webviewVersionHolder.version) {
          const match = /"Browser": "([^"]+)"/.exec(line);
          if (match) {
            webviewVersionHolder.version = match[1];
            this.log.debug(`Webview version: '${webviewVersionHolder.version}'`);
          }
        }
        if (this.verbose) {
          this.log.debug(`[${streamName.toUpperCase()}] ${line}`);
        }
      });
    }

    proc.once('exit', (code: number | null, signal: string | null) => {
      this._driverVersion = null;
      this._desiredProtocol = null;
      this._onlineStatus = null;
      if (
        this.state !== Chromedriver.STATE_STOPPED &&
        this.state !== Chromedriver.STATE_STOPPING &&
        this.state !== Chromedriver.STATE_RESTARTING
      ) {
        this.log.error(`Chromedriver exited unexpectedly with code ${code}, signal ${signal}`);
        this.changeState(Chromedriver.STATE_STOPPED);
      }
      this.proc?.removeAllListeners();
      this.proc = null;
    });
  }

  private async launchChromedriverProcess(
    args: string[],
    webviewVersionHolder: WebviewVersionCapture,
  ): Promise<void> {
    const chromedriverPath = await this.initChromedriverPath();
    // remove stale chromedriver/adb-forward leftovers before launching
    await this.killAll();
    this.proc = new SubProcess(chromedriverPath, args);
    this.attachChromedriverProcessListeners(webviewVersionHolder);

    this.log.info(`Spawning Chromedriver with: ${this.chromedriver} ${args.join(' ')}`);
    await this.proc.start(chromedriverStdoutStartDetector);
    // wait until /status says ready, then negotiate protocol and start session
    await this.waitForOnline();
  }

  private formatChromeVersionMismatchHint(err: Error, webviewVersion?: string): string {
    if (!err.message.includes('Chrome version must be')) {
      return '';
    }
    // enrich the common version-mismatch error with actionable context
    let message =
      'Unable to automate Chrome version because it is not supported by this version of Chromedriver.\n';
    if (webviewVersion) {
      message += `Chrome version on the device: ${webviewVersion}\n`;
    }
    const versionsSupportedByDriver = /Chrome version must be (.+)/.exec(err.message)?.[1] || '';
    if (versionsSupportedByDriver) {
      message += `Chromedriver supports Chrome version(s): ${versionsSupportedByDriver}\n`;
    }
    message += 'Check the driver tutorial for troubleshooting.\n';
    return message;
  }

  private async handleChromedriverStartFailure(
    err: Error,
    webviewVersion?: string,
  ): Promise<never> {
    this.log.debug(err);
    this.emit(Chromedriver.EVENT_ERROR, err);
    // an error does not always mean subprocess has already exited
    if (this.proc) {
      try {
        await this.proc.stop();
      } catch {}
    }
    this.proc?.removeAllListeners();
    this.proc = null;

    const message = this.formatChromeVersionMismatchHint(err, webviewVersion) + err.message;
    throw new Error(message);
  }
}
