import {
  type IDataObject,
  type IExecuteFunctions,
  type ILoadOptionsFunctions,
  type INodeExecutionData,
  type INodePropertyOptions,
  type INodeType,
  type INodeTypeDescription,
  NodeOperationError,
} from "n8n-workflow";
import { makeResolverFromLegacyOptions, NodeVM } from "@n8n/vm2";
import { mkdtempSync, readdirSync, readFileSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";

import puppeteer from "puppeteer-extra";
import pluginStealth from "puppeteer-extra-plugin-stealth";
//@ts-ignore
import pluginHumanTyping from "puppeteer-extra-plugin-human-typing";
import {
  type Browser,
  type Device,
  KnownDevices as devices,
  type Page,
  type PaperFormat,
  type PDFOptions,
  type PuppeteerLifeCycleEvent,
  type ScreenshotOptions,
} from "puppeteer";

import {
  nodeDescription,
  isRunningInContainer,
} from "./Puppeteer.node.options";

const {
  NODE_FUNCTION_ALLOW_BUILTIN: builtIn,
  NODE_FUNCTION_ALLOW_EXTERNAL: external,
  CODE_ENABLE_STDOUT,
} = process.env;

const CONTAINER_LAUNCH_ARGS = [
  "--no-sandbox",
  "--disable-setuid-sandbox",
  "--disable-dev-shm-usage",
  "--disable-gpu",
];

export const vmResolver = makeResolverFromLegacyOptions({
  external: external
    ? {
        modules: external.split(","),
        transitive: false,
      }
    : false,
  builtin: builtIn?.split(",") ?? [],
});

interface HeaderObject {
  parameter: Record<string, string>[];
}

interface QueryParameter {
  name: string;
  value: string;
}

type ErrorResponse = INodeExecutionData & {
  json: {
    error: string;
    url?: string;
    headers?: HeaderObject;
    statusCode?: number;
    body?: string;
  };
  pairedItem: {
    item: number;
  };
  [key: string]: unknown;
  error: Error;
};

const DEFAULT_USER_AGENT =
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.75 Safari/537.36";

async function handleError(
  this: IExecuteFunctions,
  error: Error,
  itemIndex: number,
  url?: string,
  page?: Page,
): Promise<INodeExecutionData[]> {
  if (page) {
    try {
      await page.close();
    } catch (closeError) {
      console.error("Error closing page:", closeError);
    }
  }

  if (this.continueOnFail()) {
    const nodeOperationError = new NodeOperationError(
      this.getNode(),
      error.message,
    );

    const errorResponse: ErrorResponse = {
      json: {
        error: error.message,
      },
      pairedItem: {
        item: itemIndex,
      },
      error: nodeOperationError,
    };

    if (url) {
      errorResponse.json.url = url;
    }

    return [errorResponse];
  }

  throw new NodeOperationError(this.getNode(), error.message);
}

async function handleOptions(
  this: IExecuteFunctions,
  itemIndex: number,
  items: INodeExecutionData[],
  browser: Browser,
  page: Page,
): Promise<void> {
  const options = this.getNodeParameter("options", 0, {}) as IDataObject;
  const pageCaching = options.pageCaching !== false;
  const headers: HeaderObject = (options.headers || {}) as HeaderObject;

  const requestHeaders = (headers.parameter || []).reduce((acc, header) => {
    acc[header.name] = header.value;
    return acc;
  }, {});
  const device = options.device as string;

  await page.setCacheEnabled(pageCaching);

  if (device) {
    const emulatedDevice = devices[device as keyof typeof devices] as Device;
    if (emulatedDevice) {
      await page.emulate(emulatedDevice);
    }
  } else {
    const userAgent =
      requestHeaders["User-Agent"] ||
      requestHeaders["user-agent"] ||
      DEFAULT_USER_AGENT;
    await page.setUserAgent(userAgent);
  }

  await page.setExtraHTTPHeaders(requestHeaders);
}

async function setupDownloadCapture(
  page: Page,
): Promise<{ downloadPath: string; cleanup: () => void }> {
  // Create a temporary directory for downloads
  const downloadPath = mkdtempSync(join(tmpdir(), "n8n-puppeteer-"));

  // Get the CDP session
  const client = await page.createCDPSession();

  // Set download behavior
  await client.send("Browser.setDownloadBehavior", {
    behavior: "allow",
    downloadPath,
  });

  const cleanup = () => {
    try {
      rmSync(downloadPath, { recursive: true, force: true });
    } catch (error) {
      console.error("Error cleaning up download directory:", error);
    }
  };

  return { downloadPath, cleanup };
}

async function getDownloadedFiles(
  downloadPath: string,
  helpers: IExecuteFunctions["helpers"],
): Promise<INodeExecutionData["binary"]> {
  const files = readdirSync(downloadPath);
  const binaryData: INodeExecutionData["binary"] = {};

  for (const file of files) {
    const filePath = join(downloadPath, file);
    const fileBuffer = readFileSync(filePath);
    const binary = await helpers.prepareBinaryData(fileBuffer, file);
    binaryData[file] = binary;
  }

  return binaryData;
}

async function runCustomScript(
  this: IExecuteFunctions,
  itemIndex: number,
  items: INodeExecutionData[],
  browser: Browser,
  page: Page,
): Promise<INodeExecutionData[]> {
  const scriptCode = this.getNodeParameter("scriptCode", itemIndex) as string;
  const options = this.getNodeParameter("options", 0, {}) as IDataObject;
  const captureDownloads = options.captureDownloads === true;

  // Get the query parameter for AI agent compatibility
  const query = this.getNodeParameter("query", itemIndex, "") as string;

  let downloadPath: string | undefined;
  let cleanup: (() => void) | undefined;

  // Set up download capture if enabled
  if (captureDownloads) {
    const downloadSetup = await setupDownloadCapture(page);
    downloadPath = downloadSetup.downloadPath;
    cleanup = downloadSetup.cleanup;
    console.log(
      `Puppeteer node: Download capture enabled, files will be saved to: ${downloadPath}`,
    );
  }
  const context = {
    $getNodeParameter: this.getNodeParameter,
    $getWorkflowStaticData: this.getWorkflowStaticData,
    helpers: {
      ...this.helpers,
      httpRequestWithAuthentication:
        this.helpers.httpRequestWithAuthentication.bind(this),
      requestWithAuthenticationPaginated:
        this.helpers.requestWithAuthenticationPaginated.bind(this),
    },
    ...this.getWorkflowDataProxy(itemIndex),
    $browser: browser,
    $page: page,
    $puppeteer: puppeteer,
    $input: {
      query,
    },
  };
  const vm = new NodeVM({
    console: "redirect",
    sandbox: context,
    require: vmResolver,
    wasm: false,
  });

  vm.on(
    "console.log",
    this.getMode() === "manual"
      ? this.sendMessageToUI.bind(this)
      : CODE_ENABLE_STDOUT === "true"
        ? (...args: unknown[]) =>
            console.log(
              `[Workflow "${this.getWorkflow().id}"][Node "${this.getNode().name}"]`,
              ...args,
            )
        : () => {},
  );

  try {
    const scriptResult = await vm.run(
      `module.exports = async function() { ${scriptCode}\n}()`,
    );

    if (!Array.isArray(scriptResult)) {
      if (cleanup) cleanup();
      return handleError.call(
        this,
        new Error(
          "Custom script must return an array of items. Please ensure your script returns an array, e.g., return [{ key: value }].",
        ),
        itemIndex,
        undefined,
        page,
      );
    }

    let normalizedItems = this.helpers.normalizeItems(scriptResult);

    // If download capture is enabled, attach downloaded files to the output
    if (captureDownloads && downloadPath) {
      // Wait a bit for downloads to complete
      await new Promise((resolve) => setTimeout(resolve, 1000));

      const downloadedFiles = await getDownloadedFiles(
        downloadPath,
        this.helpers,
      );

      // If there are downloaded files, attach them to the first item
      const fileCount = Object.keys(downloadedFiles || {}).length;
      if (fileCount > 0) {
        console.log(`Puppeteer node: Captured ${fileCount} downloaded file(s)`);

        if (normalizedItems.length === 0) {
          normalizedItems = [{ json: {} }];
        }

        // Attach files to the first item
        normalizedItems[0] = {
          ...normalizedItems[0],
          binary: {
            ...(normalizedItems[0].binary || {}),
            ...downloadedFiles,
          },
        };
      }

      cleanup!();
    }

    return normalizedItems;
  } catch (error) {
    if (cleanup) cleanup();
    return handleError.call(this, error as Error, itemIndex, undefined, page);
  }
}

async function processPageOperation(
  this: IExecuteFunctions,
  operation: string,
  url: URL,
  page: Page,
  itemIndex: number,
  options: IDataObject,
): Promise<INodeExecutionData[]> {
  const waitUntil = options.waitUntil as PuppeteerLifeCycleEvent;
  const timeout = options.timeout as number;

  try {
    const response = await page.goto(url.toString(), {
      waitUntil,
      timeout,
    });

    const headers = await response?.headers();
    const statusCode = response?.status();

    if (!response || (statusCode && statusCode >= 400)) {
      return handleError.call(
        this,
        new Error(`Request failed with status code ${statusCode || 0}`),
        itemIndex,
        url.toString(),
        page,
      );
    }

    if (operation === "getPageContent") {
      const body = await page.content();
      return [
        {
          json: {
            body,
            headers,
            statusCode,
            url: url.toString(),
          },
          pairedItem: {
            item: itemIndex,
          },
        },
      ];
    }

    if (operation === "getScreenshot") {
      try {
        const dataPropertyName = this.getNodeParameter(
          "dataPropertyName",
          itemIndex,
        ) as string;
        const fileName = options.fileName as string;
        const type = this.getNodeParameter(
          "imageType",
          itemIndex,
        ) as ScreenshotOptions["type"];
        const fullPage = this.getNodeParameter(
          "fullPage",
          itemIndex,
        ) as boolean;
        const screenshotOptions: ScreenshotOptions = {
          type,
          fullPage,
        };

        if (type !== "png") {
          const quality = this.getNodeParameter("quality", itemIndex) as number;
          screenshotOptions.quality = quality;
        }

        if (fileName) {
          screenshotOptions.path = fileName as
            | `${string}.jpeg`
            | `${string}.png`
            | `${string}.webp`;
        }

        const screenshot = await page.screenshot(screenshotOptions);
        if (screenshot) {
          const binaryData = await this.helpers.prepareBinaryData(
            Buffer.from(screenshot),
            screenshotOptions.path,
            `image/${type}`,
          );
          return [
            {
              binary: { [dataPropertyName]: binaryData },
              json: {
                headers,
                statusCode,
                url: url.toString(),
              },
              pairedItem: {
                item: itemIndex,
              },
            },
          ];
        }
      } catch (error) {
        return handleError.call(
          this,
          error as Error,
          itemIndex,
          url.toString(),
          page,
        );
      }
    }

    if (operation === "getPDF") {
      try {
        const dataPropertyName = this.getNodeParameter(
          "dataPropertyName",
          itemIndex,
        ) as string;
        const pageRanges = this.getNodeParameter(
          "pageRanges",
          itemIndex,
        ) as string;
        const displayHeaderFooter = this.getNodeParameter(
          "displayHeaderFooter",
          itemIndex,
        ) as boolean;
        const omitBackground = this.getNodeParameter(
          "omitBackground",
          itemIndex,
        ) as boolean;
        const printBackground = this.getNodeParameter(
          "printBackground",
          itemIndex,
        ) as boolean;
        const landscape = this.getNodeParameter(
          "landscape",
          itemIndex,
        ) as boolean;
        const preferCSSPageSize = this.getNodeParameter(
          "preferCSSPageSize",
          itemIndex,
        ) as boolean;
        const scale = this.getNodeParameter("scale", itemIndex) as number;
        const margin = this.getNodeParameter("margin", 0, {}) as IDataObject;

        let headerTemplate = "";
        let footerTemplate = "";
        let height = "";
        let width = "";
        let format: PaperFormat = "A4";

        if (displayHeaderFooter === true) {
          headerTemplate = this.getNodeParameter(
            "headerTemplate",
            itemIndex,
          ) as string;
          footerTemplate = this.getNodeParameter(
            "footerTemplate",
            itemIndex,
          ) as string;
        }

        if (preferCSSPageSize !== true) {
          height = this.getNodeParameter("height", itemIndex) as string;
          width = this.getNodeParameter("width", itemIndex) as string;

          if (!height || !width) {
            format = this.getNodeParameter("format", itemIndex) as PaperFormat;
          }
        }

        const pdfOptions: PDFOptions = {
          format,
          displayHeaderFooter,
          omitBackground,
          printBackground,
          landscape,
          headerTemplate,
          footerTemplate,
          preferCSSPageSize,
          scale,
          height,
          width,
          pageRanges,
          margin,
        };
        const fileName = options.fileName as string;
        if (fileName) {
          pdfOptions.path = fileName;
        }

        const pdf = await page.pdf(pdfOptions);
        if (pdf) {
          const binaryData = await this.helpers.prepareBinaryData(
            Buffer.from(pdf),
            pdfOptions.path,
            "application/pdf",
          );
          return [
            {
              binary: { [dataPropertyName]: binaryData },
              json: {
                headers,
                statusCode,
                url: url.toString(),
              },
              pairedItem: {
                item: itemIndex,
              },
            },
          ];
        }
      } catch (error) {
        return handleError.call(
          this,
          error as Error,
          itemIndex,
          url.toString(),
          page,
        );
      }
    }

    return handleError.call(
      this,
      new Error(`Unsupported operation: ${operation}`),
      itemIndex,
      url.toString(),
      page,
    );
  } catch (error) {
    return handleError.call(
      this,
      error as Error,
      itemIndex,
      url.toString(),
      page,
    );
  }
}

export class Puppeteer implements INodeType {
  description: INodeTypeDescription = nodeDescription;

  methods = {
    loadOptions: {
      async getDevices(
        this: ILoadOptionsFunctions,
      ): Promise<INodePropertyOptions[]> {
        const deviceNames = Object.keys(devices);
        const returnData: INodePropertyOptions[] = [];

        for (const name of deviceNames) {
          const device = devices[name as keyof typeof devices] as Device;
          returnData.push({
            name,
            value: name,
            description: `${device.viewport.width} x ${device.viewport.height} @ ${device.viewport.deviceScaleFactor}x`,
          });
        }

        return returnData;
      },
    },
  };

  async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
    const items = this.getInputData();
    const returnData: INodeExecutionData[] = [];
    const options = this.getNodeParameter("options", 0, {}) as IDataObject;
    const operation = this.getNodeParameter("operation", 0) as string;
    let headless: "shell" | boolean = options.headless !== false;
    const headlessShell = options.shell === true;
    const executablePath = options.executablePath as string;

    // Support environment variables for browser connection
    const browserWSEndpoint =
      (options.browserWSEndpoint as string) ||
      process.env.PUPPETEER_BROWSER_WS_ENDPOINT ||
      process.env.PUPPETEER_WS_ENDPOINT ||
      "";
    const protocol =
      (options.protocol as "cdp" | "webDriverBiDi" | undefined) ||
      (process.env.PUPPETEER_PROTOCOL as "cdp" | "webDriverBiDi" | undefined);
    const stealth = options.stealth === true;
    const humanTyping = options.humanTyping === true;
    const humanTypingOptions = {
      keyboardLayout: "en",
      ...((options.humanTypingOptions as IDataObject) || {}),
    };
    const launchArguments = (options.launchArguments as IDataObject) || {};
    const launchArgs: IDataObject[] = launchArguments.args as IDataObject[];
    const args: string[] = [];
    const device = options.device as string;
    const protocolTimeout = options.protocolTimeout as number;
    let batchSize = options.batchSize as number;

    if (!Number.isInteger(batchSize) || batchSize < 1) {
      batchSize = 1;
    }

    // More on launch arguments: https://www.chromium.org/developers/how-tos/run-chromium-with-flags/
    if (launchArgs && launchArgs.length > 0) {
      args.push(...launchArgs.map((arg: IDataObject) => arg.arg as string));
    }

    // Auto-detect container environment or use explicit setting
    const addContainerArgsOption = options.addContainerArgs;
    const isContainer = isRunningInContainer();
    let addContainerArgs = false;

    // Determine whether to add container arguments
    // When addContainerArgsOption is undefined (not set), default to true and auto-detect
    // When explicitly true, always add
    // When explicitly false, never add
    if (addContainerArgsOption === false) {
      // Explicitly disabled by user
      console.log(
        "Puppeteer node: Container arguments explicitly disabled by user",
      );
      addContainerArgs = false;
    } else if (
      addContainerArgsOption === true ||
      addContainerArgsOption === undefined
    ) {
      // Explicitly enabled OR not set (use default behavior)
      if (isContainer) {
        console.log(
          "Puppeteer node: Container environment detected, applying container arguments",
        );
        addContainerArgs = true;
      } else if (addContainerArgsOption === true) {
        // Force enabled even outside container
        console.log(
          "Puppeteer node: Container arguments enabled (no container detected)",
        );
        addContainerArgs = true;
      }
    }

    if (addContainerArgs) {
      const missingContainerArgs = CONTAINER_LAUNCH_ARGS.filter(
        (arg) =>
          !args.some(
            (existingArg) =>
              existingArg === arg || existingArg.startsWith(`${arg}=`),
          ),
      );

      if (missingContainerArgs.length > 0) {
        console.log(
          "Puppeteer node: Adding container arguments:",
          missingContainerArgs,
        );
        args.push(...missingContainerArgs);
      } else {
        console.log(
          "Puppeteer node: Container arguments already present in launch arguments",
        );
      }
    }

    // More on proxying: https://www.chromium.org/developers/design-documents/network-settings
    if (options.proxyServer) {
      args.push(`--proxy-server=${options.proxyServer}`);
    }

    if (stealth) {
      puppeteer.use(pluginStealth());
    }
    if (humanTyping) {
      puppeteer.use(pluginHumanTyping(humanTypingOptions));
    }

    if (headless && headlessShell) {
      headless = "shell";
    }

    let browser: Browser;
    try {
      if (browserWSEndpoint) {
        const isFromEnv =
          !options.browserWSEndpoint &&
          (process.env.PUPPETEER_BROWSER_WS_ENDPOINT ||
            process.env.PUPPETEER_WS_ENDPOINT);
        if (isFromEnv) {
          console.log(
            `Puppeteer node: Using browser WebSocket endpoint from environment variable: ${browserWSEndpoint}`,
          );
        }
        if (protocol && protocol !== "cdp") {
          console.log(`Puppeteer node: Using protocol: ${protocol}`);
        }
        browser = await puppeteer.connect({
          browserWSEndpoint,
          protocol: protocol || "cdp",
          protocolTimeout,
        });
      } else {
        browser = await puppeteer.launch({
          headless,
          args,
          executablePath,
          protocolTimeout,
        });
      }
    } catch (error) {
      throw new Error(
        `Failed to launch/connect to browser: ${(error as Error).message}`,
      );
    }

    const processItem = async (
      item: INodeExecutionData,
      itemIndex: number,
    ): Promise<INodeExecutionData[]> => {
      let page: Page | undefined;
      try {
        page = await browser.newPage();
        await handleOptions.call(this, itemIndex, items, browser, page);

        if (operation === "runCustomScript") {
          console.log(
            `Processing ${itemIndex + 1} of ${items.length}: [${operation}]${device ? ` [${device}] ` : " "} Custom Script`,
          );
          return await runCustomScript.call(
            this,
            itemIndex,
            items,
            browser,
            page,
          );
        }
        const urlString = this.getNodeParameter("url", itemIndex) as string;
        const queryParametersOptions = this.getNodeParameter(
          "queryParameters",
          itemIndex,
          {},
        ) as IDataObject;

        const queryParameters =
          (queryParametersOptions.parameters as QueryParameter[]) || [];
        let url: URL;

        try {
          url = new URL(urlString);
          for (const queryParameter of queryParameters) {
            url.searchParams.append(queryParameter.name, queryParameter.value);
          }
        } catch (error) {
          return handleError.call(
            this,
            new Error(`Invalid URL: ${urlString}`),
            itemIndex,
            urlString,
            page,
          );
        }

        console.log(
          `Processing ${itemIndex + 1} of ${items.length}: [${operation}]${device ? ` [${device}] ` : " "}${url}`,
        );

        return await processPageOperation.call(
          this,
          operation,
          url,
          page,
          itemIndex,
          options,
        );
      } catch (error) {
        return handleError.call(
          this,
          error as Error,
          itemIndex,
          undefined,
          page,
        );
      } finally {
        if (page) {
          try {
            await page.close();
          } catch (error) {
            console.error("Error closing page:", error);
          }
        }
      }
    };

    try {
      for (let i = 0; i < items.length; i += batchSize) {
        const batch = items.slice(i, i + batchSize);
        const results = await Promise.all(
          batch.map((item, idx) => processItem(item, i + idx)),
        );
        if (results?.length) {
          returnData.push(...results.flat());
        }
      }
    } finally {
      if (browser) {
        try {
          if (browserWSEndpoint) {
            await browser.disconnect();
          } else {
            await browser.close();
          }
        } catch (error) {
          console.error("Error closing browser:", error);
        }
      }
    }

    return this.prepareOutputData(returnData);
  }
}
