import { createHash, randomBytes } from "node:crypto";
import { constants as fsConstants } from "node:fs";
import { open } from "node:fs/promises";
import { resolve } from "node:path";

export const FOUNDRY_BINDING_GRANT_SCHEMA_VERSION = "nodedc.module-foundry.binding-grant.v1";
export const FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION = "nodedc.foundry.binding-upsert/v1";
export const FOUNDRY_BINDING_CATALOG_ACTION = "foundry.data-product.catalog.read";
export const FOUNDRY_BINDING_UPSERT_ACTION = "foundry.map-data-product.upsert";
export const FOUNDRY_BINDING_CATALOG_PATH = "/internal/foundry/v1/data-products";
export const FOUNDRY_BINDING_UPSERT_PATH = "/internal/foundry/v1/data-product-bindings";

const TOKEN_PREFIX = "ndc_fndbg_";
const TOKEN_PATTERN = /^ndc_fndbg_[A-Za-z0-9_-]{43}$/;
const HASH_PATTERN = /^[a-f0-9]{64}$/;
const IDENTIFIER = /^[A-Za-z0-9._:-]{1,160}$/;
// Keep caller-controlled identifiers compatible with
// @nodedc/external-provider-contract. Grant metadata has its own, deliberately
// broader IDENTIFIER grammar above, while binding values use the canonical
// lower-case identifier grammar.
const CONTRACT_IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
const APPLICATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const PAGE_ID = /^[a-z0-9][a-z0-9-]{0,79}$/;
const SLOT_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9-]{0,79}$/;
const SECRET_LIKE_KEY = /(token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key)/i;
const SECRET_LIKE_VALUE = /(?:ndc_(?:edp(?:wb|rb)|fndbg)_[A-Za-z0-9_-]+|(?:bearer|basic)\s+\S+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i;
const TRANSPORT_OR_SCOPE_KEY = /(provider|tenant|connection|endpoint|url|credential|payload)/i;
const ALLOWED_ACTIONS = new Set([FOUNDRY_BINDING_CATALOG_ACTION, FOUNDRY_BINDING_UPSERT_ACTION]);
const GRANT_KEYS = new Set([
  "schemaVersion",
  "grantId",
  "tokenHash",
  "active",
  "issuedAt",
  "expiresAt",
  "actorId",
  "ownerKey",
  "actions",
  "targets",
]);
const TARGET_KEYS = new Set([
  "applicationId",
  "pageId",
  "bindingId",
  "dataProductId",
  "slotId",
  "semanticTypes",
  "fieldProjection",
]);
const REQUEST_KEYS = new Set(["schemaVersion", "applicationId", "pageId", "idempotencyKey", "binding"]);
const BINDING_KEYS = new Set(["id", "dataProductId", "slotId", "semanticTypes", "fieldProjection"]);
const MAX_REQUEST_BYTES = 64 * 1024;
const MAX_GRANT_BYTES = 128 * 1024;
const DEFAULT_MAX_GRANT_TTL_MS = 90 * 24 * 60 * 60 * 1000;

export function createFoundryBindingGrantToken() {
  return `${TOKEN_PREFIX}${randomBytes(32).toString("base64url")}`;
}

export function foundryBindingGrantFileName(token) {
  if (!TOKEN_PATTERN.test(String(token || ""))) throw apiError("foundry_binding_unauthorized", 401);
  return createHash("sha256").update(token, "utf8").digest("hex");
}

/**
 * Handles only the private Foundry binding workload surface. Authentication is
 * an opaque, revocable workload grant. Browser sessions, shared Platform
 * credentials and EDP reader/writer tokens are deliberately unsupported.
 */
export async function handleFoundryBindingApiRequest(request, response, options = {}) {
  try {
    const url = new URL(request.url || "/", `http://${request.headers.host || "foundry.local"}`);
    if (url.pathname !== FOUNDRY_BINDING_CATALOG_PATH && url.pathname !== FOUNDRY_BINDING_UPSERT_PATH) {
      return sendJson(response, 404, { ok: false, error: "foundry_binding_route_not_found" });
    }
    // Authorization and grant records are evaluated on every request so
    // revocation/rotation takes effect immediately. Never let an intermediary
    // replay a previously authorized catalog or binding result.
    if (request.headers.origin) throw apiError("foundry_binding_browser_origin_forbidden", 403);

    const expectedMethod = url.pathname === FOUNDRY_BINDING_CATALOG_PATH ? "GET" : "POST";
    if (request.method !== expectedMethod) {
      response.setHeader("allow", expectedMethod);
      throw apiError("method_not_allowed", 405);
    }

    const token = bearerToken(request);
    const grant = await resolveGrant(token, options);
    if (url.pathname === FOUNDRY_BINDING_CATALOG_PATH) {
      assertAction(grant, FOUNDRY_BINDING_CATALOG_ACTION);
      return sendJson(response, 200, {
        ok: true,
        dataProducts: grantedDataProducts(grant),
      });
    }

    assertAction(grant, FOUNDRY_BINDING_UPSERT_ACTION);
    const contentType = String(request.headers["content-type"] || "").split(";", 1)[0].trim().toLowerCase();
    if (contentType !== "application/json") throw apiError("foundry_binding_content_type_required", 415);
    const input = validateUpsertInput(await readJsonBody(request));
    const target = findExactTarget(grant, input);
    if (!target) throw apiError("foundry_binding_target_forbidden", 403);
    if (typeof options.preflightReaderGrant !== "function") {
      throw apiError("foundry_binding_reader_grant_preflight_unavailable", 503);
    }
    if (typeof options.upsertBinding !== "function") {
      throw apiError("foundry_binding_upsert_unavailable", 503);
    }

    const actor = Object.freeze({ actorId: grant.actorId, ownerKey: grant.ownerKey });
    const preflight = await options.preflightReaderGrant({
      applicationId: input.applicationId,
      pageId: input.pageId,
      bindingId: input.binding.id,
      dataProductId: input.binding.dataProductId,
      actor,
      grantId: grant.grantId,
    });
    if (preflight === false) throw apiError("data_product_reader_grant_not_ready", 409);

    const result = await options.upsertBinding(input, actor);
    // The callback may return a complete application/page object. This private
    // workload surface deliberately projects only the already-authorized
    // binding instead of reflecting callback-owned state back to L2.
    const resultBinding = { ...input.binding, delivery: "snapshot+patch" };
    const replayed = result?.idempotency?.replayed === true;
    return sendJson(response, 200, {
      ok: true,
      applicationId: input.applicationId,
      pageId: input.pageId,
      binding: resultBinding,
      idempotency: { replayed },
    });
  } catch (error) {
    const normalized = normalizeError(error);
    return sendJson(response, normalized.statusCode, { ok: false, error: normalized.code });
  }
}

async function resolveGrant(token, options) {
  const fileName = foundryBindingGrantFileName(token);
  const grantsDir = String(options.grantsDir || "").trim();
  if (!grantsDir) throw apiError("foundry_binding_grants_not_configured", 503);
  const path = resolve(grantsDir, fileName);
  const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0);
  let handle;
  try {
    handle = await open(path, flags);
  } catch (error) {
    if (error?.code === "ENOENT") throw apiError("foundry_binding_unauthorized", 401);
    if (error?.code === "ELOOP") throw apiError("foundry_binding_grant_file_invalid", 503);
    throw apiError("foundry_binding_grant_file_unavailable", 503);
  }
  try {
    const metadata = await handle.stat();
    if (!metadata.isFile() || metadata.size < 2 || metadata.size > MAX_GRANT_BYTES) {
      throw apiError("foundry_binding_grant_file_invalid", 503);
    }
    // Grant files are immutable capability records. They may be replaced
    // atomically for rotation/revocation, but the open inode itself is never
    // writable. Production additionally requires root ownership.
    if ((metadata.mode & 0o222) !== 0) throw apiError("foundry_binding_grant_file_invalid", 503);
    const production = options.production ?? process.env.NODE_ENV === "production";
    if (production && metadata.uid !== 0) throw apiError("foundry_binding_grant_file_invalid", 503);
    let value;
    try {
      value = JSON.parse(await handle.readFile("utf8"));
    } catch {
      throw apiError("foundry_binding_grant_file_invalid", 503);
    }
    return validateGrant(value, fileName, options);
  } finally {
    await handle.close();
  }
}

function validateGrant(value, expectedTokenHash, options) {
  if (!isPlainObject(value) || !hasOnlyKeys(value, GRANT_KEYS)) {
    throw apiError("foundry_binding_grant_invalid", 503);
  }
  if (value.schemaVersion !== FOUNDRY_BINDING_GRANT_SCHEMA_VERSION) {
    throw apiError("foundry_binding_grant_invalid", 503);
  }
  const grantId = requireIdentifier(value.grantId, "foundry_binding_grant_invalid");
  const tokenHash = String(value.tokenHash || "");
  if (!HASH_PATTERN.test(tokenHash) || tokenHash !== expectedTokenHash) {
    throw apiError("foundry_binding_grant_invalid", 503);
  }
  if (value.active !== true) throw apiError("foundry_binding_grant_inactive", 401);
  const actorId = requireIdentifier(value.actorId, "foundry_binding_grant_invalid");
  const ownerKey = requireIdentifier(value.ownerKey, "foundry_binding_grant_invalid");
  const issuedAt = timestamp(value.issuedAt, "foundry_binding_grant_invalid");
  const expiresAt = timestamp(value.expiresAt, "foundry_binding_grant_invalid");
  const now = nowMs(options.now);
  const maxTtlMs = boundedMaxTtl(options.maxGrantTtlMs);
  if (issuedAt > now + 60_000 || expiresAt <= issuedAt || expiresAt - issuedAt > maxTtlMs) {
    throw apiError("foundry_binding_grant_invalid", 503);
  }
  if (expiresAt <= now) throw apiError("foundry_binding_grant_expired", 401);
  const actions = uniqueStrings(value.actions, (item) => ALLOWED_ACTIONS.has(item), 1, ALLOWED_ACTIONS.size);
  if (!actions) throw apiError("foundry_binding_grant_invalid", 503);
  if (!Array.isArray(value.targets) || value.targets.length < 1 || value.targets.length > 128) {
    throw apiError("foundry_binding_grant_invalid", 503);
  }
  const targets = value.targets.map(validateGrantTarget);
  const targetKeys = new Set(targets.map(targetKey));
  if (targetKeys.size !== targets.length) throw apiError("foundry_binding_grant_invalid", 503);
  return Object.freeze({ grantId, actorId, ownerKey, actions, targets });
}

function validateGrantTarget(value) {
  if (!isPlainObject(value) || !hasOnlyKeys(value, TARGET_KEYS)) {
    throw apiError("foundry_binding_grant_invalid", 503);
  }
  return Object.freeze({
    applicationId: requireApplicationId(value.applicationId, "foundry_binding_grant_invalid"),
    pageId: requirePageId(value.pageId, "foundry_binding_grant_invalid"),
    bindingId: requireContractIdentifier(value.bindingId, "foundry_binding_grant_invalid"),
    dataProductId: requireContractIdentifier(value.dataProductId, "foundry_binding_grant_invalid"),
    slotId: requireSlot(value.slotId, "foundry_binding_grant_invalid"),
    semanticTypes: Object.freeze(identifierList(value.semanticTypes, 1, 8, "foundry_binding_grant_invalid")),
    fieldProjection: Object.freeze(fieldList(value.fieldProjection, "foundry_binding_grant_invalid")),
  });
}

function validateUpsertInput(value) {
  if (containsSecretMaterial(value)) throw apiError("foundry_binding_secret_material_forbidden", 400);
  if (containsTransportOrScopeKey(value)) throw apiError("foundry_binding_transport_or_scope_forbidden", 400);
  if (!isPlainObject(value) || !hasOnlyKeys(value, REQUEST_KEYS) || !isPlainObject(value.binding) || !hasOnlyKeys(value.binding, BINDING_KEYS)) {
    throw apiError("foundry_binding_request_invalid", 400);
  }
  if (value.schemaVersion !== FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION) {
    throw apiError("foundry_binding_schema_version_unsupported", 400);
  }
  const input = {
    schemaVersion: FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION,
    applicationId: requireApplicationId(value.applicationId, "foundry_binding_request_invalid"),
    pageId: requirePageId(value.pageId, "foundry_binding_request_invalid"),
    idempotencyKey: requireIdempotencyKey(value.idempotencyKey),
    binding: validateBinding(value.binding),
  };
  return Object.freeze({ ...input, binding: Object.freeze(input.binding) });
}

function validateBinding(value) {
  if (!isPlainObject(value) || !hasOnlyKeys(value, BINDING_KEYS)) {
    throw apiError("foundry_binding_request_invalid", 400);
  }
  const semanticTypes = Object.freeze(identifierList(value.semanticTypes, 1, 8, "foundry_binding_request_invalid"));
  const fieldProjection = Object.freeze(fieldList(value.fieldProjection, "foundry_binding_request_invalid"));
  if (fieldProjection.some((field) => SECRET_LIKE_KEY.test(field))) {
    throw apiError("foundry_binding_secret_material_forbidden", 400);
  }
  return {
    id: requireContractIdentifier(value.id, "foundry_binding_request_invalid"),
    dataProductId: requireContractIdentifier(value.dataProductId, "foundry_binding_request_invalid"),
    slotId: requireSlot(value.slotId, "foundry_binding_request_invalid"),
    semanticTypes,
    fieldProjection,
  };
}

function findExactTarget(grant, input) {
  const candidate = {
    applicationId: input.applicationId,
    pageId: input.pageId,
    bindingId: input.binding.id,
    dataProductId: input.binding.dataProductId,
    slotId: input.binding.slotId,
    semanticTypes: input.binding.semanticTypes,
    fieldProjection: input.binding.fieldProjection,
  };
  const key = targetKey(candidate);
  return grant.targets.find((target) => targetKey(target) === key) || null;
}

function targetKey(target) {
  return JSON.stringify([
    target.applicationId,
    target.pageId,
    target.bindingId,
    target.dataProductId,
    target.slotId,
    sorted(target.semanticTypes),
    sorted(target.fieldProjection),
  ]);
}

function grantedDataProducts(grant) {
  const products = new Map();
  for (const target of grant.targets) {
    const existing = products.get(target.dataProductId) || new Set();
    for (const semanticType of target.semanticTypes) existing.add(semanticType);
    products.set(target.dataProductId, existing);
  }
  return [...products.entries()]
    .sort(([left], [right]) => left.localeCompare(right))
    .map(([id, semanticTypes]) => ({ id, semanticTypes: [...semanticTypes].sort() }));
}

function assertAction(grant, action) {
  if (!grant.actions.includes(action)) throw apiError("foundry_binding_action_forbidden", 403);
}

async function readJsonBody(request) {
  const chunks = [];
  let size = 0;
  for await (const chunk of request) {
    size += chunk.length;
    if (size > MAX_REQUEST_BYTES) throw apiError("foundry_binding_payload_too_large", 413);
    chunks.push(chunk);
  }
  try {
    return JSON.parse(Buffer.concat(chunks).toString("utf8"));
  } catch {
    throw apiError("foundry_binding_invalid_json", 400);
  }
}

function bearerToken(request) {
  const value = String(request.headers.authorization || "");
  const match = /^Bearer ([^\s]+)$/.exec(value);
  if (!match || !TOKEN_PATTERN.test(match[1])) throw apiError("foundry_binding_unauthorized", 401);
  return match[1];
}

function requireIdentifier(value, code) {
  const normalized = String(value || "").trim();
  if (!IDENTIFIER.test(normalized)) throw apiError(code, code.endsWith("grant_invalid") ? 503 : 400);
  return normalized;
}

function requireContractIdentifier(value, code) {
  const normalized = typeof value === "string" ? value : "";
  if (!CONTRACT_IDENTIFIER.test(normalized)) throw apiError(code, code.endsWith("grant_invalid") ? 503 : 400);
  return normalized;
}

function requireSlot(value, code) {
  const normalized = typeof value === "string" ? value : "";
  if (!SLOT_IDENTIFIER.test(normalized)) throw apiError(code, code.endsWith("grant_invalid") ? 503 : 400);
  return normalized;
}

function requireApplicationId(value, code) {
  const normalized = typeof value === "string" ? value : "";
  if (!APPLICATION_ID.test(normalized)) throw apiError(code, code.endsWith("grant_invalid") ? 503 : 400);
  return normalized;
}

function requirePageId(value, code) {
  const normalized = typeof value === "string" ? value : "";
  if (!PAGE_ID.test(normalized)) throw apiError(code, code.endsWith("grant_invalid") ? 503 : 400);
  return normalized;
}

function requireIdempotencyKey(value) {
  const normalized = typeof value === "string" ? value : "";
  if (!CONTRACT_IDENTIFIER.test(normalized)) throw apiError("foundry_binding_request_invalid", 400);
  return normalized;
}

function identifierList(value, min, max, code) {
  const list = uniqueStrings(value, (item) => CONTRACT_IDENTIFIER.test(item), min, max);
  if (!list) throw apiError(code, code.endsWith("grant_invalid") ? 503 : 400);
  return list;
}

function fieldList(value, code) {
  const list = uniqueStrings(value, (item) => CONTRACT_IDENTIFIER.test(item), 0, 32);
  if (!list) throw apiError(code, code.endsWith("grant_invalid") ? 503 : 400);
  return list;
}

function uniqueStrings(value, predicate, min, max) {
  if (!Array.isArray(value) || value.length < min || value.length > max) return null;
  const normalized = value.map((item) => typeof item === "string" ? item : "");
  if (normalized.some((item) => !item || !predicate(item))) return null;
  if (new Set(normalized).size !== normalized.length) return null;
  return normalized;
}

function timestamp(value, code) {
  const number = Date.parse(String(value || ""));
  if (!Number.isFinite(number)) throw apiError(code, 503);
  return number;
}

function nowMs(value) {
  const candidate = typeof value === "function" ? value() : value;
  if (candidate instanceof Date) return candidate.getTime();
  if (candidate === undefined) return Date.now();
  const number = Number(candidate);
  if (!Number.isFinite(number)) throw apiError("foundry_binding_clock_invalid", 503);
  return number;
}

function boundedMaxTtl(value) {
  if (value === undefined) return DEFAULT_MAX_GRANT_TTL_MS;
  const number = Number(value);
  if (!Number.isInteger(number) || number < 60_000 || number > 365 * 24 * 60 * 60 * 1000) {
    throw apiError("foundry_binding_max_ttl_invalid", 503);
  }
  return number;
}

function containsSecretMaterial(value) {
  if (typeof value === "string") return SECRET_LIKE_VALUE.test(value);
  if (Array.isArray(value)) return value.some(containsSecretMaterial);
  if (!isPlainObject(value)) return false;
  return Object.entries(value).some(([key, child]) => SECRET_LIKE_KEY.test(key) || containsSecretMaterial(child));
}

function containsTransportOrScopeKey(value) {
  if (Array.isArray(value)) return value.some(containsTransportOrScopeKey);
  if (!isPlainObject(value)) return false;
  return Object.entries(value).some(([key, child]) => TRANSPORT_OR_SCOPE_KEY.test(key) || containsTransportOrScopeKey(child));
}

function hasOnlyKeys(value, allowed) {
  return Object.keys(value).every((key) => allowed.has(key));
}

function sorted(value) {
  return [...value].sort();
}

function isPlainObject(value) {
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
  const prototype = Object.getPrototypeOf(value);
  return prototype === Object.prototype || prototype === null;
}

function apiError(code, statusCode) {
  return Object.assign(new Error(code), { code, statusCode });
}

function normalizeError(error) {
  const statusCode = Number(error?.statusCode);
  const code = String(error?.code || error?.message || "");
  if (Number.isInteger(statusCode) && statusCode >= 400 && statusCode <= 599 && /^[a-z0-9_.:-]{1,120}$/i.test(code)) {
    return { statusCode, code };
  }
  return { statusCode: 500, code: "foundry_binding_internal_error" };
}

function sendJson(response, statusCode, payload) {
  if (response.writableEnded) return;
  response.writeHead(statusCode, {
    "cache-control": "no-store, max-age=0",
    "content-type": "application/json; charset=utf-8",
    pragma: "no-cache",
    vary: "authorization",
  });
  response.end(JSON.stringify(payload));
}
