import jwt from 'jsonwebtoken';

/**
 * The July 12 "third proof". A cryptographically valid actor token is NOT enough — a hacked
 * app can replay a stolen one. Before IIOS processes a privileged request it must also verify
 * a CONTEXT ATTESTATION: a short-lived, single-use, signed statement from an authorized parent
 * (AppShell for CRM, the adapter registry for channels, Calendar for calendar) that says
 * "this request, this client, this app, this context is genuinely mine."
 *
 * This module is the verifier + its two ports (client registry, nonce ledger) + a dev signer.
 * It is intentionally decoupled from the request path so it can be unit-tested in isolation and
 * wired into the messaging guard as a separate, gated step.
 */

/** The subset of AppShell's context-attestation envelope that IIOS verifies. */
export interface ContextAttestation {
  attestationId?: string;
  issuer: string;
  issuerType?: string; // APPSHELL | SUPPORT_BFF | ADAPTER | CALENDAR | SERVICE
  audience: string; // must equal the configured IIOS audience
  clientId: string; // must be a registered, ACTIVE client
  appId: string; // must match the actor token's app_id AND be allowed for this client
  scope?: { orgId?: string; appId?: string; tenantId?: string; buId?: string };
  nonce: string; // single-use
  iat?: number;
  exp?: number; // short-lived
}

/** A registered caller allowed to attest context on behalf of one or more apps. */
export interface ClientRegistryEntry {
  clientId: string;
  clientType: string;
  allowedAppIds: string[];
  attestSecret?: string; // dev: shared HS256 signing key (AppShell's key stand-in)
  jwksUri?: string; // prod: verify the attestation signature asymmetrically (ES256) via this JWKS
  status: 'ACTIVE' | 'DISABLED';
}

export interface ClientRegistryPort {
  find(clientId: string): Promise<ClientRegistryEntry | null>;
}

export interface NonceStorePort {
  /** Atomically reserve a nonce. Returns true if fresh, false if it was already seen (replay). */
  reserve(input: { nonce: string; clientId: string; expiresAt: Date }): Promise<boolean>;
}

/**
 * Resolves the PUBLIC key for a JWKS uri + key id, for the prod asymmetric (ES256) path.
 * A port so the verifier stays network-free and unit-testable — the real impl fetches +
 * caches the client's JWKS; tests inject a fake that returns a known key.
 */
export interface PublicKeyResolverPort {
  /** Return the signing key (PEM) for `kid` at `jwksUri`, or null if it can't be resolved. */
  resolve(jwksUri: string, kid: string): Promise<string | null>;
}

export type AttestationReason =
  | 'MALFORMED'
  | 'UNKNOWN_CLIENT'
  | 'CLIENT_DISABLED'
  | 'NO_KEY'
  | 'BAD_SIGNATURE'
  | 'WRONG_AUDIENCE'
  | 'APP_MISMATCH'
  | 'EXPIRED'
  | 'REPLAY';

export type AttestationResult =
  | { ok: true; attestation: ContextAttestation }
  | { ok: false; reason: AttestationReason; detail: string };

export interface AttestationConfig {
  /** The audience IIOS requires on attestations addressed to it (e.g. 'iios-core'). */
  audience: string;
}

const deny = (reason: AttestationReason, detail: string): AttestationResult => ({ ok: false, reason, detail });

export class ContextAttestationVerifier {
  constructor(
    private readonly registry: ClientRegistryPort,
    private readonly nonces: NonceStorePort,
    private readonly config: AttestationConfig,
    /** Optional — required only for clients that verify via a JWKS (prod ES256). */
    private readonly keys?: PublicKeyResolverPort,
  ) {}

  /**
   * Verify an attestation against an already-verified actor token. Fail-closed: any doubt → deny.
   * @param attestationJwt the signed attestation the parent issued
   * @param tokenAppId     the `app_id` from the verified actor token (Level-1 proof)
   * @param now            current time — injected so tests are deterministic
   */
  async verify(attestationJwt: string, tokenAppId: string, now: Date = new Date()): Promise<AttestationResult> {
    // 1. Decode WITHOUT verifying, only to learn who claims to have signed it.
    const claimed = jwt.decode(attestationJwt, { json: true }) as (ContextAttestation & jwt.JwtPayload) | null;
    if (!claimed || typeof claimed !== 'object' || !claimed.clientId || !claimed.nonce) {
      return deny('MALFORMED', 'attestation missing clientId/nonce');
    }

    // 2. The claimed client must be registered and ACTIVE.
    const client = await this.registry.find(claimed.clientId);
    if (!client) return deny('UNKNOWN_CLIENT', `client "${claimed.clientId}" is not registered`);
    if (client.status !== 'ACTIVE') return deny('CLIENT_DISABLED', `client "${claimed.clientId}" is ${client.status}`);

    // 3. Verify the signature with the client's key. A client verifies EITHER asymmetrically via
    //    its published JWKS (prod: AppShell signs ES256 with a private key) OR with a shared HS256
    //    secret (dev stand-in). ignoreExpiration so we can return a precise EXPIRED (step 6) rather
    //    than BAD_SIGNATURE.
    const key = await this.resolveVerificationKey(attestationJwt, client);
    if (!key.ok) return deny(key.reason, key.detail);
    let att: ContextAttestation & jwt.JwtPayload;
    try {
      att = jwt.verify(attestationJwt, key.pem, { algorithms: [key.alg], ignoreExpiration: true }) as ContextAttestation & jwt.JwtPayload;
    } catch (e) {
      return deny('BAD_SIGNATURE', (e as Error).message);
    }

    // 4. Audience must be IIOS — a token for another service can't be replayed at IIOS.
    if (att.audience !== this.config.audience) return deny('WRONG_AUDIENCE', `audience "${att.audience}" != "${this.config.audience}"`);

    // 5. app binding: the attestation's app must match the token's app AND be allowed for this client.
    if (att.appId !== tokenAppId) return deny('APP_MISMATCH', `attestation app "${att.appId}" != token app "${tokenAppId}"`);
    if (!client.allowedAppIds.includes(att.appId)) return deny('APP_MISMATCH', `client "${client.clientId}" not allowed for app "${att.appId}"`);

    // 6. Freshness — attestations are short-lived.
    const expMs = (att.exp ?? 0) * 1000;
    if (!expMs || expMs <= now.getTime()) return deny('EXPIRED', 'attestation expired or missing exp');

    // 7. Single-use — reserve the nonce. A replayed (stolen) attestation loses here. Fail-closed.
    const fresh = await this.nonces.reserve({ nonce: att.nonce, clientId: client.clientId, expiresAt: new Date(expMs) });
    if (!fresh) return deny('REPLAY', `nonce "${att.nonce}" already used`);

    return { ok: true, attestation: att };
  }

  /**
   * Pick the algorithm + verification key for a client. JWKS (ES256) wins when configured:
   * read the `kid` from the JWT header and resolve the public key from the client's JWKS.
   * Otherwise fall back to the shared HS256 dev secret. Either way returns a precise NO_KEY
   * reason when no usable key can be obtained (fail-closed).
   */
  private async resolveVerificationKey(
    attestationJwt: string,
    client: ClientRegistryEntry,
  ): Promise<{ ok: true; pem: string; alg: 'ES256' | 'HS256' } | { ok: false; reason: AttestationReason; detail: string }> {
    if (client.jwksUri) {
      if (!this.keys) return { ok: false, reason: 'NO_KEY', detail: `client "${client.clientId}" uses JWKS but no key resolver is wired` };
      const decoded = jwt.decode(attestationJwt, { complete: true });
      const kid = decoded && typeof decoded === 'object' ? (decoded.header?.kid as string | undefined) : undefined;
      if (!kid) return { ok: false, reason: 'NO_KEY', detail: 'attestation header missing kid (required for JWKS)' };
      const pem = await this.keys.resolve(client.jwksUri, kid).catch(() => null);
      if (!pem) return { ok: false, reason: 'NO_KEY', detail: `no JWKS key for kid "${kid}"` };
      return { ok: true, pem, alg: 'ES256' };
    }
    if (client.attestSecret) return { ok: true, pem: client.attestSecret, alg: 'HS256' };
    return { ok: false, reason: 'NO_KEY', detail: `no verification key configured for "${client.clientId}"` };
  }
}

/**
 * DEV/TEST helper: mint a signed attestation the way AppShell would (HS256 stand-in for its
 * signing key). Production AppShell signs asymmetrically and IIOS verifies via the client's JWKS.
 */
export function signDevAttestation(
  claims: Omit<ContextAttestation, 'iat' | 'exp'> & { ttlSeconds?: number },
  secret: string,
  now: Date = new Date(),
): string {
  const { ttlSeconds = 300, ...rest } = claims;
  const iat = Math.floor(now.getTime() / 1000);
  return jwt.sign({ ...rest, iat, exp: iat + ttlSeconds }, secret, { algorithm: 'HS256' });
}

/**
 * DEV/TEST helper: mint an attestation signed ASYMMETRICALLY (ES256), the way production AppShell
 * does. `privateKeyPem` is a PKCS#8 EC private key; `kid` is stamped into the JWT header so the
 * verifier can pick the matching public key from the client's JWKS.
 */
export function signDevAttestationES256(
  claims: Omit<ContextAttestation, 'iat' | 'exp'> & { ttlSeconds?: number },
  privateKeyPem: string,
  kid: string,
  now: Date = new Date(),
): string {
  const { ttlSeconds = 300, ...rest } = claims;
  const iat = Math.floor(now.getTime() / 1000);
  return jwt.sign({ ...rest, iat, exp: iat + ttlSeconds }, privateKeyPem, { algorithm: 'ES256', keyid: kid });
}
