import {fs, tempDir, util} from 'appium/support';
import {asyncfilter} from 'asyncbox';
import path from 'node:path';
import _ from 'lodash';
import {CrashReportsClient} from '../crash-reports-client';
import {IOSLog} from './ios-log';
import {toLogEntry, grepFile} from './helpers';
import type {AppiumLogger} from '@appium/types';
import type {Simulator} from 'appium-ios-simulator';
import type {LogEntry} from '../../commands/types';

// The file format has been changed from '.crash' to '.ips' since Monterey.
const CRASH_REPORTS_GLOB_PATTERN = '**/*.@(crash|ips)';
// The size of a single diagnostic report might be hundreds of kilobytes.
// Thus we do not want to store too many items in the memory at once.
const MAX_RECENT_ITEMS = 20;

/**
 * Options for {@link IOSCrashLog}.
 */
export interface IOSCrashLogOptions {
  /** UDID of a real device (omit with `sim` for Simulator crash logs). */
  udid?: string;
  /** Simulator instance; required for simulator-side collection. */
  sim?: Simulator;
  log: AppiumLogger;
  /**
   * For real devices: must reflect **iOS/tvOS 18+** so {@link CrashReportsClient} can be used.
   * Typically matches `isIos18OrNewer` from the active session.
   */
  useRemoteXPC?: boolean;
}

type TSerializedEntry = [string, number];

/**
 * Collects iOS/tvOS crash logs for BiDi `log.entryAdded` / classic log APIs.
 *
 * - **Simulator:** reads `~/Library/Logs/DiagnosticReports` and filters by simulator UDID.
 * - **Real device:** uses RemoteXPC (`appium-ios-remotexpc`) when `useRemoteXPC` is true; if the client
 *   cannot be created, collection is skipped and errors are logged (no session failure).
 */
export class IOSCrashLog extends IOSLog<TSerializedEntry, TSerializedEntry> {
  private readonly _udid: string | undefined;
  private readonly _useRemoteXPC: boolean;
  private _realDeviceClient: CrashReportsClient | null;
  private readonly _logDir: string | null;
  private readonly _sim: Simulator | undefined;
  private _recentCrashFiles: string[];
  private _started: boolean;

  /**
   * @param opts - Provide `udid` for a real device or `sim` for a Simulator (mutually exclusive by usage).
   */
  constructor(opts: IOSCrashLogOptions) {
    super({
      log: opts.log,
      maxBufferSize: MAX_RECENT_ITEMS,
    });
    this._udid = opts.udid;
    this._sim = opts.sim;
    this._useRemoteXPC = opts.useRemoteXPC ?? false;
    this._realDeviceClient = null;
    this._logDir = this._isRealDevice()
      ? null
      : path.resolve(process.env.HOME || '/', 'Library', 'Logs', 'DiagnosticReports');
    this._recentCrashFiles = [];
    this._started = false;
  }

  override get isCapturing(): boolean {
    return this._started;
  }

  /** Records the current crash file snapshot so only new reports appear in {@link IOSCrashLog.getLogs}. */
  override async startCapture(): Promise<void> {
    this._recentCrashFiles = await this._listCrashFiles();
    this._started = true;
  }

  /** Stops polling and closes any real-device {@link CrashReportsClient}. */
  override async stopCapture(): Promise<void> {
    this._started = false;
    // Clean up the client connection
    if (this._realDeviceClient) {
      await this._realDeviceClient.close();
      this._realDeviceClient = null;
    }
  }

  /**
   * @returns New crash log entries since the last successful poll (bounded by {@link MAX_RECENT_ITEMS}).
   */
  override async getLogs(): Promise<LogEntry[]> {
    const crashFiles = (await this._listCrashFiles()).slice(-MAX_RECENT_ITEMS);
    const diffFiles = _.difference(crashFiles, this._recentCrashFiles);
    if (_.isEmpty(diffFiles)) {
      return [];
    }

    this.log.debug(`Found ${util.pluralize('fresh crash report', diffFiles.length, true)}`);
    await this._serializeCrashes(diffFiles);
    this._recentCrashFiles = crashFiles;
    return super.getLogs();
  }

  protected override _serializeEntry(value: TSerializedEntry): TSerializedEntry {
    return value;
  }

  protected override _deserializeEntry(value: TSerializedEntry): LogEntry {
    const [message, timestamp] = value;
    return toLogEntry(message, timestamp);
  }

  /** Reads crash file contents and {@link IOSLog.broadcast}s them as `[text, mtime]` tuples. */
  private async _serializeCrashes(paths: string[]): Promise<void> {
    const tmpRoot = await tempDir.openDir();
    try {
      for (const filePath of paths) {
        let fullPath = filePath;
        if (this._isRealDevice()) {
          const fileName = filePath;
          try {
            await (this._realDeviceClient as CrashReportsClient).exportCrash(fileName, tmpRoot);
          } catch (e) {
            this.log.warn(
              `Cannot export the crash report '${fileName}'. Skipping it. ` +
                `Original error: ${(e as Error).message}`,
            );
            return;
          }
          fullPath = path.join(tmpRoot, fileName);
        }
        const {ctime} = await fs.stat(fullPath);
        this.broadcast([await fs.readFile(fullPath, 'utf8'), ctime.getTime()]);
      }
    } finally {
      await fs.rimraf(tmpRoot);
    }
  }

  /**
   * Lazily creates a {@link CrashReportsClient} and lists `.ips` basenames on the device.
   *
   * @returns Empty array if RemoteXPC setup or listing fails (logged). The client is reset after
   *   listing errors so a later poll can recreate it. Never throws to callers.
   */
  private async _gatherFromRealDevice(): Promise<string[]> {
    if (!this._realDeviceClient) {
      try {
        this._realDeviceClient = await CrashReportsClient.create(
          this._udid as string,
          this._useRemoteXPC,
        );
      } catch (err) {
        this.log.error(
          `Failed to create crash reports client: ${(err as Error).message}. ` +
            `Skipping crash logs collection for real devices.`,
        );
        return [];
      }
    }

    try {
      return await this._realDeviceClient.listCrashes();
    } catch (err) {
      this.log.error(
        `Failed to list crash reports on device: ${(err as Error).message}. ` +
          `Skipping this poll; the next poll will attempt to reconnect.`,
      );
      const client = this._realDeviceClient;
      this._realDeviceClient = null;
      if (client) {
        try {
          await client.close();
        } catch {
          // ignore secondary teardown errors
        }
      }
      return [];
    }
  }

  /** Glob diagnostic reports and keep files whose content references the simulator UDID. */
  private async _gatherFromSimulator(): Promise<string[]> {
    if (!this._logDir || !this._sim || !(await fs.exists(this._logDir))) {
      this.log.debug(`Crash reports root '${this._logDir}' does not exist. Got nothing to gather.`);
      return [];
    }

    const foundFiles = await fs.glob(CRASH_REPORTS_GLOB_PATTERN, {
      cwd: this._logDir,
      absolute: true,
    });
    const simUdid = (this._sim as Simulator).udid;
    // For Simulator only include files, that contain current UDID
    return await asyncfilter(foundFiles, async (filePath) => {
      try {
        return await grepFile(filePath, simUdid, {caseInsensitive: true});
      } catch (err) {
        this.log.warn(err);
        return false;
      }
    });
  }

  /** Dispatches to real-device RemoteXPC listing or simulator filesystem globbing. */
  private async _listCrashFiles(): Promise<string[]> {
    return this._isRealDevice()
      ? await this._gatherFromRealDevice()
      : await this._gatherFromSimulator();
  }

  private _isRealDevice(): boolean {
    return Boolean(this._udid);
  }
}

export default IOSCrashLog;
