import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { z } from 'zod';
import { decryptSecret, encryptSecret } from '../../lib/secrets';
import { HUBSPOT_REQUIRED_SCOPES } from './contracts';

const HUBSPOT_AUTHORIZE_URL = 'https://app.hubspot.com/oauth/authorize';
const HUBSPOT_TOKEN_URL = 'https://api.hubapi.com/oauth/v3/token';
const TOKEN_REFRESH_SKEW_MS = 60_000;

const tokenResponseSchema = z.object({
  access_token: z.string().min(1),
  refresh_token: z.string().min(1),
  expires_in: z.number().int().positive(),
  hub_id: z.union([z.string().min(1), z.number().int().nonnegative()]).transform(String),
  scopes: z.array(z.string()),
}).passthrough();

export interface HubSpotOAuthConfig {
  clientId: string;
  clientSecret: string;
  redirectUri: string;
}

export interface HubSpotTokenResponse {
  accessToken: string;
  refreshToken: string;
  expiresInSeconds: number;
  portalId: string;
  scopes: string[];
}

export class HubSpotOAuthError extends Error {
  constructor(
    message: string,
    readonly status?: number,
  ) {
    super(message);
    this.name = 'HubSpotOAuthError';
  }
}

export function buildHubSpotAuthorizationUrl(
  config: Pick<HubSpotOAuthConfig, 'clientId' | 'redirectUri'>,
  state: string,
): string {
  const url = new URL(HUBSPOT_AUTHORIZE_URL);
  url.searchParams.set('client_id', config.clientId);
  url.searchParams.set('redirect_uri', config.redirectUri);
  url.searchParams.set('scope', HUBSPOT_REQUIRED_SCOPES.join(' '));
  url.searchParams.set('state', state);
  return url.toString();
}

export class HubSpotOAuthClient {
  constructor(
    private readonly config: HubSpotOAuthConfig,
    private readonly fetchImpl: typeof fetch = fetch,
  ) {}

  authorizationUrl(state: string): string {
    return buildHubSpotAuthorizationUrl(this.config, state);
  }

  exchangeAuthorizationCode(code: string, signal?: AbortSignal): Promise<HubSpotTokenResponse> {
    return this.tokenRequest({
      grant_type: 'authorization_code',
      client_id: this.config.clientId,
      client_secret: this.config.clientSecret,
      redirect_uri: this.config.redirectUri,
      code,
    }, signal);
  }

  refreshAccessToken(refreshToken: string, signal?: AbortSignal): Promise<HubSpotTokenResponse> {
    return this.tokenRequest({
      grant_type: 'refresh_token',
      client_id: this.config.clientId,
      client_secret: this.config.clientSecret,
      redirect_uri: this.config.redirectUri,
      refresh_token: refreshToken,
    }, signal);
  }

  private async tokenRequest(
    form: Record<string, string>,
    signal?: AbortSignal,
  ): Promise<HubSpotTokenResponse> {
    const response = await this.fetchImpl(HUBSPOT_TOKEN_URL, {
      method: 'POST',
      headers: { 'content-type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams(form),
      signal,
    });
    if (!response.ok) {
      throw new HubSpotOAuthError('HubSpot rejected the OAuth token request.', response.status);
    }
    const parsed = tokenResponseSchema.safeParse(await response.json());
    if (!parsed.success) throw new HubSpotOAuthError('HubSpot returned an invalid OAuth token response.');
    return {
      accessToken: parsed.data.access_token,
      refreshToken: parsed.data.refresh_token,
      expiresInSeconds: parsed.data.expires_in,
      portalId: parsed.data.hub_id,
      scopes: parsed.data.scopes,
    };
  }
}

type TokenKind = 'access' | 'refresh';

function tokenPurpose(connectionId: string, kind: TokenKind): string {
  return `hubspot:${connectionId}:${kind}-token`;
}

export class HubSpotTokenVault {
  constructor(private readonly encryptionKey: string | undefined) {}

  encrypt(connectionId: string, kind: TokenKind, token: string): string {
    return encryptSecret(token, this.encryptionKey, tokenPurpose(connectionId, kind));
  }

  decrypt(connectionId: string, kind: TokenKind, envelope: string): string {
    return decryptSecret(envelope, this.encryptionKey, tokenPurpose(connectionId, kind));
  }
}

export interface LockedHubSpotCredential {
  id: string;
  status: 'active' | 'reauthorization_required' | 'disconnected' | 'error';
  encryptedAccessToken: string;
  encryptedRefreshToken: string;
  accessTokenExpiresAt: Date;
  updateTokens(input: {
    encryptedAccessToken: string;
    encryptedRefreshToken: string;
    accessTokenExpiresAt: Date;
    grantedScopes: readonly string[];
    refreshedAt: Date;
  }): Promise<void>;
}

export interface HubSpotCredentialLockStore {
  /** The adapter must hold one row/advisory lock through the callback and update. */
  withConnectionLock<T>(
    connectionId: string,
    operation: (credential: LockedHubSpotCredential) => Promise<T>,
  ): Promise<T>;
}

export class HubSpotTokenManager {
  constructor(
    private readonly store: HubSpotCredentialLockStore,
    private readonly oauth: Pick<HubSpotOAuthClient, 'refreshAccessToken'>,
    private readonly vault: HubSpotTokenVault,
    private readonly now: () => Date = () => new Date(),
  ) {}

  getAccessToken(connectionId: string, signal?: AbortSignal): Promise<string> {
    return this.store.withConnectionLock(connectionId, async (credential) => {
      if (credential.status !== 'active') {
        throw new HubSpotOAuthError('The HubSpot connection is not active.');
      }
      const now = this.now();
      if (credential.accessTokenExpiresAt.getTime() > now.getTime() + TOKEN_REFRESH_SKEW_MS) {
        return this.vault.decrypt(connectionId, 'access', credential.encryptedAccessToken);
      }
      const refreshToken = this.vault.decrypt(
        connectionId,
        'refresh',
        credential.encryptedRefreshToken,
      );
      const refreshed = await this.oauth.refreshAccessToken(refreshToken, signal);
      const missingScope = HUBSPOT_REQUIRED_SCOPES.find((scope) => !refreshed.scopes.includes(scope));
      if (missingScope) throw new HubSpotOAuthError(`HubSpot did not grant required scope ${missingScope}.`);
      const expiresAt = new Date(now.getTime() + refreshed.expiresInSeconds * 1_000);
      await credential.updateTokens({
        encryptedAccessToken: this.vault.encrypt(connectionId, 'access', refreshed.accessToken),
        encryptedRefreshToken: this.vault.encrypt(connectionId, 'refresh', refreshed.refreshToken),
        accessTokenExpiresAt: expiresAt,
        grantedScopes: refreshed.scopes,
        refreshedAt: now,
      });
      return refreshed.accessToken;
    });
  }
}

export interface StoredHubSpotOAuthState {
  requestedByUserId: string;
  returnPath: string;
}

export interface HubSpotConnectionInstallStore {
  createOAuthState(input: {
    nonceHash: string;
    requestedByUserId: string;
    returnPath: string;
    expiresAt: Date;
  }): Promise<void>;
  consumeOAuthState(nonceHash: string, now: Date): Promise<StoredHubSpotOAuthState | null>;
  reserveConnectionId(portalId: string, proposedId: string): Promise<string>;
  saveConnection(input: {
    id: string;
    portalId: string;
    encryptedAccessToken: string;
    encryptedRefreshToken: string;
    accessTokenExpiresAt: Date;
    grantedScopes: readonly string[];
    connectedByUserId: string;
    installedAt: Date;
  }): Promise<void>;
}

const OAUTH_STATE_TTL_MS = 10 * 60 * 1_000;
const DEFAULT_RETURN_PATH = '/settings/integrations/hubspot';

export class HubSpotConnectionService {
  constructor(
    private readonly store: HubSpotConnectionInstallStore,
    private readonly oauth: Pick<HubSpotOAuthClient, 'authorizationUrl' | 'exchangeAuthorizationCode'>,
    private readonly vault: HubSpotTokenVault,
    private readonly now: () => Date = () => new Date(),
  ) {}

  async begin(requestedByUserId: string): Promise<{ authorizationUrl: string }> {
    const state = randomBytes(32).toString('base64url');
    const now = this.now();
    await this.store.createOAuthState({
      nonceHash: hashOAuthState(state),
      requestedByUserId,
      returnPath: DEFAULT_RETURN_PATH,
      expiresAt: new Date(now.getTime() + OAUTH_STATE_TTL_MS),
    });
    return { authorizationUrl: this.oauth.authorizationUrl(state) };
  }

  async complete(code: string, state: string, signal?: AbortSignal): Promise<{ returnPath: string }> {
    const now = this.now();
    const storedState = await this.store.consumeOAuthState(hashOAuthState(state), now);
    if (!storedState) throw new HubSpotOAuthError('The HubSpot OAuth state is invalid or expired.');
    const tokens = await this.oauth.exchangeAuthorizationCode(code, signal);
    const missingScope = HUBSPOT_REQUIRED_SCOPES.find((scope) => !tokens.scopes.includes(scope));
    if (missingScope) throw new HubSpotOAuthError(`HubSpot did not grant required scope ${missingScope}.`);
    const connectionId = await this.store.reserveConnectionId(tokens.portalId, randomUUID());
    await this.store.saveConnection({
      id: connectionId,
      portalId: tokens.portalId,
      encryptedAccessToken: this.vault.encrypt(connectionId, 'access', tokens.accessToken),
      encryptedRefreshToken: this.vault.encrypt(connectionId, 'refresh', tokens.refreshToken),
      accessTokenExpiresAt: new Date(now.getTime() + tokens.expiresInSeconds * 1_000),
      grantedScopes: tokens.scopes,
      connectedByUserId: storedState.requestedByUserId,
      installedAt: now,
    });
    return { returnPath: storedState.returnPath };
  }
}

export function hashOAuthState(state: string): string {
  return createHash('sha256').update(state, 'utf8').digest('hex');
}
