import * as child_process from "child_process";
import fs from "fs";
import fsp from "node:fs/promises";
import { pipeline } from "node:stream/promises";
import assert from "node:assert";
import crypto from "crypto";

import path from "path";

import { request } from "undici";

import { formatErr, LogContext, logger } from "./logger.js";
import { getFollowRedirectDispatcher, getSafeProxyString } from "./proxy.js";
import { initStorage, S3StorageSync, UploadResult } from "./storage.js";

import {
  DISPLAY,
  PAGE_OP_TIMEOUT_SECS,
  type ServiceWorkerOpt,
} from "./constants.js";

import puppeteer, {
  Frame,
  HTTPRequest,
  Page,
  LaunchOptions,
  Viewport,
  CookieData,
  WaitForNetworkIdleOptions,
  CDPSession,
  Target,
  Browser as PptrBrowser,
} from "puppeteer-core";
import { Recorder } from "./recorder.js";
import { timedRun } from "./timing.js";
import { replaceDir } from "./file_reader.js";

type BtrixChromeOpts = {
  proxyServer?: string;
  proxyPacUrl?: string;
  userAgent?: string | null;
  extraArgs?: string[];
};

type LaunchOpts = {
  profileUrl: string;
  chromeOptions: BtrixChromeOpts;
  signals: boolean;
  headless: boolean;
  // TODO: Fix this the next time the file is edited.
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  emulateDevice?: Record<string, any>;

  ondisconnect?: ((err: unknown) => NonNullable<unknown>) | null;

  swOpt?: ServiceWorkerOpt;

  recording: boolean;
};

// fixed height of the browser UI (may need to be adjusted in the future)
// todo: a way to determine this?
const BROWSER_HEIGHT_OFFSET = 81;

// ==================================================================
export class Browser {
  downloadsDir: string;
  profileDir: string;
  customProfile = false;
  // TODO: Fix this the next time the file is edited.
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  emulateDevice: Record<string, any> | null = null;

  browser?: PptrBrowser | null = null;
  firstCDP: CDPSession | null = null;

  recorders: Recorder[] = [];

  swOpt?: ServiceWorkerOpt = "disabled";

  crashed = false;
  launched = false;

  screenWidth: number;
  screenHeight: number;
  screenWHRatio: number;

  constructor(rootDir: string) {
    this.downloadsDir = path.join(rootDir, "downloads");
    this.profileDir = path.join(rootDir, "profile");

    // must be provided, part of Dockerfile
    assert(process.env.GEOMETRY);

    const geom = process.env.GEOMETRY.split("x");

    this.screenWidth = Number(geom[0]);
    this.screenHeight = Number(geom[1]);
    this.screenWHRatio = this.screenWidth / this.screenHeight;
  }

  async launch({
    profileUrl,
    chromeOptions,
    signals = false,
    headless = false,
    emulateDevice = {},
    swOpt = "disabled",
    ondisconnect = null,
    recording = true,
  }: LaunchOpts) {
    if (this.isLaunched()) {
      return;
    }

    await this.installProfile(profileUrl);

    this.swOpt = swOpt;

    this.emulateDevice = emulateDevice;

    const args = this.chromeArgs(chromeOptions);

    if (recording) {
      args.push("--disable-site-isolation-trials");
    }

    if (!headless) {
      args.push(`--display=${DISPLAY}`);
    }

    const defaultViewport = {
      width: this.screenWidth,
      height: this.screenHeight - (recording ? 0 : BROWSER_HEIGHT_OFFSET),
    };

    const launchOpts: LaunchOptions = {
      args,
      headless,
      executablePath: this.getBrowserExe(),
      ignoreDefaultArgs: ["--enable-automation", "--hide-scrollbars"],
      acceptInsecureCerts: true,
      handleSIGHUP: signals,
      handleSIGINT: signals,
      handleSIGTERM: signals,
      protocolTimeout: 0,

      defaultViewport,
      waitForInitialPage: false,
      userDataDir: this.profileDir,
      targetFilter: recording
        ? undefined
        : (target) => this.targetFilter(target),
    };
    await this._init(launchOpts, recording, ondisconnect);
  }

  targetFilter(target: Target) {
    const attach = !(!target.url() && target.type() === "other");
    logger.debug(
      "Target Filter",
      { url: target.url(), type: target.type(), attach },
      "browser",
    );
    return attach;
  }

  async setupPage({ page }: { page: Page; cdp: CDPSession }) {
    switch (this.swOpt) {
      case "disabled":
        logger.debug("Service Workers: always disabled", {}, "browser");
        await page.setBypassServiceWorker(true);
        break;

      case "disabled-if-profile":
        if (this.customProfile) {
          logger.debug(
            "Service Workers: disabled since using profile",
            {},
            "browser",
          );
          await page.setBypassServiceWorker(true);
        }
        break;

      case "enabled":
        logger.debug("Service Workers: always enabled", {}, "browser");
        break;
    }
  }

  async installProfile(profileUrl: string) {
    await fsp.mkdir(this.profileDir, { recursive: true });

    if (!profileUrl) {
      return;
    }

    const profileTarGz = path.join(this.downloadsDir, "profile.tar.gz");

    const exists = fs.existsSync(profileTarGz);

    const suffix = crypto.randomBytes(4).toString("hex");

    const tmpProfileDest = path.join(
      this.downloadsDir,
      `profile-${suffix}.tar.gz`,
    );
    const tmpProfileDir = path.join(this.downloadsDir, `profile-${suffix}`);

    await fsp.mkdir(tmpProfileDir, { recursive: true });

    try {
      await this.loadProfile(profileUrl, tmpProfileDest, tmpProfileDir);

      // replace old profile dir with new profile dir
      await replaceDir(tmpProfileDir, this.profileDir, exists);

      // replace old tarball with new tarball
      await fsp.rename(tmpProfileDest, profileTarGz);
    } catch (e) {
      if (exists) {
        logger.warn(
          "Error updating profile, using existing profile",
          formatErr(e),
          "browser",
        );
      } else {
        // remove the temp profile dir, likely empty
        await fsp.rm(tmpProfileDir, { recursive: true });
        logger.fatal("Profile setup failed", formatErr(e), "browser");
      }
    }
    this.customProfile = true;
  }

  async loadProfile(
    profileRemoteSrc: string,
    profileLocalSrc: string,
    profileDir: string,
  ) {
    if (
      profileRemoteSrc &&
      (profileRemoteSrc.startsWith("http:") ||
        profileRemoteSrc.startsWith("https:"))
    ) {
      logger.info(
        `Downloading ${profileRemoteSrc} to ${profileLocalSrc}`,
        {},
        "browser",
      );

      const resp = await request(profileRemoteSrc, {
        dispatcher: getFollowRedirectDispatcher(),
      });

      await pipeline(resp.body, fs.createWriteStream(profileLocalSrc));
    } else if (profileRemoteSrc && profileRemoteSrc.startsWith("@")) {
      const storage = initStorage();

      if (!storage) {
        throw new Error(
          "Profile specified relative to s3 storage, but no S3 storage defined",
        );
      }

      await storage.downloadFile(profileRemoteSrc.slice(1), profileLocalSrc);
    } else {
      await fsp.copyFile(profileRemoteSrc, profileLocalSrc);
    }

    try {
      child_process.execSync("tar xvfz " + profileLocalSrc, {
        cwd: profileDir,
        stdio: "ignore",
      });
      this.removeSingletons();
    } catch (e) {
      throw new Error(`Profile ${profileLocalSrc} not a valid tar.gz`);
    }
  }

  removeSingletons() {
    try {
      child_process.execSync("rm ./Singleton*", {
        cwd: this.profileDir,
        stdio: "ignore",
      });
    } catch (e) {
      // ignore
    }
  }

  async saveProfile(
    localFilename?: string,
    storage: S3StorageSync | null = null,
    remoteFilename?: string,
  ): Promise<UploadResult | null> {
    if (this.browser) {
      logger.warn(
        "Browser must be closed before saving profile",
        {},
        "browser",
      );
      return null;
    }

    if (!this.launched) {
      logger.warn(
        "Browser never launched, skipping profile update",
        {},
        "browser",
      );
      return null;
    }

    logger.info("Saving Browser Profile");

    try {
      // First, determine valid local path
      // can't save to http/https, so use default local path
      if (
        localFilename &&
        (localFilename.startsWith("http:") ||
          localFilename.startsWith("https") ||
          localFilename.startsWith("@"))
      ) {
        localFilename = "";
      }

      if (localFilename && !localFilename.startsWith("/")) {
        localFilename = path.resolve("/crawls/profiles/", localFilename);
        logger.info(
          `Absolute path for filename not provided, saving to ${localFilename}`,
          "browser",
        );
      }

      const profileFilename =
        localFilename || "/crawls/profiles/profile.tar.gz";

      const outputDir = path.dirname(profileFilename);
      if (outputDir && !fs.existsSync(outputDir)) {
        fs.mkdirSync(outputDir, { recursive: true });
      }

      logger.info("Local profile saved", { profileFilename }, "browser");
      child_process.execFileSync("tar", ["cvfz", profileFilename, "./"], {
        cwd: this.profileDir,
      });

      let resource: UploadResult | null = null;

      // Only storage relative remote path supported
      if (remoteFilename && storage) {
        // remote storage relative prefix, always storage relative here
        if (remoteFilename.startsWith("@")) {
          remoteFilename = remoteFilename.slice(1);
        }
        if (
          remoteFilename.startsWith("http:") ||
          remoteFilename.startsWith("https:") ||
          remoteFilename.startsWith("/")
        ) {
          logger.warn(
            "Not saving remote profile, invalid target",
            { remoteFilename },
            "browser",
          );
        } else {
          logger.info("Uploading to remote storage...", {}, "browser");
          resource = await storage.uploadFile(profileFilename, remoteFilename);
        }
      }

      logger.info("Profile creation done", {}, "browser");
      return resource;
    } catch (e) {
      logger.error("Unable to save profile after crawl", e, "browser");
      return null;
    }
  }

  chromeArgs({
    proxyServer = "",
    proxyPacUrl = "",
    userAgent = null,
    extraArgs = [],
  }: BtrixChromeOpts) {
    // Chrome Flags, including proxy server
    const args = [
      // eslint-disable-next-line no-use-before-define
      ...defaultArgs,
      ...(process.env.CHROME_FLAGS ?? "").split(" ").filter(Boolean),
      //"--no-xshm", // needed for Chrome >80 (check if puppeteer adds automatically)
      "--no-sandbox",
      "--disable-background-media-suspend",
      "--remote-debugging-port=9221",
      "--remote-allow-origins=*",
      "--autoplay-policy=no-user-gesture-required",
      `--user-agent=${userAgent || this.getDefaultUA()}`,
      ...extraArgs,
    ];

    if (proxyServer) {
      const proxyString = getSafeProxyString(proxyServer);
      logger.info("Using proxy", { proxy: proxyString }, "browser");

      args.push("--ignore-certificate-errors");
      args.push(`--proxy-server=${proxyServer}`);
    } else if (proxyPacUrl) {
      args.push("--proxy-pac-url=" + proxyPacUrl);
    }

    return args;
  }

  getDefaultUA() {
    let version: string | undefined = process.env.BROWSER_VERSION;

    try {
      const browser = this.getBrowserExe();
      if (browser) {
        version = child_process.execFileSync(browser, ["--version"], {
          encoding: "utf8",
        });
        const match = version && version.match(/[\d.]+/);
        if (match) {
          version = match[0];
        }
      }
    } catch (e) {
      console.error(e);
    }

    return `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${version} Safari/537.36`;
  }

  getBrowserExe() {
    const files = [
      process.env.BROWSER_BIN,
      "/usr/bin/google-chrome",
      "/usr/bin/chromium-browser",
    ];
    for (const file of files) {
      if (file && fs.existsSync(file)) {
        return file;
      }
    }
  }

  async evaluateWithCLI(
    cdp: CDPSession,
    frame: Frame,
    frameIdToExecId: Map<string, number>,
    funcString: string,
    logData: Record<string, string>,
    contextName: LogContext,
  ) {
    const frameUrl = frame.url();
    // TODO: Fix this the next time the file is edited.
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    let details: Record<string, any> = { frameUrl, ...logData };

    if (!frameUrl || frame.detached) {
      logger.debug(
        "Run Script Skipped, frame no longer attached or has no URL",
        details,
        contextName,
      );
      return false;
    }

    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const frameId = (frame as any)._id;

    const contextId = frameIdToExecId.get(frameId);

    if (!contextId) {
      logger.warn(
        "Not running behavior, missing CDP context id for frame id",
        { frameId },
        "browser",
      );
      return;
    }

    const isTopFrame = !frame.parentFrame();

    if (isTopFrame) {
      logger.debug("Run Script Started", details, contextName);
    } else {
      logger.debug("Run Script Started in iframe", details, contextName);
    }

    // from puppeteer _evaluateInternal() but with includeCommandLineAPI: true
    //const contextId = context._contextId;
    const expression = funcString + "\n//# sourceURL=__evaluation_script__";

    const { exceptionDetails, result } = await cdp.send("Runtime.evaluate", {
      expression,
      contextId,
      returnByValue: true,
      awaitPromise: true,
      userGesture: true,
      includeCommandLineAPI: true,
    });

    if (exceptionDetails) {
      if (exceptionDetails.stackTrace) {
        details = {
          ...exceptionDetails.stackTrace,
          text: exceptionDetails.text,
          ...details,
        };
      }
      logger.error("Run Script Failed", details, contextName);
    } else {
      if (isTopFrame) {
        logger.debug("Run Script Finished", details, contextName);
      } else {
        logger.debug("Run Script Finished in iframe", details, contextName);
      }
    }

    return result.value;
  }

  isLaunched() {
    if (this.browser) {
      logger.warn("Context already inited", {}, "browser");
      return true;
    }

    return false;
  }

  async close() {
    if (!this.browser) {
      return;
    }

    if (!this.crashed) {
      this.browser.removeAllListeners("disconnected");
      try {
        await timedRun(
          this.browser.close(),
          PAGE_OP_TIMEOUT_SECS,
          "Closing Browser Timed Out",
          {},
          "browser",
          true,
        );
      } catch (e) {
        // ignore
      }
      this.browser = null;
    }
  }

  killBrowser() {
    // used when main browser process appears to be stalled
    // puppeteer should still be able to continue, from initial testing
    // and avoids interrupting crawler when not auto-restarting
    if (this.browser) {
      const proc = this.browser.process();
      if (proc) {
        proc.kill();
      }
    }
  }

  async addInitScript(page: Page, script: string) {
    await page.evaluateOnNewDocument(script);
  }

  async checkScript(cdp: CDPSession, filename: string, script: string) {
    const { exceptionDetails } = await cdp.send("Runtime.evaluate", {
      expression: script,
    });
    if (exceptionDetails) {
      logger.fatal(
        "Custom behavior load error, aborting",
        { filename, ...exceptionDetails },
        "behavior",
      );
    }
  }

  private async _init(
    launchOpts: LaunchOptions,
    recording: boolean,
    // eslint-disable-next-line @typescript-eslint/ban-types
    ondisconnect: Function | null = null,
  ) {
    this.browser = await puppeteer.launch(launchOpts);

    const target = this.browser.target();

    this.firstCDP = await target.createCDPSession();
    this.launched = true;

    if (recording) {
      await this.browserContextFetch();
    }

    if (ondisconnect) {
      this.browser.on("disconnected", (err) => ondisconnect(err));
    }
    this.browser.on("disconnected", () => {
      this.browser = null;
    });

    // common permissions
    const permissions = [
      "notifications",
      "geolocation",
      "camera",
      "microphone",
    ];

    for (const name of permissions) {
      await this.firstCDP.send("Browser.setPermission", {
        permission: { name },
        setting: "granted",
      });
    }
  }

  async newWindowPageWithCDP(): Promise<{ cdp: CDPSession; page: Page }> {
    // unique url to detect new pages
    const startPage =
      "about:blank?_browsertrix" + Math.random().toString(36).slice(2);

    const p = new Promise<Target>((resolve) => {
      const listener = (target: Target) => {
        if (target.url() === startPage) {
          resolve(target);
          if (this.browser) {
            this.browser.off("targetcreated", listener);
          }
        }
      };

      if (this.browser) {
        this.browser.on("targetcreated", listener);
      }
    });

    if (!this.firstCDP) {
      throw new Error("CDP missing");
    }

    try {
      await this.firstCDP.send("Target.createTarget", {
        url: startPage,
        newWindow: true,
      });
    } catch (e) {
      if (!this.browser) {
        throw e;
      }
      const target = this.browser.target();

      this.firstCDP = await target.createCDPSession();

      await this.firstCDP.send("Target.createTarget", {
        url: startPage,
        newWindow: true,
      });
    }

    const target = await p;

    const page = await target.page();
    if (!page) {
      throw new Error("page missing");
    }

    const device = this.emulateDevice;

    if (device && page) {
      if (device.viewport && device.userAgent) {
        // TODO: Fix this the next time the file is edited.
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        await page.emulate(device as any);
      } else if (device.userAgent) {
        await page.setUserAgent(device.userAgent);
      }
    }

    const cdp = await target.createCDPSession();

    return { page, cdp };
  }

  async browserContextFetch() {
    if (!this.firstCDP) {
      return;
    }

    this.firstCDP.on("Fetch.requestPaused", async (params) => {
      const { frameId, requestId, request } = params;

      const { url } = request;

      if (!this.firstCDP) {
        throw new Error("CDP missing");
      }

      let foundRecorder = null;

      for (const recorder of this.recorders) {
        if (recorder.swUrls.has(url)) {
          recorder.swFrameIds.add(frameId);
        }

        if (recorder.hasFrame(frameId)) {
          foundRecorder = recorder;
          break;
        }
      }

      if (!foundRecorder) {
        try {
          await this.firstCDP.send("Fetch.continueResponse", { requestId });
        } catch (e) {
          logger.debug(
            "continueResponse failed",
            { url, ...formatErr(e), from: "serviceWorker" },
            "recorder",
          );
        }

        return;
      }

      await foundRecorder.handleRequestPaused(params, this.firstCDP, true);
    });

    await this.firstCDP.send("Fetch.enable", {
      patterns: [{ urlPattern: "*", requestStage: "Response" }],
    });
  }

  interceptRequest(page: Page, callback: (event: HTTPRequest) => void) {
    page.on("request", callback);
  }

  async waitForNetworkIdle(page: Page, params: WaitForNetworkIdleOptions) {
    return await page.waitForNetworkIdle(params);
  }

  async setViewport(page: Page, params: Viewport) {
    await page.setViewport(params);
  }

  async getCookies() {
    return (await this.browser?.cookies()) || [];
  }

  async setCookies(cookies: CookieData[]) {
    return await this.browser?.setCookie(...cookies);
  }
}

// ==================================================================
// Default Chromium args from playwright
export const defaultArgs = [
  "--disable-field-trial-config", // https://source.chromium.org/chromium/chromium/src/+/main:testing/variations/README.md
  "--disable-background-networking",
  "--enable-features=NetworkService,NetworkServiceInProcess",
  "--disable-background-timer-throttling",
  "--disable-backgrounding-occluded-windows",
  "--disable-back-forward-cache", // Avoids surprises like main request not being intercepted during page.goBack().
  "--disable-breakpad",
  "--disable-client-side-phishing-detection",
  "--disable-component-extensions-with-background-pages",
  "--disable-component-update",
  "--allow-brave-component-update", // disable general update, but allow brave update
  "--no-default-browser-check",
  "--disable-default-apps",
  "--disable-dev-shm-usage",
  "--disable-extensions",
  // AvoidUnnecessaryBeforeUnloadCheckSync - https://github.com/microsoft/playwright/issues/14047
  // Translate - https://github.com/microsoft/playwright/issues/16126
  // Optimization* - https://bugs.chromium.org/p/chromium/issues/detail?id=1311753
  "--disable-features=ImprovedCookieControls,LazyFrameLoading,GlobalMediaControls,DestroyProfileOnBrowserClose,MediaRouter,DialMediaRouteProvider,AcceptCHFrame,AutoExpandDetailsElement,CertificateTransparencyComponentUpdater,AvoidUnnecessaryBeforeUnloadCheckSync,Translate,OptimizationGuideModelDownloading,OptimizationHintsFetching,OptimizationTargetPrediction,OptimizationHints",
  "--allow-pre-commit-input",
  "--disable-hang-monitor",
  "--disable-ipc-flooding-protection",
  "--disable-popup-blocking",
  "--disable-prompt-on-repost",
  "--disable-renderer-backgrounding",
  "--disable-sync",
  "--disable-lazy-loading",
  "--force-color-profile=srgb",
  "--metrics-recording-only",
  "--no-first-run",
  "--enable-automation",
  "--password-store=basic",
  "--use-mock-keychain",
  // See https://chromium-review.googlesource.com/c/chromium/src/+/2436773
  "--no-service-autorun",
  "--export-tagged-pdf",
  "--apps-keep-chrome-alive-in-tests",
];
