import os from 'node:os';
import path from 'node:path';
import {JWProxy, errors} from 'appium/driver';
import {fs, util, system} from 'appium/support';
import {SubProcess} from 'teen_process';
import {waitForCondition} from 'asyncbox';
import {findAPortNotInUse} from 'portscanner';
import {execSync} from 'node:child_process';
import type {AppiumLogger, StringRecord, HTTPMethod, HTTPBody} from '@appium/types';
import {VERBOSITY} from './constants';

const GD_BINARY = `geckodriver${system.isWindows() ? '.exe' : ''}`;
const STARTUP_TIMEOUT_MS = 10000; // 10 seconds
const GECKO_PORT_RANGE: [number, number] = [5200, 5300];
const GECKO_SERVER_GUARD = util.getLockFileGuard(
  path.resolve(os.tmpdir(), 'gecko_server_guard.lock'),
  {timeout: 5, tryRecovery: true},
);
const DEFAULT_MARIONETTE_PORT = 2828;
export const GECKO_SERVER_HOST = '127.0.0.1';

export interface SessionOptions {
  reqBasePath?: string;
}

class GeckoDriverProcess {
  private readonly noReset?: boolean;
  private readonly verbosity?: string;
  private readonly androidStorage?: string;
  private readonly marionettePort?: number;
  private readonly geckodriverExecutable?: string;
  private _port?: number;
  private readonly log: AppiumLogger;
  private _proc: SubProcess | null = null;

  constructor(log: AppiumLogger, opts: StringRecord = {}) {
    this.noReset = opts.noReset;
    this.verbosity = opts.verbosity;
    this.androidStorage = opts.androidStorage;
    this.marionettePort = opts.marionettePort;
    this.geckodriverExecutable = opts.geckodriverExecutable;
    this._port = opts.systemPort;
    this.log = log;
  }

  get isRunning(): boolean {
    return !!this._proc?.isRunning;
  }

  get port(): number | undefined {
    return this._port;
  }

  get proc(): SubProcess | null {
    return this._proc;
  }

  async init(): Promise<void> {
    if (this.isRunning) {
      return;
    }

    if (!this._port) {
      await GECKO_SERVER_GUARD(async () => {
        const [startPort, endPort] = GECKO_PORT_RANGE;
        try {
          this._port = await findAPortNotInUse(startPort, endPort);
        } catch {
          throw new Error(
            `Cannot find any free port in range ${startPort}..${endPort}. ` +
              `Double check the processes that are locking ports within this range and terminate ` +
              `these which are not needed anymore or set any free port number to the 'systemPort' capability`,
          );
        }
      });
    }

    const driverBin = await this.resolveGeckodriverBinary();
    const args: string[] = [];
    /* #region Options */
    switch (this.verbosity?.toLowerCase()) {
      case VERBOSITY.DEBUG:
        args.push('-v');
        break;
      case VERBOSITY.TRACE:
        args.push('-vv');
        break;
    }
    if (this.noReset) {
      args.push('--connect-existing');
      // https://firefox-source-docs.mozilla.org/testing/geckodriver/Flags.html#code-connect-existing-code
      if (this.marionettePort == null) {
        this.log.info(
          `'marionettePort' capability value is not provided while 'noReset' is enabled`,
        );
        this.log.info(
          `Assigning 'marionettePort' to the default value (${DEFAULT_MARIONETTE_PORT})`,
        );
      }
      args.push('--marionette-port', `${this.marionettePort ?? DEFAULT_MARIONETTE_PORT}`);
    } else if (this.marionettePort != null) {
      args.push('--marionette-port', `${this.marionettePort}`);
    }
    /* #endregion */

    args.push('-p', `${this._port}`);
    if (this.androidStorage) {
      args.push('--android-storage', this.androidStorage);
    }
    this._proc = new SubProcess(driverBin, args);
    this._proc.on('output', (stdout, stderr) => {
      const line = (stdout || stderr).trim();
      if (line) {
        this.log.debug(`[${GD_BINARY}] ${line}`);
      }
    });
    this._proc.on('exit', (code, signal) => {
      this.log.info(`${GD_BINARY} has exited with code ${code}, signal ${signal}`);
    });
    this.log.info(`Starting '${driverBin}' with args ${JSON.stringify(args)}`);
    await this._proc.start(0);
  }

  async stop(): Promise<void> {
    if (this.isRunning) {
      await this._proc?.stop('SIGTERM');
    }
  }

  async kill(): Promise<void> {
    if (this.isRunning) {
      try {
        await this._proc?.stop('SIGKILL');
      } catch {}
    }
  }

  private async resolveGeckodriverBinary(): Promise<string> {
    if (this.geckodriverExecutable) {
      if (!(await fs.exists(this.geckodriverExecutable))) {
        throw new Error(
          `The custom geckodriver binary at '${this.geckodriverExecutable}' cannot be found. ` +
            `Make sure the path is correct and accessible to the Appium server process`,
        );
      }
      return this.geckodriverExecutable;
    }

    try {
      return await fs.which(GD_BINARY);
    } catch {
      throw new Error(
        `${GD_BINARY} binary cannot be found in PATH. ` +
          `Please make sure it is present on your system`,
      );
    }
  }
}

export class GeckoProxy extends JWProxy {
  didProcessExit?: boolean;

  override async proxyCommand(url: string, method: HTTPMethod, body: HTTPBody = null) {
    if (this.didProcessExit) {
      throw new errors.InvalidContextError(
        `'${method} ${url}' cannot be proxied to Gecko Driver server because ` +
          'its process is not running (probably crashed). Check the Appium log for more details',
      );
    }
    return await super.proxyCommand(url, method, body);
  }
}

const RUNNING_PROCESS_IDS: (number | undefined)[] = [];
const removeRunningProcessId = (pid: number): void => {
  let idx = RUNNING_PROCESS_IDS.indexOf(pid);
  while (idx >= 0) {
    RUNNING_PROCESS_IDS.splice(idx, 1);
    idx = RUNNING_PROCESS_IDS.indexOf(pid);
  }
};

process.once('exit', () => {
  if (RUNNING_PROCESS_IDS.length === 0) {
    return;
  }

  const command = system.isWindows()
    ? 'taskkill.exe ' +
      RUNNING_PROCESS_IDS.filter((pid): pid is number => pid !== undefined)
        .map((pid) => `/PID ${pid}`)
        .join(' ')
    : `kill ${RUNNING_PROCESS_IDS.filter((pid): pid is number => pid !== undefined).join(' ')}`;
  try {
    execSync(command);
  } catch {}
});

export class GeckoDriverServer {
  private _proxy: GeckoProxy | null = null;
  private readonly _process: GeckoDriverProcess;
  private readonly log: AppiumLogger;

  constructor(log: AppiumLogger, caps: StringRecord) {
    this._process = new GeckoDriverProcess(log, caps);
    this.log = log;
    this._proxy = null;
  }

  get proxy(): GeckoProxy {
    if (!this._proxy) {
      throw new Error('Gecko proxy is not initialized');
    }
    return this._proxy;
  }

  get isRunning(): boolean {
    return !!this._process?.isRunning;
  }

  async start(geckoCaps: StringRecord, opts: SessionOptions = {}): Promise<StringRecord> {
    await this._process.init();

    const proxyOpts: any = {
      server: GECKO_SERVER_HOST,
      port: this._process.port,
      log: this.log,
      base: '',
      keepAlive: true,
    };
    if (opts.reqBasePath) {
      proxyOpts.reqBasePath = opts.reqBasePath;
    }
    this._proxy = new GeckoProxy(proxyOpts);
    this._proxy.didProcessExit = false;
    this._process?.proc?.on('exit', () => {
      if (this._proxy) {
        this._proxy.didProcessExit = true;
      }
    });

    try {
      await waitForCondition(
        async () => {
          try {
            await this._proxy?.command('/status', 'GET');
            return true;
          } catch (err: any) {
            if (this._proxy?.didProcessExit) {
              throw new Error(err.message, {cause: err});
            }
            return false;
          }
        },
        {
          waitMs: STARTUP_TIMEOUT_MS,
          intervalMs: 1000,
        },
      );
    } catch (e: any) {
      if (this._process.isRunning) {
        // avoid "frozen" processes,
        await this._process.kill();
      }
      if (/Condition unmet/.test(e.message)) {
        throw new Error(
          `Gecko Driver server is not listening within ${STARTUP_TIMEOUT_MS}ms timeout. ` +
            `Make sure it could be started manually from a terminal`,
          {cause: e},
        );
      }
      throw e;
    }
    const pid = this._process.proc?.pid;
    if (pid) {
      RUNNING_PROCESS_IDS.push(pid);
      this._process.proc?.on('exit', () => removeRunningProcessId(pid));
    }

    return (await this._proxy.command('/session', 'POST', {
      capabilities: {
        firstMatch: [{}],
        alwaysMatch: geckoCaps,
      },
    })) as StringRecord;
  }

  async stop(): Promise<void> {
    if (!this.isRunning) {
      this.log.info(`Gecko Driver session cannot be stopped, because the server is not running`);
      return;
    }

    if (this._proxy?.sessionId) {
      try {
        await this._proxy.command(`/session/${this._proxy.sessionId}`, 'DELETE');
      } catch (e: any) {
        this.log.info(`Gecko Driver session cannot be deleted. Original error: ${e.message}`);
      }
    }

    try {
      await this._process.stop();
    } catch (e: any) {
      this.log.warn(`Gecko Driver process cannot be stopped (${e.message}). Killing it forcefully`);
      await this._process.kill();
    }
  }
}

export default GeckoDriverServer;
