import { OAuthAccessToken, OAuthRefreshToken } from '@api/graphql';
import { OAuthSequelizeAccessTokenRepository } from '@app/o-auth/access-token';
import { OAuthCreateAccessTokenService } from '@app/o-auth/access-token/application/create/o-auth-create-access-token.service';
import { OAuthFindApplicationService } from '@app/o-auth/application';
import { OAuthCreateCredentialService } from '@app/o-auth/credential/application/create/o-auth-create-credential.service';
import { OAuthSequelizeRefreshTokenRepository } from '@app/o-auth/refresh-token';
import { OAuthCreateRefreshTokenService } from '@app/o-auth/refresh-token/application/create/o-auth-create-refresh-token.service';
import { oAuthAuthenticateApplication } 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, UnauthorizedClientError } from '../../../domain/errors';
import type {
  OAuthCreateTokenRefreshInput,
  OAuthCreateTokenResponse,
} from '../../../domain/flows.types';

@Injectable()
export class OAuthCreateTokenRefreshService {
  constructor(
    @Inject(JWT_SIGNER)
    private readonly jwtSigner: IJwtSigner,
    @Inject(ACCOUNT_LOADER)
    private readonly accountLoader: IAccountLoader,
    private readonly findApplication: OAuthFindApplicationService,
    private readonly refreshTokenRepo: OAuthSequelizeRefreshTokenRepository,
    private readonly accessTokenRepo: OAuthSequelizeAccessTokenRepository,
    private readonly createAccessToken: OAuthCreateAccessTokenService,
    private readonly createRefreshToken: OAuthCreateRefreshTokenService,
    private readonly createCredential: OAuthCreateCredentialService,
  ) {}

  async main(payload: OAuthCreateTokenRefreshInput): Promise<OAuthCreateTokenResponse> {
    // 1. Authenticate the application by Basic credentials only — do NOT filter by
    // grantType. RFC 6749 §6: the refresh token is bound to the issuing client, so
    // the server must verify THAT binding, not require a REFRESH_TOKEN-typed client.
    // Raises invalid_client on unknown code or secret mismatch (RFC 6749 §5.2).
    // oAuthAuthenticateApplication eager-loads `clients` (include: [{ association: 'clients' }]).
    const application = await oAuthAuthenticateApplication(this.findApplication, {
      clientId: payload.clientId,
      clientSecret: payload.clientSecret,
    });

    // 2. Look up the refresh token by its `token` value. All failure modes (unknown,
    // expired, revoked) collapse to a single `invalid_grant`.
    const refreshRow = await this.findRefreshToken(payload.refreshToken);
    if (!refreshRow) throw new InvalidGrantError();
    if (refreshRow.isRevoked) throw new InvalidGrantError();
    if (refreshRow.expiresAt && refreshRow.expiresAt.getTime() < Date.now()) {
      throw new InvalidGrantError();
    }

    // 3. Verify the linked access token row exists.
    const accessRow = (await this.accessTokenRepo.repository.findByPk(
      refreshRow.accessTokenId,
    )) as OAuthAccessToken | null;
    if (!accessRow) throw new InvalidGrantError();

    // 4. Binding check (RFC 6749 §10.4): confirm the issuing client belongs to the
    // authenticated application. The application was loaded with `clients` eager-loaded
    // (via oAuthAuthenticateApplication → include: [{ association: 'clients' }]), so this
    // is an in-memory check — no extra DB round-trip.
    const issuingClientBelongsToApp = (application.clients ?? []).some(
      (client) => client?.id === accessRow.clientId,
    );
    if (!issuingClientBelongsToApp) throw new UnauthorizedClientError();

    const issuingClientId = accessRow.clientId;
    const accountId = accessRow.accountId;

    // 5. Confirm the account is still loadable.
    const account = await this.accountLoader.loadById(accountId);
    if (!account) throw new InvalidGrantError();

    // 6. Single-use rotation (RFC 6819 §5.2.2.3): mark BOTH old tokens as revoked
    // BEFORE issuing new ones. If the new-issue step fails after revocation, the user
    // is left without a session — better than leaving an old token valid alongside.
    await this.refreshTokenRepo.repository.update(
      { isRevoked: true },
      { where: { id: refreshRow.id } },
    );
    await this.accessTokenRepo.repository.update(
      { isRevoked: true },
      { where: { id: accessRow.id } },
    );

    // 7. Issue new access + refresh.
    const newAccess = await this.jwtSigner.signAccess({ aci: account.id });
    const newRefresh = await this.jwtSigner.signRefresh();

    const newAccessTokenId = randomUUID();
    const newRefreshTokenId = randomUUID();

    await this.createAccessToken.main({
      id: newAccessTokenId,
      clientId: issuingClientId,
      accountId: account.id,
      token: newAccess.token,
      name: null,
      isRevoked: false,
      expiresAt: newAccess.expiresAt,
    });

    await this.createRefreshToken.main({
      id: newRefreshTokenId,
      accessTokenId: newAccessTokenId,
      token: newRefresh.token,
      isRevoked: false,
      expiresAt: newRefresh.expiresAt,
    });

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

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

  /** Find a refresh token row by its plaintext token value. Returns null when not found. */
  private async findRefreshToken(token: string): Promise<OAuthRefreshToken | null> {
    const row = (await this.refreshTokenRepo.repository.findOne({
      where: { token },
    })) as OAuthRefreshToken | null;
    return row;
  }
}

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));
}
