import type {EventEmitter} from 'node:events';
import type {Merge} from 'type-fest';
import type {Capabilities, DriverCaps, W3CCapabilities} from './capabilities';
import type {
  BidiModuleMap,
  BiDiResultData,
  ExecuteMethodMap,
  MethodMap,
} from './command-maps';
import type {
  DefaultCreateSessionResult,
  DefaultDeleteSessionResult,
  DriverData,
  EventHistory,
  IImplementedCommands,
  IWDClassicCommands,
  IAppiumCommands,
  IJSONWPCommands,
  IMJSONWPCommands,
  IOtherProtocolCommands,
} from './commands';
import type {Constraints} from './constraints';
import type {ServerArgs} from './config';
import type {HTTPHeaders, HTTPMethod} from './http';
import type {AppiumLogger} from './logger';
import type {AppiumServer, UpdateServerCallback} from './server';
import type {Class, StringRecord} from './util';
import type internal from 'node:stream';

/**
 * Interface implemented by the `DeviceSettings` class in `@appium/base-driver`
 */
export interface IDeviceSettings<T extends StringRecord> {
  update(newSettings: T): Promise<void>;
  getSettings(): T;
}

export interface DriverHelpers {
  configureApp: (
    app: string,
    supportedAppExtensions?: string | string[] | ConfigureAppOptions,
  ) => Promise<string>;
  isPackageOrBundle: (app: string) => boolean;
  duplicateKeys: <T>(input: T, firstKey: string, secondKey: string) => T;
  parseCapsArray: (cap: string | string[]) => string[];
  generateDriverLogPrefix: (obj: object, sessionId?: string) => string;
}

export type SettingsUpdateListener<T extends Record<string, unknown> = Record<string, unknown>> = (
  prop: keyof T,
  newValue: unknown,
  curValue: unknown,
) => Promise<void>;

export type Protocol = 'MJSONWP' | 'W3C';

/**
 * Methods and properties which both `AppiumDriver` and `BaseDriver` inherit.
 *
 * This should not be used directly by external code.
 */
export interface Core<C extends Constraints, Settings extends StringRecord = StringRecord> {
  shouldValidateCaps: boolean;
  sessionId: string | null;
  sessionCreationTimestampMs: number;
  opts: DriverOpts<C>;
  initialOpts: InitialOpts;
  protocol?: Protocol;
  helpers: DriverHelpers;
  basePath: string;
  relaxedSecurityEnabled: boolean;
  allowInsecure: string[];
  denyInsecure: string[];
  newCommandTimeoutMs: number;
  implicitWaitMs: number;
  locatorStrategies: string[];
  webLocatorStrategies: string[];
  eventEmitter: EventEmitter;
  settings: IDeviceSettings<Settings>;
  log: AppiumLogger;
  driverData: DriverData;
  isCommandsQueueEnabled: boolean;
  eventHistory: EventHistory;
  bidiEventSubs: Record<string, string[]>;
  updateBidiCommands(cmds: BidiModuleMap): void;
  onUnexpectedShutdown(handler: () => any): void;
  /**
   * @summary Retrieve the server's current status.
   * @description
   * Returns information about whether a remote end is in a state in which it can create new sessions and can additionally include arbitrary meta information that is specific to the implementation.
   *
   * The readiness state is represented by the ready property of the body, which is false if an attempt to create a session at the current time would fail. However, the value true does not guarantee that a New Session command will succeed.
   *
   * Implementations may optionally include additional meta information as part of the body, but the top-level properties ready and message are reserved and must not be overwritten.
   *
   * @example
   * ```js
   * // webdriver.io example
   * await driver.status();
   * ```
   *
   * ```python
   * driver.get_status()
   * ```
   *
   * ```java
   * driver.getStatus();
   * ```
   *
   * ```ruby
   * # ruby_lib example
   * remote_status
   *
   * # ruby_lib_core example
   * @driver.remote_status
   * ```
   */
  getStatus(): Promise<any>;
  sessionExists(sessionId?: string): boolean;
  isW3CProtocol(): boolean;
  isMjsonwpProtocol(): boolean;
  isFeatureEnabled(name: string): boolean;
  assertFeatureEnabled(name: string): void;
  validateLocatorStrategy(strategy: string, webContext?: boolean): void;
  proxyActive(sessionId?: string): boolean;
  get bidiProxyUrl(): string | null;
  getProxyAvoidList(sessionId?: string): RouteMatcher[];
  canProxy(sessionId?: string): boolean;
  proxyRouteIsAvoided(sessionId: string, method: string, url: string, body?: any): boolean;
  addManagedDriver(driver: Driver): void;
  getManagedDrivers(): Driver<Constraints>[];
  clearNewCommandTimeout(): Promise<void>;
  logEvent(eventName: string): void;
  driverForSession(sessionId: string): Core<Constraints> | null;
}

/**
 * `BaseDriver` implements this.  It contains default behavior;
 * external drivers are expected to implement {@linkcode ExternalDriver} instead.
 *
 * `C` should be the constraints of the driver.
 * `CArgs` would be the shape of `cliArgs`.
 * `Settings` is the shape of the raw device settings object (see {@linkcode IDeviceSettings})
 */
export interface Driver<
  C extends Constraints = Constraints,
  CArgs extends StringRecord = StringRecord,
  Settings extends StringRecord = StringRecord,
  CreateResult = DefaultCreateSessionResult<C>,
  DeleteResult = DefaultDeleteSessionResult,
  SessionData extends StringRecord = StringRecord,
> extends IImplementedCommands<C, Settings, CreateResult, DeleteResult, SessionData>,
    Core<C, Settings> {
  /**
   * The set of command line arguments set for this driver.
   *
   * These are derived from the Appium server configuration for this
   * particular driver (CLI flags and/or config files). They are **not**
   * user capabilities and cannot be influenced by test code.
   */
  cliArgs: CArgs;
  /**
   * The underlying HTTP server instance hosting this driver.
   *
   * This is assigned by the Appium server when a session is created.
   * It is primarily useful for advanced integrations (for example,
   * when a driver or plugin needs direct access to the web server).
   */
  server?: AppiumServer;
  /**
   * The hostname or IP address on which the Appium server is listening.
   *
   * This is assigned by the Appium server when a session is created,
   * based on the `--address` (or equivalent config) used to start the
   * server. It can be used by drivers which need to construct URLs that
   * point back to the Appium server.
   */
  serverHost?: string;
  /**
   * The TCP port on which the Appium server is listening.
   *
   * This is assigned by the Appium server when a session is created,
   * based on the `--port` (or equivalent config) used to start the
   * server. It is often used together with {@link Driver.serverHost}
   * and {@link Driver.serverPath} to build callback or proxy URLs.
   */
  serverPort?: number;
  /**
   * The base path under which WebDriver routes are exposed.
   *
   * This is assigned by the Appium server when a session is created,
   * based on the `--base-path` (or equivalent config) used to start the
   * server. For example, this might be an empty string (`''`) or
   * `'/wd/hub'`.
   */
  serverPath?: string;

  // The following methods are implemented by `BaseDriver`.

  /**
   * Execute a driver (WebDriver-protocol) command by its name as defined in the routes file
   *
   * @param cmd - the name of the command
   * @param args - arguments to pass to the command
   *
   * @returns The result of running the command
   */
  executeCommand(cmd: string, ...args: any[]): Promise<any>;


  /**
   * A helper method to modify the command name before it's logged.
   *
   * Useful for resolving generic commands like 'execute' to a more specific
   * name based on arguments (e.g., identifying custom extensions).
   *
   * @param cmd - The original command name
   * @param args - Arguments passed to the command
   * @returns A potentially updated command name
   */
  clarifyCommandName?(cmd: string, args: string[]): string;

  /** Execute a driver (WebDriver Bidi protocol) command by its name as defined in the bidi commands file
   * @param bidiCmd - the name of the command in the bidi spec
   * @param args - arguments to pass to the command
   */
  executeBidiCommand(bidiCmd: string, ...args: any[]): Promise<BiDiResultData>;

  /**
   * Signify to any owning processes that this driver encountered an error which should cause the
   * session to terminate immediately (for example an upstream service failed)
   *
   * @param err - the Error object which is causing the shutdown
   */
  startUnexpectedShutdown(err?: Error): Promise<void>;

  /**
   * Start the timer for the New Command Timeout, which when it runs out, will stop the current
   * session
   */
  startNewCommandTimeout(): Promise<void>;

  /**
   * The processed capabilities used to start the session represented by the current driver instance
   */
  caps?: Capabilities<C>;

  /**
   * The original capabilities used to start the session represented by the current driver instance
   */
  originalCaps?: W3CCapabilities<C>;

  /**
   * The constraints object used to validate capabilities
   */
  desiredCapConstraints: C;

  /**
   * Validate the capabilities used to start a session
   *
   * @param caps - the capabilities
   *
   * @internal
   *
   * @returns Whether or not the capabilities are valid
   */
  validateDesiredCaps(caps: DriverCaps<C>): boolean;

  /**
   * A helper function to log unrecognized capabilities to the console
   *
   * @params caps - the capabilities
   *
   * @internal
   */
  logExtraCaps(caps: DriverCaps<C>): void;

  /**
   * A helper function used to assign server information to the driver instance so the driver knows
   * where the server is Running
   *
   * @param server - the server object
   * @param host - the server hostname
   * @param port - the server port
   * @param path - the server base url
   */
  assignServer?(server: AppiumServer, host: string, port: number, path: string): void;
}

/**
 * External drivers must subclass `BaseDriver`, and can implement any methods from this interface.
 * None of these methods are implemented within Appium itself.
 */
export interface ExternalDriver<
  C extends Constraints = Constraints,
  Ctx = string,
  CArgs extends StringRecord = StringRecord,
  Settings extends StringRecord = StringRecord,
  CreateResult = DefaultCreateSessionResult<C>,
  DeleteResult = DefaultDeleteSessionResult,
  SessionData extends StringRecord = StringRecord,
> extends Driver<C, CArgs, Settings, CreateResult, DeleteResult, SessionData>,
    IWDClassicCommands,
    IAppiumCommands,
    IJSONWPCommands,
    IMJSONWPCommands<Ctx>,
    IOtherProtocolCommands {
  /**
   * Proxy a command to a connected WebDriver server
   *
   * @typeParam TReq - the type of the incoming body
   * @typeParam TRes - the type of the return value
   * @param url - the incoming URL
   * @param method - the incoming HTTP method
   * @param body - the incoming HTTP body
   *
   * @returns The return value of the proxied command
   */
  proxyCommand?<TReq = any, TRes = unknown>(
    url: string,
    method: HTTPMethod,
    body?: TReq,
  ): Promise<TRes>;
}

/**
 * Static members of a {@linkcode DriverClass}.
 *
 * This is likely unusable by external consumers, but YMMV!
 */
export interface DriverStatic<T extends Driver> {
  baseVersion: string;
  updateServer?: UpdateServerCallback;
  newMethodMap?: MethodMap<T>;
  /**
    * Drivers can define new custom bidi commands and map them to driver methods. The format must
    * be the same as that used by Appium's bidi-commands.js file, for example:
    * @example
    * {
    *   myNewBidiModule: {
    *     myNewBidiCommand: {
    *       command: 'driverMethodThatWillBeCalled',
    *       params: {
    *         required: ['requiredParam'],
    *         optional: ['optionalParam'],
    *       }
    *     }
    *   }
    * }
    */
  newBidiCommands?: BidiModuleMap;
  executeMethodMap?: ExecuteMethodMap<T>;
}

/**
 * Represents a driver class, which is used internally by Appium.
 *
 * This is likely unusable by external consumers, but YMMV!
 */
export type DriverClass<T extends Driver = Driver> = Class<
  T,
  DriverStatic<T>,
  [] | [Partial<ServerArgs>] | [Partial<ServerArgs>, boolean]
>;

export interface ExtraDriverOpts {
  fastReset?: boolean;
  skipUninstall?: boolean;
}
/**
 * Options as set within {@linkcode ExternalDriver.createSession}, which is a union of {@linkcode InitialOpts} and {@linkcode DriverCaps}.
 */
export type DriverOpts<C extends Constraints> = InitialOpts & DriverCaps<C>;

/**
 * Options as provided to the {@linkcode Driver} constructor.
 */
export type InitialOpts = Merge<ServerArgs, ExtraDriverOpts>;

/**
 * An instance method of a driver class, whose name may be referenced by {@linkcode MethodDef.command}, and serves as an Appium command.
 *
 * Note that this signature differs from a `PluginCommand`.
 */
export type DriverCommand<TArgs extends readonly any[] = any[], TRetval = unknown> = (
  ...args: TArgs
) => Promise<TRetval>;

/**
 * Tuple of an HTTP method with a regex matching a request path
 */
export type RouteMatcher = [HTTPMethod, RegExp];

/**
 * Result of the {@linkcode onPostProcess ConfigureAppOptions.onPostProcess} callback.
 */
export interface PostProcessResult {
  /**
   * The full past to the post-processed application package on the local file system .
   *
   * This might be a file or a folder path.
   */
  appPath: string;
}

/**
 * Information about a cached app instance.
 */
export interface CachedAppInfo {
  /**
   * SHA1 hash of the package if it is a file (and not a folder)
   */
  packageHash: string;
  /**
   * Date instance; the value of the file's `Last-Modified` header
   */
  lastModified?: Date | null;
  /**
   * The value of the file's `Etag` header
   */
  etag?: string | null;
  /**
   * `true` if the file contains an `immutable` mark in `Cache-control` header
   */
  immutable?: boolean;
  /**
   * Integer representation of `maxAge` parameter in `Cache-control` header
   */
  maxAge?: number | null;
  /**
   * The timestamp this item has been added to the cache (measured in Unix epoch milliseconds)
   */
  timestamp?: number;
  /**
   * An object containing either `file` property with SHA1 hash of the file or `folder` property
   * with total amount of cached files and subfolders
   */
  integrity?: {file?: string} | {folder?: number};
  /**
   * The full path to the cached app
   */
  fullPath?: string;
}

/**
 * Options for the post-processing step
 *
 * The generic can be supplied if using `axios`, where `headers` is a fancy object.
 */
export interface PostProcessOptions<Headers = HTTPHeaders> {
  /**
   * The original application url or path
   */
  originalAppLink: string;
  /**
   * The information about the previously cached app instance (if exists)
   */
  cachedAppInfo?: CachedAppInfo;
  /**
   * Whether the app has been downloaded from a remote URL
   */
  isUrl?: boolean;
  /**
   * Optional headers object.
   *
   * Only present if `isUrl` is `true` and if the server responds to `HEAD` requests. All header names are normalized to lowercase.
   */
  headers?: Headers;
  /**
   * A string containing full path to the preprocessed application package (either downloaded or a local one)
   */
  appPath?: string;
}

export interface DownloadAppOptions<Headers = HTTPHeaders> {
  /**
   * The original application url.
   */
  url: string;
  /**
   * Response headers from the download url.
   */
  headers: Headers;

  /**
   * Response stream.
   */
  stream: internal.Readable;
}

export interface ConfigureAppOptions {
  /**
   *
   * Optional function, which should be applied to the application after it is
   * downloaded/preprocessed.
   *
   * This function may be async and is expected to accept single object parameter. The function is
   * expected to either return a falsy value, which means the app must not be cached and a fresh
   * copy of it is downloaded each time, _or_ if this function returns an object containing an
   * `appPath` property, then the integrity of it will be verified and stored into the cache.
   * @returns
   */
  onPostProcess?: (
    obj: PostProcessOptions,
  ) => Promise<PostProcessResult | undefined> | PostProcessResult | undefined;
  /**
   * Optional function, which should be applied to the application upon download
   * progress initialization instead of the standard download handler.
   * The callback does not get invoked if the original application is not a URL.
   * It is expected that `onPostProcess` is also provided if this callback is defined.
   * Otherwise, there is a possibility the app configuration flow could be broken.
   *
   * @returns The full path to the downloaded app
   */
  onDownload?: (
    obj: DownloadAppOptions,
  ) => Promise<string>;
  supportedExtensions: string[];
}
