import { mkdir, writeFile } from "node:fs/promises";
import { createHash, randomBytes } from "node:crypto";
import playwright from "../devfront/node_modules/playwright/index.js";

const { chromium } = playwright;

const gatewayUrl = process.env.RP_CLAIMS_E2E_GATEWAY_URL ?? "http://localhost:5000";
const devfrontUrl = process.env.RP_CLAIMS_E2E_DEVFRONT_URL ?? "http://localhost:5174";
const reportDir = process.env.RP_CLAIMS_E2E_REPORT_DIR ?? "reports/rp-claims-live-e2e";
const tenantSlug = process.env.RP_CLAIMS_E2E_TENANT_SLUG ?? "public-org";
const runId = process.env.RP_CLAIMS_E2E_RUN_ID ?? `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
const email = `rp-claims-${runId}@example.test`;
const loginId = `rp${runId.replaceAll("-", "").slice(0, 18)}`;
const loginIdentifier = email;
const password = `RpClaims${runId.slice(0, 6)}!1aA`;
const redirectUri = "http://127.0.0.1:18080/callback";
const subjectByLoginId = new Map();

const scenarios = [
  {
    name: "rp-a",
    clientId: `rp-claims-a-${runId}`,
    defaults: {
      approvalLevel: "A",
      activeMember: true,
      score: 1,
      featureList: ["default"],
      preferences: { theme: "light", density: "comfortable" },
      contractDate: "2026-06-09",
      approvedAt: "2026-06-09T09:30",
      adminManagedNote: "admin-default",
    },
    updates: [
      {
        label: "admin-update",
        metadata: {
          approvalLevel: "B",
          activeMember: false,
          score: 42,
          featureList: ["sso", "claims"],
          preferences: { theme: "dark", density: "compact" },
          contractDate: "2026-06-10",
          approvedAt: "2026-06-09T10:30",
          adminManagedNote: "admin-updated",
          approvalLevel_permissions: {
            readPermission: "admin_only",
            writePermission: "user_and_admin",
          },
        },
        expected: {
          approvalLevel: "B",
          activeMember: false,
          score: 42,
          featureList: ["sso", "claims"],
          preferences: { theme: "dark", density: "compact" },
          contractDate: "2026-06-10",
          approvedAt: "2026-06-09T10:30",
          adminManagedNote: "admin-updated",
        },
      },
      {
        label: "second-update",
        metadata: {
          approvalLevel: "C",
          activeMember: true,
          score: 7.5,
          featureList: ["delta", "omega", "42"],
          preferences: { theme: "contrast", density: "spacious", nested: { level: 2 } },
          contractDate: "2026-06-11",
          approvedAt: "2026-06-11T14:45:30Z",
          adminManagedNote: "admin-updated-again",
          approvalLevel_permissions: {
            readPermission: "user_and_admin",
            writePermission: "user_and_admin",
          },
        },
        expected: {
          approvalLevel: "C",
          activeMember: true,
          score: 7.5,
          featureList: ["delta", "omega", "42"],
          preferences: { theme: "contrast", density: "spacious", nested: { level: 2 } },
          contractDate: "2026-06-11",
          approvedAt: "2026-06-11T14:45:30Z",
          adminManagedNote: "admin-updated-again",
        },
      },
    ],
  },
  {
    name: "rp-b",
    clientId: `rp-claims-b-${runId}`,
    defaults: {
      approvalLevel: "B-default",
      activeMember: false,
      score: 100,
      featureList: ["rp-b-default"],
      preferences: { theme: "blue", density: "wide" },
      contractDate: "2026-07-01",
      approvedAt: "2026-07-01T08:00",
    },
    updates: [
      {
        label: "rp-b-isolated-update",
        metadata: {
          approvalLevel: "B-only",
          activeMember: true,
          score: 314,
          featureList: ["only", "rp-b"],
          preferences: { theme: "green", density: "narrow" },
          contractDate: "2026-07-02",
          approvedAt: "2026-07-02T09:15",
        },
        expected: {
          approvalLevel: "B-only",
          activeMember: true,
          score: 314,
          featureList: ["only", "rp-b"],
          preferences: { theme: "green", density: "narrow" },
          contractDate: "2026-07-02",
          approvedAt: "2026-07-02T09:15",
        },
      },
    ],
  },
];

const report = {
  runId,
  gatewayUrl,
  devfrontUrl,
  loginId,
  loginIdentifier,
  email,
  createdAt: new Date().toISOString(),
  steps: [],
  scenarios: [],
  passed: false,
};

function assertDeepEqual(actual, expected, label) {
  const actualJson = JSON.stringify(canonicalize(actual));
  const expectedJson = JSON.stringify(canonicalize(expected));
  if (actualJson !== expectedJson) {
    throw new Error(`${label}: expected ${expectedJson}, got ${actualJson}`);
  }
}

function canonicalize(value) {
  if (Array.isArray(value)) {
    return value.map(canonicalize);
  }
  if (value && typeof value === "object") {
    return Object.fromEntries(
      Object.entries(value)
        .sort(([left], [right]) => left.localeCompare(right))
        .map(([key, item]) => [key, canonicalize(item)]),
    );
  }
  return value;
}

function assertAbsent(record, key, label) {
  if (Object.hasOwn(record ?? {}, key)) {
    throw new Error(`${label}: ${key} must not be present`);
  }
}

function base64urlDecode(input) {
  const padded = input.padEnd(input.length + ((4 - (input.length % 4)) % 4), "=");
  return Buffer.from(padded.replaceAll("-", "+").replaceAll("_", "/"), "base64").toString("utf8");
}

function decodeJwtPayload(token) {
  const parts = String(token).split(".");
  if (parts.length < 2) {
    throw new Error("id_token is not a JWT");
  }
  return JSON.parse(base64urlDecode(parts[1]));
}

function base64url(buffer) {
  return Buffer.from(buffer)
    .toString("base64")
    .replaceAll("+", "-")
    .replaceAll("/", "_")
    .replaceAll("=", "");
}

function pkceChallenge(verifier) {
  return base64url(createHash("sha256").update(verifier).digest());
}

function claimDefinitions(defaults) {
  const claims = [
    { key: "approvalLevel", valueType: "text", value: defaults.approvalLevel },
    { key: "activeMember", valueType: "boolean", value: String(defaults.activeMember) },
    { key: "score", valueType: "number", value: String(defaults.score) },
    { key: "featureList", valueType: "array", value: JSON.stringify(defaults.featureList) },
    { key: "preferences", valueType: "object", value: JSON.stringify(defaults.preferences) },
    { key: "contractDate", valueType: "date", value: defaults.contractDate },
    { key: "approvedAt", valueType: "datetime", value: defaults.approvedAt },
  ].filter((claim) => claim.value !== undefined);
  if (defaults.adminManagedNote !== undefined) {
    claims.push({
      key: "adminManagedNote",
      valueType: "text",
      value: defaults.adminManagedNote,
      writePermission: "admin_only",
    });
  }
  return claims.map((claim) => ({
    namespace: "rp_claims",
    readPermission: "user_and_admin",
    writePermission: claim.writePermission ?? "user_and_admin",
    ...claim,
  }));
}

async function requestJson(url, options = {}) {
  const { cookieJar, ...fetchOptions } = options;
  const cookieHeader = cookieJar ? cookieHeaderFromJar(cookieJar) : "";
  const response = await fetch(url, {
    ...fetchOptions,
    headers: {
      "Content-Type": "application/json",
      "X-Test-Role": "super_admin",
      ...(cookieHeader ? { Cookie: cookieHeader } : {}),
      ...(options.headers ?? {}),
    },
  });
  rememberCookies(cookieJar, response);
  const text = await response.text();
  let body = null;
  if (text) {
    try {
      body = JSON.parse(text);
    } catch {
      body = text;
    }
  }
  if (!response.ok) {
    throw new Error(`${options.method ?? "GET"} ${url} failed: ${response.status} ${text}`);
  }
  return { response, body };
}

async function createUser() {
  const body = {
    email,
    loginId,
    password,
    name: `RP Claims ${runId}`,
    role: "user",
    tenantSlug,
  };
  const result = await requestJson(`${gatewayUrl}/api/v1/admin/users`, {
    method: "POST",
    body: JSON.stringify(body),
  });
  const id = result.body?.id ?? result.body?.user?.id ?? result.body?.identityId;
  if (!id) {
    throw new Error(`created user response has no id: ${JSON.stringify(result.body)}`);
  }
  subjectByLoginId.set(loginIdentifier, id);
  report.steps.push({ step: "create-user", status: "ok", subject: id });
  return id;
}

async function createClient(scenario) {
  const body = {
    id: scenario.clientId,
    name: `RP Claims E2E ${scenario.name} ${runId}`,
    type: "pkce",
    status: "active",
    redirectUris: [redirectUri],
    scopes: ["openid", "profile", "email"],
    grantTypes: ["authorization_code", "refresh_token"],
    responseTypes: ["code"],
    tokenEndpointAuthMethod: "none",
    skipConsent: false,
    metadata: {
      id_token_claims: claimDefinitions(scenario.defaults),
      tenant_id: "tenant-rp-claims-live-e2e",
    },
  };
  await requestJson(`${devfrontUrl}/api/v1/dev/clients`, {
    method: "POST",
    body: JSON.stringify(body),
  });
  report.steps.push({ step: "create-client", status: "ok", clientId: scenario.clientId });
}

function cookieHeaderFromJar(cookieJar) {
  return Object.entries(cookieJar ?? {})
    .map(([name, value]) => `${name}=${value}`)
    .join("; ");
}

function rememberCookies(cookieJar, response) {
  if (!cookieJar || !response?.headers) {
    return;
  }
  const setCookies =
    typeof response.headers.getSetCookie === "function"
      ? response.headers.getSetCookie()
      : response.headers.get("set-cookie")
        ? [response.headers.get("set-cookie")]
        : [];
  for (const header of setCookies) {
    const firstPart = String(header ?? "").split(";")[0];
    const separator = firstPart.indexOf("=");
    if (separator <= 0) {
      continue;
    }
    cookieJar[firstPart.slice(0, separator)] = firstPart.slice(separator + 1);
  }
}

async function manualFetch(url, options = {}) {
  try {
    const { cookieJar, ...fetchOptions } = options;
    const cookieHeader = cookieJar ? cookieHeaderFromJar(cookieJar) : "";
    const response = await fetch(url, {
      redirect: "manual",
      ...fetchOptions,
      headers: {
        ...(cookieHeader ? { Cookie: cookieHeader } : {}),
        ...(options.headers ?? {}),
      },
    });
    rememberCookies(cookieJar, response);
    return response;
  } catch (error) {
    throw new Error(`manual fetch failed for ${url}: ${error instanceof Error ? error.message : String(error)}`);
  }
}

function locationFrom(response, label) {
  const location = response.headers.get("location");
  if (!location) {
    throw new Error(`${label}: redirect location missing, status=${response.status}`);
  }
  return new URL(location, gatewayUrl).toString();
}

function searchParam(url, name, label) {
  const value = new URL(url).searchParams.get(name);
  if (!value) {
    throw new Error(`${label}: ${name} missing from ${url}`);
  }
  return value;
}

async function authorizeAndDecodeClaims(clientId, label) {
  const state = `${label}-${Math.random().toString(16).slice(2, 8)}`;
  const nonce = `${label}-${Math.random().toString(16).slice(2, 8)}`;
  const verifier = base64url(randomBytes(32));
  const cookieJar = {};
  const authorize = new URL(`${gatewayUrl}/oidc/oauth2/auth`);
  authorize.searchParams.set("client_id", clientId);
  authorize.searchParams.set("redirect_uri", redirectUri);
  authorize.searchParams.set("response_type", "code");
  authorize.searchParams.set("scope", "openid profile email");
  authorize.searchParams.set("state", state);
  authorize.searchParams.set("nonce", nonce);
  authorize.searchParams.set("code_challenge", pkceChallenge(verifier));
  authorize.searchParams.set("code_challenge_method", "S256");
  const trace = { label, clientId, authorize: authorize.toString(), redirects: [] };
  report.steps.push({ step: "authorize-token", status: "started", trace });

  const first = await manualFetch(authorize, { cookieJar });
  let next = locationFrom(first, `${label}: authorize`);
  trace.redirects.push({ from: "authorize", status: first.status, location: next });
  const loginChallenge = searchParam(next, "login_challenge", `${label}: login redirect`);

  const login = await requestJson(`${gatewayUrl}/api/v1/auth/password/login`, {
    method: "POST",
    cookieJar,
    body: JSON.stringify({ loginId: loginIdentifier, password, login_challenge: loginChallenge }),
  });
  next = login.body.redirectTo;
  if (!next) {
    throw new Error(`${label}: password login response missing redirectTo`);
  }
  trace.redirects.push({ from: "password-login", location: next });

  let afterLogin = await manualFetch(next, { cookieJar });
  next = locationFrom(afterLogin, `${label}: after login`);
  trace.redirects.push({ from: "after-login", status: afterLogin.status, location: next });
  if (new URL(next).searchParams.has("consent_challenge")) {
    const consentChallenge = searchParam(next, "consent_challenge", `${label}: consent redirect`);
    const consent = await requestJson(`${gatewayUrl}/api/v1/auth/consent/accept`, {
      method: "POST",
      cookieJar,
      body: JSON.stringify({
        consent_challenge: consentChallenge,
        grant_scope: ["openid", "profile", "email"],
      }),
    });
    next = consent.body.redirectTo;
    if (!next) {
      throw new Error(`${label}: consent response missing redirectTo`);
    }
    trace.redirects.push({ from: "consent-accept", location: next });
    afterLogin = await manualFetch(next, { cookieJar });
    next = locationFrom(afterLogin, `${label}: after consent`);
    trace.redirects.push({ from: "after-consent", status: afterLogin.status, location: next });
  }

  if (new URL(next).searchParams.has("error")) {
    const callback = new URL(next);
    throw new Error(`${label}: callback error ${callback.searchParams.get("error")} ${callback.searchParams.get("error_description") ?? ""}`.trim());
  }

  if (!new URL(next).searchParams.has("code")) {
    if (new URL(next).hostname === "127.0.0.1") {
      throw new Error(`${label}: callback has no code: ${next}`);
    }
    const final = await manualFetch(next, { cookieJar });
    next = locationFrom(final, `${label}: final code redirect`);
    trace.redirects.push({ from: "final", status: final.status, location: next });
  }

  const code = searchParam(next, "code", `${label}: callback`);
  const returnedState = searchParam(next, "state", `${label}: callback`);
  if (returnedState !== state) {
    throw new Error(`${label}: state mismatch`);
  }

  const tokenResponse = await fetch(`${gatewayUrl}/oidc/oauth2/token`, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      client_id: clientId,
      redirect_uri: redirectUri,
      code,
      code_verifier: verifier,
    }),
  });
  const tokenText = await tokenResponse.text();
  let tokenBody;
  try {
    tokenBody = JSON.parse(tokenText);
  } catch {
    tokenBody = tokenText;
  }
  if (!tokenResponse.ok) {
    throw new Error(`${label}: token failed ${tokenResponse.status} ${tokenText}`);
  }
  const payload = decodeJwtPayload(tokenBody.id_token);
  report.steps.push({ step: "authorize-token", status: "ok", trace });
  return {
    idTokenPayload: payload,
    rpClaims: payload.rp_claims,
    tokenResponse: {
      token_type: tokenBody.token_type,
      expires_in: tokenBody.expires_in,
      scope: tokenBody.scope,
      has_access_token: Boolean(tokenBody.access_token),
      has_id_token: Boolean(tokenBody.id_token),
    },
  };
}

async function updateClaimsFromDevfrontBrowser(page, scenario, metadata, label) {
  const result = await requestClaimsUpdateFromDevfrontBrowser(page, scenario, metadata);
  if (!result.ok) {
    throw new Error(`${label}: devfront metadata update failed ${result.status} ${JSON.stringify(result.body)}`);
  }
  return result;
}

async function requestClaimsUpdateFromDevfrontBrowser(page, scenario, metadata) {
  const subject = subjectByLoginId.get(loginIdentifier);
  return page.evaluate(
    async ({ clientId, subject, metadata }) => {
      const response = await fetch(`/api/v1/dev/clients/${clientId}/users/${subject}/metadata`, {
        method: "PUT",
        headers: {
          "Content-Type": "application/json",
          "X-Test-Role": "super_admin",
          "X-Tenant-ID": "tenant-rp-claims-live-e2e",
        },
        body: JSON.stringify({ metadata }),
      });
      const text = await response.text();
      let body;
      try {
        body = text ? JSON.parse(text) : null;
      } catch {
        body = text;
      }
      return { ok: response.ok, status: response.status, body };
    },
    { clientId: scenario.clientId, subject, metadata },
  );
}

let browser;
try {
  await mkdir(reportDir, { recursive: true });
  const subject = await createUser();
  report.subject = subject;

  for (const scenario of scenarios) {
    await createClient(scenario);
  }

  browser = await chromium.launch({ headless: true });
  const page = await browser.newPage({
    extraHTTPHeaders: {
      "X-Test-Role": "super_admin",
      "X-Tenant-ID": "tenant-rp-claims-live-e2e",
    },
  });
  await page.goto(devfrontUrl, { waitUntil: "domcontentloaded" });

  for (const scenario of scenarios) {
    const scenarioReport = {
      name: scenario.name,
      clientId: scenario.clientId,
      defaultCheck: null,
      negativeChecks: [],
      updates: [],
    };
    const defaults = await authorizeAndDecodeClaims(scenario.clientId, `${scenario.name}-default`);
    assertDeepEqual(defaults.rpClaims, scenario.defaults, `${scenario.name}: default rp_claims`);
    scenarioReport.defaultCheck = {
      expected: scenario.defaults,
      actual: defaults.rpClaims,
      token: defaults.tokenResponse,
    };

    const undefinedClaimResult = await requestClaimsUpdateFromDevfrontBrowser(page, scenario, {
      internalMemo: "must-be-rejected",
    });
    if (undefinedClaimResult.status !== 400) {
      throw new Error(`${scenario.name}: undefined claim update should be rejected with 400, got ${undefinedClaimResult.status}`);
    }
    scenarioReport.negativeChecks.push({
      label: "undefined-claim-rejected",
      status: undefinedClaimResult.status,
      body: undefinedClaimResult.body,
    });

    if (Object.hasOwn(scenario.defaults, "score")) {
      const invalidTypeResult = await requestClaimsUpdateFromDevfrontBrowser(page, scenario, {
        score: "not-a-number",
      });
      if (invalidTypeResult.status !== 400) {
        throw new Error(`${scenario.name}: invalid number update should be rejected with 400, got ${invalidTypeResult.status}`);
      }
      scenarioReport.negativeChecks.push({
        label: "invalid-number-rejected",
        status: invalidTypeResult.status,
        body: invalidTypeResult.body,
      });
    }

    for (const update of scenario.updates) {
      const updateResult = await updateClaimsFromDevfrontBrowser(page, scenario, update.metadata, `${scenario.name}-${update.label}`);
      const decoded = await authorizeAndDecodeClaims(scenario.clientId, `${scenario.name}-${update.label}`);
      assertDeepEqual(decoded.rpClaims, update.expected, `${scenario.name}.${update.label}: updated rp_claims`);
      assertAbsent(decoded.rpClaims, "internalMemo", `${scenario.name}.${update.label}`);
      assertAbsent(decoded.rpClaims, "approvalLevel_permissions", `${scenario.name}.${update.label}`);
      assertAbsent(decoded.rpClaims, "adminManagedNote_permissions", `${scenario.name}.${update.label}`);
      scenarioReport.updates.push({
        label: update.label,
        devfrontUpdateStatus: updateResult.status,
        expected: update.expected,
        actual: decoded.rpClaims,
        token: decoded.tokenResponse,
      });
    }
    report.scenarios.push(scenarioReport);
  }

  const rpAAfter = report.scenarios.find((item) => item.name === "rp-a")?.updates.at(-1)?.actual;
  const rpBAfter = report.scenarios.find((item) => item.name === "rp-b")?.updates.at(-1)?.actual;
  if (rpAAfter?.approvalLevel === rpBAfter?.approvalLevel) {
    throw new Error("rp isolation failed: rp-a and rp-b approvalLevel unexpectedly match");
  }
  assertAbsent(rpBAfter, "internalMemo", "rp-b isolation");
  assertAbsent(rpBAfter, "adminManagedNote", "rp-b isolation");

  report.passed = true;
} catch (error) {
  report.passed = false;
  report.error = {
    message: error instanceof Error ? error.message : String(error),
    stack: error instanceof Error ? error.stack : undefined,
  };
  process.exitCode = 1;
} finally {
  if (browser) {
    await browser.close();
  }
  report.finishedAt = new Date().toISOString();
  const reportPath = `${reportDir}/rp-claims-live-e2e-${runId}.json`;
  await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`);
  console.log(JSON.stringify({ passed: report.passed, reportPath, runId }, null, 2));
}
