import type { Context } from "@wrnexus/core";
import { signJwt, verifyJwt } from "@wrnexus/jwt";

/** Header the identity token travels in. */
export const RPC_IDENTITY_HEADER = "x-wrnexus-rpc-identity";

const MIN_SECRET_LENGTH = 32;
const DEFAULT_TTL_SECONDS = 60;
/** Upper bound on accepted token age, whatever the token's own exp says. */
const DEFAULT_MAX_AGE_SECONDS = 300;

export interface SubjectContext {
  subjectId: string;
  tenantId?: string;
  /**
   * The app that CLAIMS to have minted the token. Self-asserted: the signing
   * secret is workspace-wide, so any app can set this to any name. Useful for
   * logs and tracing; NEVER an authorization input.
   */
  callerApp: string;
}

export interface ExportOptions {
  ttlSeconds?: number;
}

export interface ImportOptions {
  /**
   * Reject a token older than this regardless of its own `exp`, so a caller
   * that mints with a huge ttlSeconds cannot create a long-lived
   * impersonation credential the callee will honour. Defaults to 300s.
   */
  maxAgeSeconds?: number;
}

/**
 * The workspace-wide RPC signing secret.
 *
 * Deliberately separate from the session secret: reusing that would make a
 * leaked RPC token a session-forgery primitive. All workspace apps share this
 * secret, so they form ONE trust boundary — any app can mint a token naming
 * any user, and compromising the lowest-privilege app compromises identity
 * across all of them.
 */
export function rpcSecret(): string {
  const secret = process.env.WRNEXUS_RPC_SECRET;
  if (!secret) {
    throw new Error(
      "WRN-RPC-SECRET: WRNEXUS_RPC_SECRET is not set. Inter-app calls cannot carry " +
        "identity without it. Use a value distinct from the session secret.",
    );
  }
  if (secret.length < MIN_SECRET_LENGTH) {
    throw new Error(
      `WRN-RPC-SECRET: WRNEXUS_RPC_SECRET must be at least ${MIN_SECRET_LENGTH} characters.`,
    );
  }
  return secret;
}

function callerAppName(): string {
  const name = process.env.WRNEXUS_APP_NAME;
  if (!name) {
    throw new Error(
      "WRN-RPC-APP: WRNEXUS_APP_NAME is not set, so a call cannot identify its caller.",
    );
  }
  return name;
}

/**
 * Mint a short-lived token naming the current subject, addressed to one app.
 *
 * Carries `sub` and `tenant` ONLY. Roles are deliberately absent: every app
 * shares the PermissionStore, so the callee resolves them itself, which makes
 * a stale or forged privilege claim impossible by construction.
 *
 * Returns undefined for an anonymous request — there is no identity to carry.
 */
export async function exportSubjectContext(
  ctx: Context,
  targetApp: string,
  options: ExportOptions = {},
): Promise<string | undefined> {
  const rawId: unknown = (ctx.user as { id?: unknown } | null | undefined)?.id;
  if (rawId === undefined || rawId === null) return undefined;
  if (typeof rawId !== "string" || rawId === "") {
    throw new Error(
      "WRN-RPC-SUBJECT: subject id must be a non-empty string; coerce numeric ids with String(id).",
    );
  }
  // An array here would mint one token valid at SEVERAL apps, defeating the
  // audience binding that stops app B replaying A's token against app C.
  if (typeof targetApp !== "string" || targetApp === "") {
    throw new Error("WRN-RPC-AUDIENCE: targetApp must be a non-empty string.");
  }
  // A numeric tenant id is the common DB-backed case. Dropping it silently
  // would leave the callee reading "no tenant" as "global", which is a
  // cross-tenant exposure — so refuse it the same way a bad subject is refused.
  // Absent ctx.tenant means untenanted (fine); a PRESENT tenant with an
  // unusable id (including null) is an error, not a silent downgrade — unlike
  // rawId === null, which mints no token at all, a bad tenant must not issue
  // an authenticated credential with silently widened scope.
  const rawTenant: unknown = ctx.tenant === undefined ? undefined : ctx.tenant.id;
  if (rawTenant !== undefined && (typeof rawTenant !== "string" || rawTenant === "")) {
    throw new Error(
      "WRN-RPC-TENANT: tenant id must be a non-empty string; coerce numeric ids with String(id).",
    );
  }
  return signJwt(
    { sub: rawId, ...(rawTenant ? { tenant: rawTenant as string } : {}) },
    rpcSecret(),
    {
      issuer: callerAppName(),
      audience: targetApp,
      expiresIn: options.ttlSeconds ?? DEFAULT_TTL_SECONDS,
    },
  );
}

/**
 * Verify a token addressed to THIS app and return the subject it names.
 *
 * `selfApp` is the audience check: it is what stops app B replaying a token it
 * received from A against a third app C.
 */
export async function importSubjectContext(
  token: string,
  selfApp: string,
  options: ImportOptions = {},
): Promise<SubjectContext> {
  // verifyJwt SKIPS the audience check entirely when audience is undefined, so
  // an empty selfApp would disable the only cross-app binding in the system and
  // accept every token from every app. currentAppName() returns
  // `string | undefined`, which is exactly how that gets passed by accident.
  if (typeof selfApp !== "string" || selfApp === "") {
    throw new Error("WRN-RPC-AUDIENCE: selfApp must be a non-empty string.");
  }
  const claims = await verifyJwt<{
    sub?: string;
    tenant?: unknown;
    iss?: string;
    exp?: number;
    iat?: number;
    aud?: unknown;
  }>(token, rpcSecret(), {
    audience: selfApp,
    maxAge: options.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS,
  });
  // verifyJwt only checks exp when it is present, so a token minted without
  // one never expires. Require it.
  if (typeof claims.exp !== "number") {
    throw new Error("WRN-RPC-IDENTITY: token has no expiry.");
  }
  // Same shape one level down: verifyJwt's maxAge check is gated on iat being
  // a number, so a token minted without iat silently defeats the age bound at
  // ANY maxAgeSeconds. A future-dated iat yields a negative age and does the
  // same. Both must be refused for maxAge to mean anything.
  const now = Math.floor(Date.now() / 1000);
  if (typeof claims.iat !== "number" || claims.iat > now + 60) {
    throw new Error("WRN-RPC-IDENTITY: token has no usable issued-at.");
  }
  if (typeof claims.sub !== "string" || claims.sub === "") {
    throw new Error("WRN-RPC-IDENTITY: token carries no usable subject.");
  }
  if (typeof claims.iss !== "string" || claims.iss === "") {
    throw new Error("WRN-RPC-IDENTITY: token names no calling app.");
  }
  // verifyJwt compares audience with includes(), so a token signed with
  // audience: ["billing", "reports"] verifies at BOTH — exactly what I3
  // exists to prevent. Require an exact single-audience match.
  if (claims.aud !== selfApp) {
    throw new Error("WRN-RPC-IDENTITY: token is addressed to more than this app.");
  }
  if (claims.tenant !== undefined && (typeof claims.tenant !== "string" || claims.tenant === "")) {
    throw new Error("WRN-RPC-IDENTITY: token carries an unusable tenant.");
  }
  return {
    subjectId: claims.sub,
    tenantId: claims.tenant as string | undefined,
    callerApp: claims.iss,
  };
}
