import {SubProcess} from 'teen_process';
import {fs, system} from 'appium/support.js';
import path from 'node:path';
import {EOL} from 'node:os';
import {escapeRegExp} from '../../utils/index.js';
import type {AppiumLogger} from '@appium/types';

const GRADLE_VERSION_KEY = 'gradle';
const GRADLE_URL_PREFIX = 'distributionUrl=';
export const GRADLE_URL_TEMPLATE =
  'https\\://services.gradle.org/distributions/gradle-VERSION-all.zip';
const DEPENDENCY_PROP_NAMES = [
  'additionalAppDependencies',
  'additionalAndroidTestDependencies',
] as const;

export const VERSION_KEYS = [
  GRADLE_VERSION_KEY,
  'androidGradlePlugin',
  'compileSdk',
  'buildTools',
  'minSdk',
  'targetSdk',
  'kotlin',
  'sourceCompatibility',
  'targetCompatibility',
  'jvmTarget',
  'composeVersion',
  'espressoVersion',
  'annotationVersion',
] as const;

export interface ServerSigningConfig {
  zipAlign: boolean;
  keystoreFile: string;
  keystorePassword: string;
  keyAlias: string;
  keyPassword: string;
}

export interface BuildServerSigningConfigArgs {
  keystoreFile: string;
  keystorePassword: string;
  keyAlias: string;
  keyPassword: string;
}

export interface EspressoBuildConfiguration {
  toolsVersions?: Record<string, string>;
  /**
   * When false, the Espresso server is built without Jetpack Compose dependencies.
   * Defaults to true when omitted.
   */
  composeSupport?: boolean;
  additionalAppDependencies?: string[];
  additionalAndroidTestDependencies?: string[];
}

export interface ServerBuilderOptions {
  serverPath: string;
  showGradleLog?: boolean;
  buildConfiguration?: EspressoBuildConfiguration;
  testAppPackage?: string;
  signingConfig?: ServerSigningConfig | null;
}

export class ServerBuilder {
  private readonly log: AppiumLogger;
  private readonly serverPath: string;
  private readonly showGradleLog?: boolean;
  private readonly serverVersions: Partial<Record<(typeof VERSION_KEYS)[number], string>>;
  private readonly testAppPackage?: string;
  private readonly signingConfig?: ServerSigningConfig | null;
  private readonly additionalAppDependencies: string[] = [];
  private readonly additionalAndroidTestDependencies: string[] = [];
  private readonly composeSupport: boolean;

  constructor(log: AppiumLogger, args: ServerBuilderOptions) {
    this.log = log;
    this.serverPath = args.serverPath;
    this.showGradleLog = args.showGradleLog;

    const buildConfiguration = args.buildConfiguration || {};
    this.composeSupport = buildConfiguration.composeSupport !== false;

    const versionConfiguration = buildConfiguration.toolsVersions || {};
    this.serverVersions = {} as Partial<Record<(typeof VERSION_KEYS)[number], string>>;
    for (const [key, value] of Object.entries(versionConfiguration)) {
      if (VERSION_KEYS.includes(key as (typeof VERSION_KEYS)[number])) {
        this.serverVersions[key as (typeof VERSION_KEYS)[number]] = value;
      } else {
        log.warn(`Got unexpected '${key}' in toolsVersion block of the build configuration`);
      }
    }

    this.testAppPackage = args.testAppPackage;
    this.signingConfig = args.signingConfig;

    for (const propName of DEPENDENCY_PROP_NAMES) {
      this[propName] = buildConfiguration[propName] || [];
    }
  }

  async build(): Promise<void> {
    const gradleVersion = this.serverVersions[GRADLE_VERSION_KEY];
    if (gradleVersion) {
      await this.setGradleWrapperVersion(gradleVersion);
    }

    await this.insertAdditionalDependencies();

    await this.runBuildProcess();
  }

  private getCommand(): {cmd: string; args: string[]} {
    const cmd = system.isWindows() ? 'gradlew.bat' : path.resolve(this.serverPath, 'gradlew');
    const buildProperty = (key: string, value?: string): string | null =>
      value ? `-P${key}=${value}` : null;
    const args: string[] = VERSION_KEYS.filter((key) => key !== GRADLE_VERSION_KEY)
      .map((key) => {
        const serverVersion = this.serverVersions[key];
        const gradleProperty = `appium${key.charAt(0).toUpperCase()}${key.slice(1)}`;
        return buildProperty(gradleProperty, serverVersion);
      })
      .filter((arg): arg is string => typeof arg === 'string' && Boolean(arg));

    const signingConfig = this.signingConfig;
    if (signingConfig) {
      args.push(
        ...Object.keys(signingConfig)
          .map((key) => {
            const propKey = key as keyof ServerSigningConfig;
            const propValue = signingConfig[propKey];
            const k = `appium${key.charAt(0).toUpperCase()}${key.slice(1)}`;
            const v: string | undefined = propValue != null ? String(propValue) : undefined;
            return buildProperty(k, v);
          })
          .filter((arg): arg is string => typeof arg === 'string' && Boolean(arg)),
      );
    }

    if (this.testAppPackage) {
      const targetPackageArg = buildProperty('appiumTargetPackage', this.testAppPackage);
      if (targetPackageArg) {
        args.push(targetPackageArg);
      }
    }
    if (!this.composeSupport) {
      const composeArg = buildProperty('appiumComposeSupport', 'false');
      if (composeArg) {
        args.push(composeArg);
      }
    }
    args.push('app:assembleAndroidTest');

    return {cmd, args};
  }

  private async setGradleWrapperVersion(version: string): Promise<void> {
    const propertiesPath = path.resolve(
      this.serverPath,
      'gradle',
      'wrapper',
      'gradle-wrapper.properties',
    );
    const originalProperties = await fs.readFile(propertiesPath, 'utf8');
    const newProperties = this.updateGradleDistUrl(originalProperties, version);
    await fs.writeFile(propertiesPath, newProperties, 'utf8');
  }

  private updateGradleDistUrl(propertiesContent: string, version: string): string {
    return propertiesContent.replace(
      new RegExp(`^(${escapeRegExp(GRADLE_URL_PREFIX)}).+$`, 'gm'),
      `$1${GRADLE_URL_TEMPLATE.replace('VERSION', version)}`,
    );
  }

  private async insertAdditionalDependencies(): Promise<void> {
    let hasAdditionalDeps = false;
    for (const propName of DEPENDENCY_PROP_NAMES) {
      const deps = this[propName];
      if (!Array.isArray(deps)) {
        throw new Error(`'${propName}' must be an array`);
      }
      if (deps.filter((line) => line.trim()).length === 0) {
        continue;
      }

      for (const dep of deps) {
        if (/[\s'\\$]/.test(dep)) {
          throw new Error(
            'Single quotes, dollar characters and whitespace characters' +
              ` are disallowed in additional dependencies: ${dep}`,
          );
        }
      }
      hasAdditionalDeps = true;
    }
    if (!hasAdditionalDeps) {
      return;
    }

    const buildPath = path.resolve(this.serverPath, 'app', 'build.gradle.kts');
    let configuration = await fs.readFile(buildPath, 'utf8');
    for (const propName of DEPENDENCY_PROP_NAMES) {
      const prefix = propName === DEPENDENCY_PROP_NAMES[0] ? 'api' : 'androidTestImplementation';
      const deps = this[propName]
        .filter((line) => line.trim())
        .map((line) => `${prefix}("${line}")`);
      if (deps.length === 0) {
        continue;
      }

      this.log.info(`Adding the following ${propName} to build.gradle.kts: ${deps}`);
      configuration = updateDependencyLines(configuration, propName, deps);
    }
    await fs.writeFile(buildPath, configuration, 'utf8');
  }

  private async runBuildProcess(): Promise<void> {
    const {cmd, args} = this.getCommand();
    this.log.debug(
      `Beginning build with command '${cmd} ${args.join(' ')}' ` +
        `in directory '${this.serverPath}'`,
    );
    const gradlebuild = new SubProcess(cmd, args, {
      cwd: this.serverPath,
      stdio: ['ignore', 'pipe', 'pipe'],
      // https://github.com/nodejs/node/issues/52572
      shell: system.isWindows(),
      windowsVerbatimArguments: true,
    });
    const gradleError: string[] = [];

    const logMsg = `Output from Gradle ${this.showGradleLog ? 'will' : 'will not'} be logged`;
    this.log.debug(`${logMsg}. To change this, use 'showGradleLog' desired capability`);
    gradlebuild.on('line-stderr', (line: string) => {
      this.log.warn(`[Gradle] ${line}`);
      gradleError.push(line);
    });
    gradlebuild.on('line-stdout', (line: string) => this.log.info(`[Gradle] ${line}`));

    try {
      await gradlebuild.start();
      await gradlebuild.join();
    } catch (err: any) {
      const msg =
        `Unable to build Espresso server - ${err.message}\n` +
        `Gradle error message:${EOL}${gradleError.join('\n')}`;
      throw this.log.errorWithException(msg);
    } finally {
      gradlebuild.removeAllListeners();
    }
  }
}

/**
 * Build keystore options for signing the built Espresso server test APK.
 *
 * @param args - Paths and credentials for the keystore used when signing.
 * @returns Configuration consumed by the Gradle signing step.
 */
export function buildServerSigningConfig(args: BuildServerSigningConfigArgs): ServerSigningConfig {
  return {
    zipAlign: true,
    keystoreFile: args.keystoreFile,
    keystorePassword: args.keystorePassword,
    keyAlias: args.keyAlias,
    keyPassword: args.keyPassword,
  };
}

/**
 * Insert Gradle dependency lines after a `// placeholder` marker in a Gradle file.
 *
 * @param originalContent - Full text of the Gradle configuration file.
 * @param dependencyPlaceholder - Placeholder comment label to find (e.g. from build.gradle).
 * @param dependencyLines - Dependency lines to insert (e.g. `implementation "..."`).
 * @returns Updated file content, or the original string if the placeholder is missing.
 */
export function updateDependencyLines(
  originalContent: string,
  dependencyPlaceholder: string,
  dependencyLines: string[],
): string {
  const configurationLines = originalContent.split('\n');
  const searchRe = new RegExp(`^\\s*//\\s*\\b${escapeRegExp(dependencyPlaceholder)}\\b`, 'm');
  const placeholderIndex = configurationLines.findIndex((line) => searchRe.test(line));
  if (placeholderIndex < 0) {
    return originalContent;
  }

  const placeholderLine = configurationLines[placeholderIndex];
  const indentLen = placeholderLine.length - placeholderLine.trimStart().length;
  configurationLines.splice(
    placeholderIndex + 1,
    0,
    ...dependencyLines.map((line) => `${' '.repeat(indentLen)}${line}`),
  );
  return configurationLines.join('\n');
}
