import * as plugins from '../plugins.js';
import { logger } from '../logging.js';
import type { IPasskeyCredential, IUser } from '../types.js';
import type { OpsServer } from './classes.opsserver.js';
import type * as interfaces from '../../ts_interfaces/index.js';
import { normalizePasskeyHostname } from '../utils/domain.js';

type TWebAuthnServer = Pick<
  typeof plugins.simplewebauthnServer,
  | 'generateRegistrationOptions'
  | 'verifyRegistrationResponse'
  | 'generateAuthenticationOptions'
  | 'verifyAuthenticationResponse'
>;

interface IPasskeyManagerOptions {
  developmentOrigin?: string;
  now?: () => number;
  webauthnServer?: TWebAuthnServer;
  bootstrapTokenFactory?: () => string;
}

interface IRegistrationCeremony {
  challenge: string;
  expiresAt: number;
  origin: string;
  rpId: string;
  webauthnUserId: string;
  bootstrapTokenHash: Buffer;
}

interface IAuthenticationCeremony {
  challenge: string;
  expiresAt: number;
  origin: string;
  rpId: string;
}

const bootstrapTtlMs = 30 * 60 * 1000;
const ceremonyTtlMs = 5 * 60 * 1000;
const setupAttemptWindowMs = 60 * 1000;
const maxSetupAttemptsPerWindow = 10;
const maxRegistrationCeremonies = 8;
const maxAuthenticationCeremonies = 128;

export class PasskeyManager {
  private readonly adminUsername = process.env.ONEBOX_ADMIN_USERNAME || 'admin';
  private readonly developmentOrigin?: string;
  private readonly now: () => number;
  private readonly webauthnServer: TWebAuthnServer;
  private readonly bootstrapTokenFactory: () => string;
  private bootstrapTokenHash: Buffer | null = null;
  private bootstrapExpiresAt = 0;
  private setupAttemptTimestamps: number[] = [];
  private registrationCeremonies = new Map<string, IRegistrationCeremony>();
  private authenticationCeremonies = new Map<string, IAuthenticationCeremony>();
  private registrationLock = Promise.resolve();

  constructor(
    private opsServerRef: OpsServer,
    optionsArg: IPasskeyManagerOptions = {},
  ) {
    this.developmentOrigin = optionsArg.developmentOrigin;
    this.now = optionsArg.now || (() => Date.now());
    this.webauthnServer = optionsArg.webauthnServer || plugins.simplewebauthnServer;
    this.bootstrapTokenFactory = optionsArg.bootstrapTokenFactory
      || (() => plugins.nodeCrypto.randomBytes(32).toString('base64url'));
  }

  public async initialize(): Promise<void> {
    const adminUser = await this.getAdminUser();
    if (adminUser.passkeyCredentials.length > 0) {
      return;
    }

    const bootstrapToken = this.bootstrapTokenFactory();
    this.bootstrapTokenHash = this.hashBootstrapToken(bootstrapToken);
    this.bootstrapExpiresAt = this.now() + bootstrapTtlMs;
    logger.warn('Onebox requires initial passkey setup.');
    logger.warn(`Onebox passkey bootstrap code (valid for 30 minutes): ${bootstrapToken}`);
    logger.warn('This code is accepted only for creating the first passkey and can never log in.');
  }

  public async getAuthState(
    originArg: string,
  ): Promise<interfaces.requests.IReq_GetAdminAuthState['response']> {
    const adminUser = await this.getAdminUser();
    const hasCredentials = adminUser.passkeyCredentials.length > 0;

    try {
      const relyingParty = hasCredentials
        ? this.getPinnedRelyingParty(adminUser)
        : await this.getConfiguredRelyingParty();
      if (originArg !== relyingParty.origin) {
        return {
          state: 'configurationRequired',
          bootstrapCodeExpired: !hasCredentials && this.isBootstrapExpired(),
          message: `Open Onebox at ${relyingParty.origin} to use its passkey.`,
        };
      }
    } catch (error) {
      return {
        state: 'configurationRequired',
        bootstrapCodeExpired: !hasCredentials && this.isBootstrapExpired(),
        message: error instanceof Error ? error.message : 'Passkey configuration is unavailable',
      };
    }

    return {
      state: hasCredentials ? 'ready' : 'setupRequired',
      bootstrapCodeExpired: !hasCredentials && this.isBootstrapExpired(),
    };
  }

  public async beginRegistration(
    originArg: string,
    bootstrapTokenArg: string,
  ): Promise<interfaces.requests.IReq_BeginAdminPasskeyRegistration['response']> {
    this.assertBootstrapToken(bootstrapTokenArg);
    this.assertSetupAttemptAllowed();

    const adminUser = await this.getAdminUser();
    if (adminUser.passkeyCredentials.length > 0) {
      throw new Error('Initial passkey setup is already complete');
    }

    const relyingParty = await this.getConfiguredRelyingParty();
    this.assertExpectedOrigin(originArg, relyingParty.origin);

    this.purgeExpiredCeremonies(this.registrationCeremonies);
    if (this.registrationCeremonies.size >= maxRegistrationCeremonies) {
      throw new Error('Too many passkey setup attempts are active; try again shortly');
    }

    const options = await this.webauthnServer.generateRegistrationOptions({
      rpName: 'Onebox',
      rpID: relyingParty.rpId,
      userID: Buffer.from(adminUser.webauthnUserId, 'base64url'),
      userName: adminUser.username,
      userDisplayName: 'Onebox Administrator',
      timeout: ceremonyTtlMs,
      attestationType: 'none',
      authenticatorSelection: {
        residentKey: 'required',
        requireResidentKey: true,
        userVerification: 'required',
      },
    });

    const ceremonyId = plugins.nodeCrypto.randomUUID();
    this.registrationCeremonies.set(ceremonyId, {
      challenge: options.challenge,
      expiresAt: this.now() + ceremonyTtlMs,
      origin: relyingParty.origin,
      rpId: relyingParty.rpId,
      webauthnUserId: adminUser.webauthnUserId,
      bootstrapTokenHash: Buffer.from(this.bootstrapTokenHash!),
    });

    return { ceremonyId, options };
  }

  public async finishRegistration(
    ceremonyIdArg: string,
    bootstrapTokenArg: string,
    responseArg: plugins.simplewebauthnServer.RegistrationResponseJSON,
  ): Promise<{ user: IUser & { id?: number }; credentialId: string }> {
    this.assertBootstrapToken(bootstrapTokenArg);
    this.assertSetupAttemptAllowed();
    const ceremony = this.consumeRegistrationCeremony(ceremonyIdArg);
    if (
      !this.bootstrapTokenHash
      || !plugins.nodeCrypto.timingSafeEqual(
        ceremony.bootstrapTokenHash,
        this.bootstrapTokenHash,
      )
    ) {
      throw new Error('Passkey bootstrap code no longer matches this setup request');
    }

    const verification = await this.webauthnServer.verifyRegistrationResponse({
      response: responseArg,
      expectedChallenge: ceremony.challenge,
      expectedOrigin: ceremony.origin,
      expectedRPID: ceremony.rpId,
      requireUserPresence: true,
      requireUserVerification: true,
    });
    if (!verification.verified) {
      throw new Error('Passkey registration could not be verified');
    }

    const { credential, credentialDeviceType, credentialBackedUp } =
      verification.registrationInfo;
    const now = this.now();
    const storedCredential: IPasskeyCredential = {
      credentialId: credential.id,
      publicKey: Buffer.from(credential.publicKey).toString('base64url'),
      counter: credential.counter,
      transports: (credential.transports ?? null) as IPasskeyCredential['transports'],
      deviceType: credentialDeviceType,
      backedUp: credentialBackedUp,
      rpId: ceremony.rpId,
      origin: ceremony.origin,
      createdAt: now,
      lastUsedAt: null,
    };

    return await this.withRegistrationLock(async () => {
      const adminUser = await this.getAdminUser();
      if (adminUser.passkeyCredentials.length > 0) {
        throw new Error('Initial passkey setup is already complete');
      }
      if (adminUser.webauthnUserId !== ceremony.webauthnUserId) {
        throw new Error('Passkey setup user identity changed');
      }

      const registered = await this.opsServerRef.oneboxRef.database.registerInitialPasskey(
        adminUser.username,
        adminUser.webauthnUserId,
        storedCredential,
      );
      if (!registered) {
        throw new Error('Another passkey completed initial setup first');
      }

      this.bootstrapTokenHash = null;
      this.bootstrapExpiresAt = 0;
      this.registrationCeremonies.clear();
      const updatedUser = await this.getAdminUser();
      return { user: updatedUser, credentialId: storedCredential.credentialId };
    });
  }

  public async beginAuthentication(
    originArg: string,
  ): Promise<interfaces.requests.IReq_BeginAdminPasskeyAuthentication['response']> {
    const adminUser = await this.getAdminUser();
    if (adminUser.passkeyCredentials.length === 0) {
      throw new Error('Onebox passkey setup is required');
    }

    const relyingParty = this.getPinnedRelyingParty(adminUser);
    this.assertExpectedOrigin(originArg, relyingParty.origin);
    this.purgeExpiredCeremonies(this.authenticationCeremonies);
    if (this.authenticationCeremonies.size >= maxAuthenticationCeremonies) {
      throw new Error('Too many passkey sign-in attempts are active; try again shortly');
    }

    const options = await this.webauthnServer.generateAuthenticationOptions({
      rpID: relyingParty.rpId,
      timeout: ceremonyTtlMs,
      userVerification: 'required',
    });
    const ceremonyId = plugins.nodeCrypto.randomUUID();
    this.authenticationCeremonies.set(ceremonyId, {
      challenge: options.challenge,
      expiresAt: this.now() + ceremonyTtlMs,
      origin: relyingParty.origin,
      rpId: relyingParty.rpId,
    });
    return { ceremonyId, options };
  }

  public async finishAuthentication(
    ceremonyIdArg: string,
    responseArg: plugins.simplewebauthnServer.AuthenticationResponseJSON,
  ): Promise<{ user: IUser & { id?: number }; credentialId: string }> {
    const ceremony = this.consumeAuthenticationCeremony(ceremonyIdArg);
    const stored = await this.opsServerRef.oneboxRef.database.getAdminPasskeyCredentialById(
      this.adminUsername,
      responseArg.id,
    );
    if (!stored) {
      throw new Error('Passkey is not registered with this Onebox');
    }
    if (
      stored.credential.origin !== ceremony.origin
      || stored.credential.rpId !== ceremony.rpId
    ) {
      throw new Error('Passkey relying party does not match this sign-in request');
    }
    if (responseArg.response.userHandle !== stored.user.webauthnUserId) {
      throw new Error('Passkey user handle does not match the Onebox administrator');
    }

    const verification = await this.webauthnServer.verifyAuthenticationResponse({
      response: responseArg,
      expectedChallenge: ceremony.challenge,
      expectedOrigin: ceremony.origin,
      expectedRPID: ceremony.rpId,
      credential: {
        id: stored.credential.credentialId,
        publicKey: Buffer.from(stored.credential.publicKey, 'base64url'),
        counter: stored.credential.counter,
        transports: stored.credential.transports ?? undefined,
      },
      requireUserVerification: true,
    });
    if (!verification.verified) {
      throw new Error('Passkey authentication could not be verified');
    }

    const counterUpdated = await this.opsServerRef.oneboxRef.database.updatePasskeyCounter(
      stored.user.username,
      stored.credential.credentialId,
      stored.credential.counter,
      verification.authenticationInfo.newCounter,
    );
    if (!counterUpdated) {
      throw new Error('Passkey counter changed during authentication');
    }

    const freshUser = await this.opsServerRef.oneboxRef.database.getUserByUsername(
      stored.user.username,
    );
    if (!freshUser) {
      throw new Error('Onebox administrator no longer exists');
    }
    return { user: freshUser, credentialId: stored.credential.credentialId };
  }

  public async isCredentialActive(
    usernameArg: string,
    credentialIdArg: string,
  ): Promise<boolean> {
    if (usernameArg !== this.adminUsername) {
      return false;
    }
    const stored = await this.opsServerRef.oneboxRef.database.getAdminPasskeyCredentialById(
      this.adminUsername,
      credentialIdArg,
    );
    return Boolean(stored);
  }


  private async getAdminUser() {
    const adminUser = await this.opsServerRef.oneboxRef.database.getUserByUsername(
      this.adminUsername,
    );
    if (
      !adminUser
      || adminUser.role !== 'admin'
      || !adminUser.webauthnUserId
      || !Array.isArray(adminUser.passkeyCredentials)
    ) {
      throw new Error('Onebox passkey administrator is not initialized');
    }
    return adminUser;
  }

  private async getConfiguredRelyingParty(): Promise<{ origin: string; rpId: string }> {
    if (this.developmentOrigin) {
      return { origin: this.developmentOrigin, rpId: 'localhost' };
    }
    const configuredDomain = (
      await this.opsServerRef.oneboxRef.database.getSetting('adminUiDomain')
    )?.trim();
    if (!configuredDomain) {
      throw new Error(
        'Set ONEBOX_ADMIN_UI_DOMAIN or run onebox config set adminUiDomain <hostname> before setup.',
      );
    }
    const domain = normalizePasskeyHostname(configuredDomain);
    if (!domain) {
      throw new Error('adminUiDomain must be a hostname served over HTTPS on port 443');
    }
    return { origin: `https://${domain}`, rpId: domain };
  }

  private getPinnedRelyingParty(userArg: IUser): { origin: string; rpId: string } {
    const firstCredential = userArg.passkeyCredentials[0];
    if (!firstCredential) {
      throw new Error('Onebox has no registered passkey');
    }
    for (const credential of userArg.passkeyCredentials) {
      if (
        credential.origin !== firstCredential.origin
        || credential.rpId !== firstCredential.rpId
      ) {
        throw new Error('Onebox passkey relying-party data is inconsistent');
      }
    }
    return { origin: firstCredential.origin, rpId: firstCredential.rpId };
  }

  private assertExpectedOrigin(originArg: string, expectedOriginArg: string): void {
    if (originArg !== expectedOriginArg) {
      throw new Error(`Passkey ceremony must run at ${expectedOriginArg}`);
    }
  }

  private hashBootstrapToken(tokenArg: string): Buffer {
    return plugins.nodeCrypto.createHash('sha256').update(tokenArg, 'utf8').digest();
  }

  private assertBootstrapToken(tokenArg: string): void {
    if (!this.bootstrapTokenHash || this.isBootstrapExpired()) {
      throw new Error(
        'Passkey bootstrap code expired; restart Onebox to rotate the local setup code',
      );
    }
    const suppliedHash = this.hashBootstrapToken(tokenArg);
    if (!plugins.nodeCrypto.timingSafeEqual(suppliedHash, this.bootstrapTokenHash)) {
      throw new Error('Passkey bootstrap code is invalid');
    }
  }

  private isBootstrapExpired(): boolean {
    return !this.bootstrapTokenHash || this.bootstrapExpiresAt <= this.now();
  }

  private assertSetupAttemptAllowed(): void {
    const cutoff = this.now() - setupAttemptWindowMs;
    this.setupAttemptTimestamps = this.setupAttemptTimestamps.filter(
      (timestampArg) => timestampArg > cutoff,
    );
    if (this.setupAttemptTimestamps.length >= maxSetupAttemptsPerWindow) {
      throw new Error('Too many passkey setup attempts; wait one minute and try again');
    }
    this.setupAttemptTimestamps.push(this.now());
  }

  private purgeExpiredCeremonies<T extends { expiresAt: number }>(
    ceremoniesArg: Map<string, T>,
  ): void {
    const now = this.now();
    for (const [ceremonyId, ceremony] of ceremoniesArg) {
      if (ceremony.expiresAt <= now) {
        ceremoniesArg.delete(ceremonyId);
      }
    }
  }

  private consumeRegistrationCeremony(ceremonyIdArg: string): IRegistrationCeremony {
    const ceremony = this.registrationCeremonies.get(ceremonyIdArg);
    this.registrationCeremonies.delete(ceremonyIdArg);
    if (!ceremony || ceremony.expiresAt <= this.now()) {
      throw new Error('Passkey setup request expired or was already used');
    }
    return ceremony;
  }

  private consumeAuthenticationCeremony(ceremonyIdArg: string): IAuthenticationCeremony {
    const ceremony = this.authenticationCeremonies.get(ceremonyIdArg);
    this.authenticationCeremonies.delete(ceremonyIdArg);
    if (!ceremony || ceremony.expiresAt <= this.now()) {
      throw new Error('Passkey sign-in request expired or was already used');
    }
    return ceremony;
  }

  private async withRegistrationLock<T>(taskArg: () => Promise<T>): Promise<T> {
    const previousLock = this.registrationLock;
    let releaseLock!: () => void;
    this.registrationLock = new Promise<void>((resolve) => {
      releaseLock = resolve;
    });
    await previousLock;
    try {
      return await taskArg();
    } finally {
      releaseLock();
    }
  }
}
