const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { Builder, By, Key, until } = require("selenium-webdriver");
const firefox = require("selenium-webdriver/firefox");

const baseUrl = process.env.SELENIUM_BASE_URL || "http://127.0.0.1:5000";
const defaultPassword = "Password123";
let uniqueSuffix = 0;

function absoluteUrl(relativePath) {
  return new URL(relativePath, baseUrl).toString();
}

function normalizeText(text) {
  return String(text || "").replace(/\s+/g, " ").trim();
}

function uniqueName(prefix) {
  uniqueSuffix += 1;
  return `${prefix}-${Date.now()}-${uniqueSuffix}`;
}

function formatCookie(sessionCookie) {
  return `${sessionCookie.name}=${sessionCookie.value}`;
}

function parseSessionCookie(setCookieHeader) {
  assert.ok(setCookieHeader, "Missing Set-Cookie header for session login.");
  const match = setCookieHeader.match(/(?:^|,\s*)rpgroller_session=([^;]+)/);
  assert.ok(match, `Could not find rpgroller_session in Set-Cookie header: ${setCookieHeader}`);
  return { name: "rpgroller_session", value: match[1] };
}

async function request(relativePath, options = {}) {
  const headers = new Headers(options.headers || {});
  if (options.cookie) {
    headers.set("cookie", typeof options.cookie === "string" ? options.cookie : formatCookie(options.cookie));
  }

  let body;
  if (options.json !== undefined) {
    headers.set("content-type", "application/json");
    headers.set("accept", "application/json");
    body = JSON.stringify(options.json);
  }

  return fetch(absoluteUrl(relativePath), {
    method: options.method || "GET",
    headers,
    body,
    redirect: options.redirect || "follow"
  });
}

async function postJson(relativePath, payload, options = {}) {
  const response = await request(relativePath, {
    method: "POST",
    json: payload,
    cookie: options.cookie,
    redirect: options.redirect
  });

  assert.equal(response.status, 200, `POST ${relativePath} failed with ${response.status}.`);
  return response.json();
}

async function deleteJson(relativePath, options = {}) {
  const response = await request(relativePath, {
    method: "DELETE",
    cookie: options.cookie
  });

  assert.equal(response.status, 200, `DELETE ${relativePath} failed with ${response.status}.`);
  return response.json();
}

async function registerUser(username, displayName, password = defaultPassword) {
  return postJson("/api/auth/register", { username, password, displayName });
}

async function loginUser(username, password = defaultPassword) {
  const response = await request("/api/auth/login", {
    method: "POST",
    json: { username, password },
    redirect: "manual"
  });

  assert.equal(response.status, 200, `Login for ${username} failed with ${response.status}.`);
  const sessionCookie = parseSessionCookie(response.headers.get("set-cookie"));
  const user = await response.json();
  return { sessionCookie, user };
}

async function registerAndLoginApi(username, displayName, password = defaultPassword) {
  await registerUser(username, displayName, password);
  return loginUser(username, password);
}

function resolveFirefoxBinary() {
  const candidates = [
    process.env.FIREFOX_BINARY,
    "/snap/firefox/current/usr/lib/firefox/firefox",
    "/usr/bin/firefox"
  ];

  return candidates.find((candidate) => candidate && fs.existsSync(candidate)) || null;
}

async function createDriver(options = {}) {
  const firefoxOptions = new firefox.Options().addArguments("-headless");
  const binary = resolveFirefoxBinary();
  if (binary) {
    firefoxOptions.setBinary(binary);
  }

  const driver = await new Builder()
    .forBrowser("firefox")
    .setFirefoxOptions(firefoxOptions)
    .build();

  await driver.manage().setTimeouts({
    implicit: 0,
    pageLoad: 30000,
    script: 30000
  });

  if (options.addonPath) {
    await driver.installAddon(path.resolve(options.addonPath), true);
  }

  return driver;
}

async function withDriver(options, callback) {
  const driver = await createDriver(options);
  try {
    return await callback(driver);
  } finally {
    await driver.quit();
  }
}

async function seedAuthenticatedBrowser(driver, sessionCookie) {
  await driver.get(absoluteUrl("/login"));
  await driver.manage().addCookie({
    name: sessionCookie.name,
    value: sessionCookie.value,
    path: "/"
  });
}

async function waitFor(driver, predicate, message, timeout = 15000) {
  await driver.wait(async () => Boolean(await predicate()), timeout, message);
}

async function waitForUrl(driver, relativePath, timeout = 15000) {
  const expectedUrl = absoluteUrl(relativePath);
  await driver.wait(until.urlIs(expectedUrl), timeout, `Expected URL ${expectedUrl}.`);
}

async function waitForSelector(driver, selector, timeout = 15000) {
  const element = await driver.wait(until.elementLocated(By.css(selector)), timeout, `Expected selector ${selector}.`);
  await driver.wait(until.elementIsVisible(element), timeout, `Expected visible selector ${selector}.`);
  return element;
}

async function waitForText(driver, text, timeout = 15000) {
  await waitFor(
    driver,
    () => driver.executeScript((expected) => document.body.innerText.includes(expected), text),
    `Expected page text "${text}".`,
    timeout
  );
}

async function waitForAbsent(driver, selector, timeout = 15000) {
  await waitFor(
    driver,
    () => driver.executeScript((css) => !document.querySelector(css), selector),
    `Expected selector ${selector} to be absent.`,
    timeout
  );
}

async function selectorCount(driver, selector) {
  return driver.executeScript((css) => document.querySelectorAll(css).length, selector);
}

async function hasSelector(driver, selector) {
  return driver.executeScript((css) => Boolean(document.querySelector(css)), selector);
}

async function elementText(driver, selector) {
  const text = await driver.executeScript((css) => document.querySelector(css)?.textContent || "", selector);
  return normalizeText(text);
}

async function allTexts(driver, selector) {
  const texts = await driver.executeScript(
    (css) => [...document.querySelectorAll(css)].map((element) => element.textContent || ""),
    selector
  );

  return texts.map(normalizeText);
}

async function getValue(driver, selector) {
  return driver.executeScript((css) => document.querySelector(css)?.value ?? null, selector);
}

async function getClassName(driver, selector) {
  return driver.executeScript((css) => document.querySelector(css)?.className ?? "", selector);
}

async function getAttribute(driver, selector, attributeName) {
  return driver.executeScript(
    (css, attribute) => document.querySelector(css)?.getAttribute(attribute) ?? null,
    selector,
    attributeName
  );
}

async function isChecked(driver, selector) {
  return driver.executeScript((css) => Boolean(document.querySelector(css)?.checked), selector);
}

async function clickSelector(driver, selector) {
  const element = await waitForSelector(driver, selector);
  await element.click();
}

async function clickText(driver, selector, text, options = {}) {
  const matched = await driver.executeScript(
    (css, expectedText, contains, last) => {
      const candidates = [...document.querySelectorAll(css)];
      const normalized = expectedText.replace(/\s+/g, " ").trim();
      const match = candidates.filter((candidate) => {
        const candidateText = (candidate.textContent || "").replace(/\s+/g, " ").trim();
        return contains ? candidateText.includes(normalized) : candidateText === normalized;
      });

      const element = last ? match.at(-1) : match[0];
      if (!element) {
        return false;
      }

      element.click();
      return true;
    },
    selector,
    text,
    Boolean(options.contains),
    Boolean(options.last)
  );

  assert.ok(matched, `Could not find ${selector} with text "${text}".`);
}

async function fillInput(driver, selector, value) {
  const updated = await driver.executeScript(
    (css, nextValue) => {
      const input = document.querySelector(css);
      if (!input) {
        return false;
      }

      input.focus();
      input.value = nextValue;
      input.dispatchEvent(new Event("input", { bubbles: true }));
      input.dispatchEvent(new Event("change", { bubbles: true }));
      return true;
    },
    selector,
    value
  );

  assert.ok(updated, `Could not find input ${selector}.`);
}

async function clickLabel(driver, labelText) {
  const clicked = await driver.executeScript((text) => {
    const label = [...document.querySelectorAll("label")].find(
      (element) => (element.textContent || "").replace(/\s+/g, " ").trim() === text
    );
    if (!label) {
      return false;
    }

    const targetId = label.getAttribute("for");
    const target = targetId ? document.getElementById(targetId) : label.querySelector("input,select,textarea");
    if (!target) {
      return false;
    }

    target.click();
    target.dispatchEvent(new Event("change", { bubbles: true }));
    return true;
  }, labelText);

  assert.ok(clicked, `Could not find label "${labelText}".`);
}

async function clickByTitle(driver, title) {
  const clicked = await driver.executeScript((expectedTitle) => {
    const button = [...document.querySelectorAll("[title]")].find((element) => element.getAttribute("title") === expectedTitle);
    if (!button) {
      return false;
    }

    button.click();
    return true;
  }, title);

  assert.ok(clicked, `Could not find element with title "${title}".`);
}

async function getDomWrapErrors(driver) {
  return driver.executeScript(() => window.__rrDomWrapTestErrors || []);
}

async function runSmokeTests(tests) {
  for (let index = 0; index < tests.length; index += 1) {
    const testCase = tests[index];
    console.log(`[${index + 1}/${tests.length}] ${testCase.name}`);
    await testCase.run();
    console.log(`PASS ${testCase.name}`);
  }
}

module.exports = {
  Key,
  absoluteUrl,
  allTexts,
  baseUrl,
  clickByTitle,
  clickLabel,
  clickSelector,
  clickText,
  defaultPassword,
  deleteJson,
  elementText,
  fillInput,
  formatCookie,
  getAttribute,
  getClassName,
  getDomWrapErrors,
  getValue,
  hasSelector,
  isChecked,
  postJson,
  registerAndLoginApi,
  request,
  runSmokeTests,
  seedAuthenticatedBrowser,
  selectorCount,
  uniqueName,
  waitFor,
  waitForAbsent,
  waitForSelector,
  waitForText,
  waitForUrl,
  withDriver
};
