import {
  APIRequestContext,
  Browser,
  BrowserContext,
  expect,
  Locator,
  Page,
  Request,
} from "@playwright/test";

const mailpitURL = process.env.MAILPIT_URL;
const baseURL = process.env.BASE_URL;

if (!mailpitURL || !baseURL) {
  throw new Error("MAILPIT_URL and BASE_URL are required");
}

export type AuthenticatedBrowser = {
  context: BrowserContext;
  page: Page;
  email: string;
};

export function projectEmail(localPart: string, projectName: string): string {
  const runID = process.env.E2E_RUN_ID;

  if (runID) {
    return `wnh-staging-e2e-${runID}-${localPart}@example.invalid`;
  }

  const scope = projectName.toLowerCase().replace(/[^a-z0-9-]/g, "-");
  return `${localPart}+${scope}@example.invalid`;
}

export function projectText(value: string, projectName: string): string {
  return `${value} [${projectName}]`;
}

export async function newIsolatedContext(browser: Browser): Promise<BrowserContext> {
  return browser.newContext({ serviceWorkers: "block" });
}

export async function gotoWithTransientRetry(
  page: Page,
  url: string,
  attempts = 3,
): Promise<void> {
  let lastError: unknown;

  for (let attempt = 1; attempt <= attempts; attempt += 1) {
    try {
      await page.goto(url);
      return;
    } catch (error) {
      lastError = error;
      const message = error instanceof Error ? error.message : String(error);
      const transient = isTransientNetworkError(message);

      if (!transient || attempt === attempts) {
        throw error;
      }

      await page.waitForTimeout(500 * attempt);
    }
  }

  throw lastError;
}

export function isTransientNetworkError(message: string): boolean {
  return [
    "ERR_NETWORK_CHANGED",
    "ERR_CONNECTION_RESET",
    "ERR_CONNECTION_CLOSED",
    "NS_ERROR_NET_RESET",
  ].some((value) => message.includes(value));
}

export async function clickUntilVisible(
  trigger: Locator,
  expected: Locator,
  attempts = 3,
): Promise<void> {
  let lastError: unknown;

  for (let attempt = 1; attempt <= attempts; attempt += 1) {
    if (await expected.isVisible()) {
      return;
    }

    await trigger.click();

    try {
      await expect(expected).toBeVisible({ timeout: 5_000 });
      return;
    } catch (error) {
      lastError = error;

      if (attempt === attempts || !(await trigger.isVisible())) {
        throw error;
      }

      await trigger.page().waitForTimeout(500 * attempt);
    }
  }

  throw lastError;
}

export async function waitForLiveViewConnected(page: Page): Promise<void> {
  await expect(page.locator("[data-phx-main].phx-connected")).toHaveCount(1, {
    timeout: 15_000,
  });
}

export async function gotoLiveView(page: Page, url: string): Promise<void> {
  await gotoWithTransientRetry(page, url);
  await waitForLiveViewConnected(page);
}

export async function waitForMapReady(page: Page): Promise<void> {
  const map = page.locator(".aid-map");

  if ((await map.count()) > 0) {
    await expect
      .poll(
        async () => {
          if ((await map.getAttribute("data-map-ready")) === "true") {
            return "map";
          }

          if ((await map.locator("[data-map-fallback=true]").count()) === 1) {
            return "fallback";
          }

          return null;
        },
        { timeout: 15_000 },
      )
      .not.toBeNull();
  }
}

export async function markerKinds(page: Page): Promise<string[]> {
  const encoded = await page.locator("#show-map").getAttribute("data-markers");
  const markers = JSON.parse(encoded ?? "[]") as Array<{ title?: string }>;
  return markers.map(({ title }) => title ?? "");
}

export function captureBrowserFailures(
  page: Page,
  options: { recoverableOneTimeNavigations?: Set<string> } = {},
): () => void {
  const failures: string[] = [];
  const pendingTransientNavigations = new Map<string, string>();

  page.on("framenavigated", (frame) => {
    if (frame === page.mainFrame()) {
      pendingTransientNavigations.delete(frame.url());
    }
  });

  page.on("console", (message) => {
    if (message.type() === "error") {
      failures.push(`console: ${message.text()}`);
    }
  });

  page.on("pageerror", (error) => {
    failures.push(`pageerror: ${error.message}`);
  });

  page.on("requestfailed", (request) => {
    const failure = request.failure()?.errorText ?? "";
    const cancelled =
      failure.includes("Load request cancelled") ||
      failure.includes("ERR_ABORTED") ||
      failure.includes("NS_BINDING_ABORTED");
    const rasterTile =
      request.url().includes("/__e2e__/map-tile.png") ||
      /\/\d+\/\d+\/\d+\.png(?:\?|$)/.test(request.url());
    const replacedDocumentAsset =
      cancelled &&
      new URL(request.url()).origin === new URL(baseURL).origin &&
      ["script", "stylesheet"].includes(request.resourceType());
    const recoverableOneTimeNavigation =
      options.recoverableOneTimeNavigations?.has(request.url()) === true &&
      isTransientNetworkError(failure);
    const pendingTransientNavigation =
      request.isNavigationRequest() &&
      new URL(request.url()).origin === new URL(baseURL).origin &&
      isTransientNetworkError(failure);

    // MapLibre cancels in-flight raster tiles when a LiveView navigation
    // destroys or re-centres a map. Chromium also aborts the old document's
    // static assets when a bounded navigation retry replaces that document.
    // A same-origin document navigation may fail once while the public tunnel
    // changes networks. Keep that failure pending until the same URL actually
    // commits; an unrecovered navigation remains a test failure.
    if (pendingTransientNavigation) {
      pendingTransientNavigations.set(
        request.url(),
        `requestfailed: ${request.method()} ${request.url()} ${failure}`,
      );
      return;
    }

    if (
      (rasterTile && cancelled) ||
      replacedDocumentAsset ||
      recoverableOneTimeNavigation
    ) {
      return;
    }

    failures.push(
      `requestfailed: ${request.method()} ${request.url()} ${failure}`,
    );
  });

  return () => {
    const unresolved = [...failures, ...pendingTransientNavigations.values()];
    expect(unresolved, unresolved.join("\n")).toEqual([]);
  };
}

export async function latestMessageID(
  request: APIRequestContext,
  email: string,
): Promise<string | undefined> {
  const response = await request.get(`${mailpitURL}/api/v1/search`, {
    params: {
      query: `to:"${email}"`,
      limit: "10",
    },
  });

  expect(response.ok()).toBeTruthy();
  const body = (await response.json()) as { messages?: Array<{ ID?: string }> };
  return body.messages?.[0]?.ID;
}

export async function waitForApplicationEmailLink(
  request: APIRequestContext,
  email: string,
  pathPrefix: string,
  previousMessageID?: string,
): Promise<string> {
  let messageID: string | undefined;

  await expect
    .poll(
      async () => {
        messageID = await latestMessageID(request, email);
        return messageID && messageID !== previousMessageID ? messageID : undefined;
      },
      {
        message: `waiting for the Mailpit login message for ${email}`,
        timeout: 15_000,
      },
    )
    .toBeTruthy();

  const response = await request.get(`${mailpitURL}/api/v1/message/${messageID}`);
  expect(response.ok()).toBeTruthy();

  const message = (await response.json()) as {
    Text?: string;
    HTML?: string;
  };
  const content = `${message.Text ?? ""}\n${message.HTML ?? ""}`;
  const candidates = content.match(/https?:\/\/[^\s"'<>]+/g) ?? [];
  const match = candidates
    .map((candidate) => candidate.replaceAll("&amp;", "&"))
    .find((candidate) => {
      try {
        return new URL(candidate).pathname.startsWith(pathPrefix);
      } catch {
        return false;
      }
    });

  if (!match) {
    throw new Error(`No application link starting with ${pathPrefix} found for ${email}`);
  }

  const link = new URL(match);
  const applicationOrigin = new URL(baseURL);
  link.protocol = applicationOrigin.protocol;
  link.host = applicationOrigin.host;

  return link.toString();
}

async function waitForMagicLink(
  request: APIRequestContext,
  email: string,
  previousMessageID?: string,
): Promise<string> {
  return waitForApplicationEmailLink(
    request,
    email,
    "/users/log-in",
    previousMessageID,
  );
}

export async function registerAndConfirm(
  browser: Browser,
  request: APIRequestContext,
  email: string,
  displayName: string,
): Promise<AuthenticatedBrowser> {
  const context = await newIsolatedContext(browser);
  const page = await context.newPage();

  await gotoWithTransientRetry(page, "/users/register");
  const form = page.locator("#email_registration_form");
  await form.getByLabel("Email").fill(email);
  await form.getByLabel("Display name").fill(displayName);
  await form
    .getByLabel("I am 18 or older and accept the safety rules")
    .check();
  await form
    .getByRole("button", { name: "Create account and email me a link" })
    .click();
  await expect(page).toHaveURL(/\/users\/log-in$/);
  await expect(
    page
      .getByRole("alert")
      .getByText(
        "If this address can be registered or signed in, login instructions will arrive shortly.",
      ),
  ).toBeVisible();

  await gotoWithTransientRetry(page, await waitForMagicLink(request, email));
  await expect(page.locator("#magic-link-fragment-form")).toBeVisible();
  await page
    .getByRole("button", { name: "Confirm and log in only this time" })
    .click();
  await expect(page.getByText(email, { exact: true })).toBeVisible();

  return { context, page, email };
}

export async function loginWithMagicLink(
  browser: Browser,
  request: APIRequestContext,
  email: string,
): Promise<AuthenticatedBrowser> {
  const context = await newIsolatedContext(browser);
  const page = await context.newPage();
  const previousMessageID = await latestMessageID(request, email);

  await gotoWithTransientRetry(page, "/users/log-in");
  const form = page.locator("#login_form_magic");
  await form.getByLabel("Email").fill(email);
  await form.getByRole("button", { name: "Email me a sign-in link" }).click();
  await expect(
    page.getByText(
      "If your email is in our system, you will receive instructions for logging in shortly.",
    ),
  ).toBeVisible();

  await gotoWithTransientRetry(
    page,
    await waitForMagicLink(request, email, previousMessageID),
  );
  await expect(page.locator("#magic-link-fragment-form")).toBeVisible();
  await page
    .getByRole("button", { name: "Confirm and log in only this time" })
    .click();
  await expect(page.getByText(email, { exact: true })).toBeVisible();

  return { context, page, email };
}

export async function loginWithPassword(
  browser: Browser,
  email: string,
  password: string,
): Promise<AuthenticatedBrowser> {
  const context = await newIsolatedContext(browser);
  const page = await context.newPage();

  for (let attempt = 1; attempt <= 3; attempt += 1) {
    let navigationFailure = "";
    const recordNavigationFailure = (request: Request): void => {
      if (
        request.isNavigationRequest() &&
        new URL(request.url()).origin === new URL(baseURL).origin &&
        isTransientNetworkError(request.failure()?.errorText ?? "")
      ) {
        navigationFailure = request.failure()?.errorText ?? "";
      }
    };

    page.on("requestfailed", recordNavigationFailure);

    try {
      await gotoWithTransientRetry(page, "/users/log-in");
      const form = page.locator("#login_form_password");
      await form.getByLabel("Email").fill(email);
      await form.getByLabel("Password").fill(password);
      await expect(form.getByLabel("Password")).toHaveValue(password);
      await form.getByRole("button", { name: "Log in only this time" }).click();
      await expect(page).not.toHaveURL(/\/users\/log-in$/, { timeout: 10_000 });
      await expect(page.getByText(email, { exact: true })).toBeVisible({
        timeout: 10_000,
      });
      return { context, page, email };
    } catch (error) {
      if (await page.getByText("Invalid email or password").isVisible()) {
        throw error;
      }

      if (
        attempt === 3 ||
        (!isTransientNetworkError(navigationFailure) &&
          !isTransientNetworkError(
            error instanceof Error ? error.message : String(error),
          ))
      ) {
        throw error;
      }

      await page.waitForTimeout(500 * attempt);
    } finally {
      page.off("requestfailed", recordNavigationFailure);
    }
  }

  throw new Error("Password login exhausted its bounded retry loop");
}

export async function selectOptionContaining(
  page: Page,
  label: string,
  expectedText: string,
): Promise<void> {
  await waitForLiveViewConnected(page);
  const select = page.getByLabel(label);
  const options = await select.locator("option").evaluateAll((nodes) =>
    nodes.map((node) => ({
      text: node.textContent?.trim() ?? "",
      value: (node as HTMLOptionElement).value,
    })),
  );
  const option = options.find(({ text }) => text.includes(expectedText));

  if (!option) {
    throw new Error(`No ${label} option contains ${expectedText}`);
  }

  await select.selectOption(option.value);
}
