import { OAuthClientGrantTypeEnum } from '@api/graphql';
import { OAuthCreateAccessTokenService } from '@app/o-auth/access-token/application/create/o-auth-create-access-token.service';
import { OAuthFindApplicationService } from '@app/o-auth/application';
import { OAuthSequelizeAuthorizationCodeRepository } from '@app/o-auth/authorization-code';
import { OAuthCreateCredentialService } from '@app/o-auth/credential/application/create/o-auth-create-credential.service';
import { OAuthCreateRefreshTokenService } from '@app/o-auth/refresh-token/application/create/o-auth-create-refresh-token.service';
import { oAuthResolveApplicationClient } from '@app/o-auth/shared';
import {
  ACCOUNT_LOADER,
  type IAccountLoader,
  type IJwtSigner,
  JWT_SIGNER,
} from '@aurora/modules/authentication/domain/ports';
import { Inject, Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { InvalidGrantError, InvalidRequestError } from '../../../domain/errors';
import type {
  OAuthCreateTokenAuthorizationCodeInput,
  OAuthCreateTokenResponse,
} from '../../../domain/flows.types';
import { oAuthVerifyPkceS256 } from '../../../shared/pkce/o-auth-verify-pkce-s256';

/**
 * Authorization Code grant exchange service (RFC 6749 §4.1.3 + RFC 7636 PKCE).
 *
 * Sibling of `OAuthCreateTokenPasswordService`. Validates and consumes an authorization
 * code, verifies PKCE S256, loads the bound account, and issues tokens.
 *
 * Business invariants enforced (Design decisions D3, D4, D5):
 *   - Code must exist and not be expired (invalid_grant).
 *   - clientId bound to the code must match the authenticated client (invalid_grant).
 *   - redirect_uri must exactly match the value stored at authorize time (invalid_grant).
 *   - PKCE S256: BASE64URL(SHA256(code_verifier)) must equal code_challenge (invalid_request).
 *   - Atomic single-use: conditional UPDATE WHERE is_used = false; 0 rows → invalid_grant.
 *
 * Token response follows RFC 6749 §5.1:
 *   { access_token, token_type: 'Bearer', expires_in, refresh_token }
 */
@Injectable()
export class OAuthCreateTokenAuthorizationCodeService {
  constructor(
    @Inject(JWT_SIGNER)
    private readonly jwtSigner: IJwtSigner,
    @Inject(ACCOUNT_LOADER)
    private readonly accountLoader: IAccountLoader,
    private readonly findApplication: OAuthFindApplicationService,
    private readonly authCodeRepo: OAuthSequelizeAuthorizationCodeRepository,
    private readonly createAccessToken: OAuthCreateAccessTokenService,
    private readonly createRefreshToken: OAuthCreateRefreshTokenService,
    private readonly createCredential: OAuthCreateCredentialService,
  ) {}

  async main(payload: OAuthCreateTokenAuthorizationCodeInput): Promise<OAuthCreateTokenResponse> {
    // 1. Authenticate the application (Basic auth client_id = OAuthApplication.code) and resolve
    // the technical OAuthClient for the AUTHORIZATION_CODE grant.
    // Raises invalid_client / unauthorized_client per RFC 6749 §5.2.
    const client = await oAuthResolveApplicationClient(this.findApplication, {
      clientId: payload.clientId,
      clientSecret: payload.clientSecret,
      grantType: OAuthClientGrantTypeEnum.AUTHORIZATION_CODE,
    });

    // 2. Look up the authorization code by its code string value.
    // All failure modes (not found, expired, mismatched bindings) → invalid_grant.
    const authCode = await this.authCodeRepo.repository.findOne({
      where: { code: payload.code },
    });

    if (!authCode) throw new InvalidGrantError();

    // 3. Validate expiry. isUsed is checked atomically in step 6.
    const expiresAt = new Date(authCode.expiresAt);
    if (expiresAt < new Date()) throw new InvalidGrantError();

    // 4. Validate binding: clientId + redirect_uri must exactly match (D5).
    if (authCode.clientId !== client.id) throw new InvalidGrantError();
    if (authCode.redirectUri !== payload.redirectUri) throw new InvalidGrantError();

    // 5. Verify PKCE S256. RFC 7636 §4.6 requires `invalid_grant` at the token endpoint
    // for a verifier mismatch. The helper throws `InvalidRequestError` on any failure;
    // we catch and rethrow as `InvalidGrantError` so the response is RFC-compliant.
    // `invalid_request` from the helper is intentional (method != S256 or empty inputs
    // are also verifier problems at exchange time); all PKCE failures here are `invalid_grant`.
    try {
      oAuthVerifyPkceS256({
        codeVerifier: payload.codeVerifier,
        codeChallenge: authCode.codeChallenge,
        codeChallengeMethod: authCode.codeChallengeMethod,
      });
    } catch (error) {
      if (error instanceof InvalidRequestError) {
        throw new InvalidGrantError();
      }
      throw error;
    }

    // 6. Atomic single-use mark: UPDATE WHERE is_used = false; 0 rows affected → reused code (D4).
    const consumed = await this.authCodeRepo.consumeCode(authCode.id);
    if (!consumed) throw new InvalidGrantError();

    // 7. Load the account from IAM via the cross-BC ACCOUNT_LOADER port.
    const account = await this.accountLoader.loadById(authCode.accountId);
    if (!account) throw new InvalidGrantError();

    // 8. Sign the access JWT + opaque refresh token.
    const access = await this.jwtSigner.signAccess({ aci: account.id });
    const refresh = await this.jwtSigner.signRefresh();

    // 9. Persist all three rows (access token, refresh token, credential audit row).
    //
    // INTENTIONAL TRADE-OFF — fail-closed single-use with non-transactional writes:
    // The code is consumed atomically in step 6 (consumeCode conditional UPDATE). The
    // three persistence writes below are sequential and NOT wrapped in a transaction,
    // mirroring the password and refresh-token siblings. Consequence: if a transient
    // failure occurs after consumeCode but before all three rows are persisted, the
    // code is burned and the user must restart the Authorization Code flow. This is the
    // deliberately chosen fail-closed posture — a burned code is preferable to a
    // retryable code that could be replayed or race-exploited.
    //
    // A possible follow-up: thread a DB transaction through the sub-services so that
    // consumeCode + the three inserts are atomic. Deferred for consistency with the
    // password/refresh grants; revisit when those siblings are transactionalized.
    //
    // Orphaned rows (e.g. access token with no credential) are cleaned by the retention job.
    const accessTokenId = randomUUID();
    const refreshTokenId = randomUUID();

    await this.createAccessToken.main({
      id: accessTokenId,
      clientId: client.id,
      accountId: account.id,
      token: access.token,
      name: null,
      isRevoked: false,
      expiresAt: access.expiresAt,
    });

    await this.createRefreshToken.main({
      id: refreshTokenId,
      accessTokenId,
      token: refresh.token,
      isRevoked: false,
      expiresAt: refresh.expiresAt,
    });

    await this.createCredential.main({
      id: randomUUID(),
      grantType: 'AUTHORIZATION_CODE',
      accountId: account.id,
      accessTokenId,
      refreshTokenId,
      ipAddress: truncate(payload.ipAddress, 45),
      userAgent: truncate(payload.userAgent, 255),
    });

    return {
      access_token: access.token,
      token_type: 'Bearer',
      expires_in: secondsUntil(access.expiresAt),
      refresh_token: refresh.token,
    };
  }
}

function truncate(value: string | null, max: number): string | null {
  if (value === null) return null;
  return value.length > max ? value.slice(0, max) : value;
}

function secondsUntil(date: Date): number {
  return Math.max(0, Math.floor((date.getTime() - Date.now()) / 1000));
}
