/**
 * Authentication-provider boundary.
 *
 * Providers prove an external identity. They do not decide whether that
 * identity belongs to PIG; workspace membership remains a database decision
 * in the authenticator and signup route.
 *
 * Two implementations:
 *
 *   **Supabase** — the hosted deployment. Well-known JWKS path, fixed issuer.
 *   **OIDC**     — any standards-compliant identity provider, which is what an
 *                  on-premises install needs. The customer already runs Okta,
 *                  Entra, Keycloak, Auth0, Authentik or Google Workspace behind
 *                  their VPN; asking them to stand up a second identity system
 *                  to use PIG would be a serious adoption tax, and in a
 *                  regulated environment often simply refused.
 *
 * Both reduce to the same thing — verify a bearer token, return a stable
 * subject and an email — because that is all PIG needs. Everything downstream
 * (teams, roles, capabilities) is PIG's own data keyed on that subject.
 */
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose';
import type { Config } from './config';

export interface VerifiedIdentity {
  subject: string;
  email?: string;
}

export interface AuthProvider {
  /** Human-readable, for startup logging and the health surface. */
  readonly name: string;
  verifyAccessToken(token: string): Promise<VerifiedIdentity>;
}

export function createSupabaseAuthProvider(supabaseUrl: string): AuthProvider {
  const issuer = `${supabaseUrl}/auth/v1`;
  // `jose` fetches lazily and caches this set, including safe key rotation.
  // Sharing one provider instance avoids a remote lookup path per handler.
  const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));

  return {
    name: 'supabase',
    async verifyAccessToken(token: string): Promise<VerifiedIdentity> {
      const { payload } = await jwtVerify(token, jwks, { issuer });
      if (!payload.sub) throw new Error('token has no subject');

      return {
        subject: payload.sub,
        email: typeof payload.email === 'string' ? payload.email : undefined,
      };
    },
  };
}

export interface OidcProviderOptions {
  /** The `iss` value the provider stamps into its tokens. */
  issuer: string;
  /**
   * JWKS location. Optional: when omitted it is discovered from
   * `${issuer}/.well-known/openid-configuration`, which every compliant
   * provider serves. Setting it explicitly avoids one startup fetch and lets
   * an air-gapped deployment skip discovery entirely.
   */
  jwksUri?: string;
  /**
   * Expected audience. **Strongly recommended.**
   *
   * Without it, any token the identity provider issued for *any* application
   * in the same tenant will verify here — a token minted for an unrelated
   * internal tool would be accepted as a PIG session. `jose` only checks the
   * audience when asked to, so leaving this unset is a real hole rather than a
   * relaxed default, and it is warned about at boot.
   */
  audience?: string;
  /**
   * Claim to read the email from. Providers disagree: most use `email`, some
   * corporate Entra configurations use `preferred_username` or `upn`. Each
   * candidate is tried in order.
   */
  emailClaims?: string[];
  /** Tolerance for clock skew between PIG and the provider. */
  clockToleranceSeconds?: number;
}

const DEFAULT_EMAIL_CLAIMS = ['email', 'preferred_username', 'upn'];

export function createOidcAuthProvider(options: OidcProviderOptions): AuthProvider {
  const issuer = options.issuer.replace(/\/+$/, '');
  const emailClaims = options.emailClaims?.length ? options.emailClaims : DEFAULT_EMAIL_CLAIMS;

  /*
   * Resolved once, lazily, and cached — including the failure.
   *
   * Discovery is a network call, so doing it per request would put the
   * identity provider on the critical path of every API call. Doing it eagerly
   * at boot would mean PIG refuses to start if the provider is briefly
   * unreachable, which on a customer's own network is a bad trade: their
   * identity provider rebooting should not take the CRM down with it.
   *
   * So it happens on first use and is retried on the next request if it fails.
   */
  let jwksPromise: Promise<ReturnType<typeof createRemoteJWKSet>> | null = null;

  async function resolveJwks() {
    if (options.jwksUri) return createRemoteJWKSet(new URL(options.jwksUri));

    const discoveryUrl = `${issuer}/.well-known/openid-configuration`;
    const response = await fetch(discoveryUrl, { headers: { accept: 'application/json' } });
    if (!response.ok) {
      throw new Error(
        `OIDC discovery failed: ${discoveryUrl} returned ${response.status}. ` +
          'Set PIG_OIDC_JWKS_URI to skip discovery.',
      );
    }

    const document = (await response.json()) as { jwks_uri?: string; issuer?: string };
    if (!document.jwks_uri) {
      throw new Error(`OIDC discovery document at ${discoveryUrl} has no jwks_uri.`);
    }

    // A discovery document whose issuer disagrees with the configured one means
    // the deployment is pointed somewhere unexpected. Verification would fail
    // later anyway; failing here says why.
    if (document.issuer && document.issuer.replace(/\/+$/, '') !== issuer) {
      throw new Error(
        `OIDC issuer mismatch: configured ${issuer}, discovery reports ${document.issuer}.`,
      );
    }

    return createRemoteJWKSet(new URL(document.jwks_uri));
  }

  return {
    name: 'oidc',
    async verifyAccessToken(token: string): Promise<VerifiedIdentity> {
      if (!jwksPromise) {
        jwksPromise = resolveJwks().catch((error) => {
          // Clear the cache so the next request retries rather than being
          // stuck with a rejected promise for the process lifetime.
          jwksPromise = null;
          throw error;
        });
      }

      const jwks = await jwksPromise;
      const { payload } = await jwtVerify(token, jwks, {
        issuer,
        ...(options.audience ? { audience: options.audience } : {}),
        clockTolerance: options.clockToleranceSeconds ?? 5,
      });

      if (!payload.sub) throw new Error('token has no subject');

      return { subject: payload.sub, email: readEmail(payload, emailClaims) };
    },
  };
}

function readEmail(payload: JWTPayload, claims: string[]): string | undefined {
  for (const claim of claims) {
    const value = payload[claim];
    // A `preferred_username` is not always an address; only take it if it
    // looks like one, so a bare username never becomes an account identity.
    if (typeof value === 'string' && value.includes('@')) return value.toLowerCase();
  }
  return undefined;
}

/**
 * Build the provider this deployment is configured for.
 *
 * OIDC wins when both are set, so an on-premises install can keep the Supabase
 * values in its environment file without them quietly taking precedence.
 */
export function createConfiguredAuthProvider(
  config: Pick<
    Config,
    | 'SUPABASE_URL'
    | 'PIG_OIDC_ISSUER'
    | 'PIG_OIDC_JWKS_URI'
    | 'PIG_OIDC_AUDIENCE'
    | 'PIG_OIDC_EMAIL_CLAIMS'
  >,
): AuthProvider | null {
  if (config.PIG_OIDC_ISSUER) {
    return createOidcAuthProvider({
      issuer: config.PIG_OIDC_ISSUER,
      jwksUri: config.PIG_OIDC_JWKS_URI || undefined,
      audience: config.PIG_OIDC_AUDIENCE || undefined,
      emailClaims: config.PIG_OIDC_EMAIL_CLAIMS
        ? config.PIG_OIDC_EMAIL_CLAIMS.split(',').map((claim) => claim.trim()).filter(Boolean)
        : undefined,
    });
  }

  return config.SUPABASE_URL ? createSupabaseAuthProvider(config.SUPABASE_URL) : null;
}
