/* eslint-disable max-lines */
import crypto from 'node:crypto';
import { type Kysely } from 'kysely';

import type { Dependencies } from '../../iocContainer/index.js';
import { inject } from '../../iocContainer/utils.js';
import {
  makeNotFoundError,
  makeUnauthorizedError,
} from '../../utils/errors.js';
import { makeKyselyTransactionWithRetry } from '../../utils/kyselyTransactionWithRetry.js';
import { asyncRandomBytes } from '../../utils/misc.js';
import { HOUR_MS } from '../../utils/time.js';
import { CoopEmailAddress } from '../sendEmailService/sendEmailService.js';
import type { MrtChartConfig } from './dbTypes.js';
import type { UserManagementPg } from './index.js';
import {
  UserPermission,
  type Invoker,
  type UserRole,
} from './permissioning.js';
import { deleteSessionsForUser } from './sessionPersistence.js';
import { hashPassword } from './utils.js';

class UserManagementService {
  constructor(
    private readonly pgQuery: Kysely<UserManagementPg>,
    private readonly sendEmail: Dependencies['sendEmail'],
    private readonly configService: Dependencies['ConfigService'],
  ) {}

  // Returns null for orgs without seeded role rows; the read path falls
  // back to UserPermissionsForRole defaults.
  async #lookupSystemRoleId(opts: {
    orgId: string;
    role: UserRole;
  }): Promise<string | null> {
    const row = await this.pgQuery
      .selectFrom('public.roles')
      .select('id')
      .where('org_id', '=', opts.orgId)
      .where('key', '=', opts.role)
      .where('is_system', '=', true)
      .executeTakeFirst();
    return row?.id ?? null;
  }

  async getUserInterfaceSettings(opts: { userId: string; orgId: string }) {
    const { userId, orgId } = opts;
    const row = await this.pgQuery
      .selectFrom('user_management_service.user_interface_settings')
      .selectAll()
      .where('user_id', '=', userId)
      .executeTakeFirst();

    if (
      row &&
      row.moderator_safety_grayscale != null &&
      row.moderator_safety_blur_level != null &&
      row.moderator_safety_mute_video != null &&
      row.moderator_safety_sepia != null
    ) {
      // If all the user's settings have been set, just return them
      return {
        moderatorSafetyGrayscale: row.moderator_safety_grayscale,
        moderatorSafetyBlurLevel: row.moderator_safety_blur_level,
        moderatorSafetyMuteVideo: row.moderator_safety_mute_video,
        moderatorSafetySepia: row.moderator_safety_sepia,
        mrtChartConfigurations: row.mrt_chart_configurations ?? [],
      };
    }

    // Otherwise, merge the user's settings with the org's default settings,
    // prioritizing the user's settings over the defaults.
    const orgDefaults = await this.getOrgDefaultUserInterfaceSettings(orgId);
    return {
      moderatorSafetyGrayscale:
        row?.moderator_safety_grayscale ?? orgDefaults.moderatorSafetyGrayscale,
      moderatorSafetySepia:
        row?.moderator_safety_sepia ?? orgDefaults.moderatorSafetySepia,
      moderatorSafetyBlurLevel:
        row?.moderator_safety_blur_level ??
        orgDefaults.moderatorSafetyBlurLevel,
      moderatorSafetyMuteVideo:
        row?.moderator_safety_mute_video ??
        orgDefaults.moderatorSafetyMuteVideo,
      mrtChartConfigurations: row?.mrt_chart_configurations ?? [],
    };
  }

  async getInviteUserToken(opts: { token: string }) {
    const { token } = opts;
    const tokenRow = await this.pgQuery
      .selectFrom('public.invite_user_tokens')
      .selectAll()
      .where('token', '=', token)
      .executeTakeFirst();

    if (tokenRow == null) {
      return null;
    }

    const orgSettings = await this.pgQuery
      .selectFrom('public.org_settings')
      .select('saml_enabled')
      .where('org_id', '=', tokenRow.org_id)
      .executeTakeFirst();

    return {
      ...tokenRow,
      orgId: tokenRow.org_id,
      createdAt: tokenRow.created_at,
      samlEnabled: orgSettings?.saml_enabled ?? false,
    };
  }

  async createInviteUserToken(opts: {
    email: string;
    role: UserRole;
    orgId: string;
  }) {
    const { email, role, orgId } = opts;

    const roleId = await this.#lookupSystemRoleId({ orgId, role });

    const token = (await asyncRandomBytes(32)).toString('hex');
    await this.pgQuery
      .insertInto('public.invite_user_tokens')
      .values({ token, email, role, role_id: roleId, org_id: orgId })
      .execute();
    return token;
  }

  async getPendingInvites(
    orgId: string,
  ): Promise<
    Array<{ id: string; email: string; role: UserRole; createdAt: string }>
  > {
    const result = await this.pgQuery
      .selectFrom('public.invite_user_tokens')
      .selectAll()
      .where('org_id', '=', orgId)
      .orderBy('created_at', 'desc')
      .execute();

    return result.map((row) => ({
      id: row.id,
      email: row.email,
      role: row.role,
      createdAt: row.created_at.toISOString(),
    }));
  }

  async deleteInvite(inviteId: string, orgId: string): Promise<boolean> {
    const result = await this.pgQuery
      .deleteFrom('public.invite_user_tokens')
      .where('id', '=', inviteId)
      .where('org_id', '=', orgId)
      .execute();

    return result.length > 0;
  }

  async upsertUserInterfaceSettings(input: {
    userId: string;
    userInterfaceSettings: {
      moderatorSafetySettings?: {
        moderatorSafetyMuteVideo: boolean;
        moderatorSafetyGrayscale: boolean;
        moderatorSafetyBlurLevel: number;
        moderatorSafetySepia: boolean;
      };
      mrtChartConfigurations?: readonly MrtChartConfig[];
    };
  }) {
    const { userId, userInterfaceSettings } = input;
    const { moderatorSafetySettings, mrtChartConfigurations } =
      userInterfaceSettings;

    const dbFormattedInterfaceSettings = {
      ...(moderatorSafetySettings
        ? {
            moderator_safety_grayscale:
              moderatorSafetySettings.moderatorSafetyGrayscale,
            moderator_safety_sepia:
              moderatorSafetySettings.moderatorSafetySepia,
            moderator_safety_blur_level:
              moderatorSafetySettings.moderatorSafetyBlurLevel,
            moderator_safety_mute_video:
              moderatorSafetySettings.moderatorSafetyMuteVideo,
          }
        : {}),
      ...(mrtChartConfigurations
        ? {
            mrt_chart_configurations: [...mrtChartConfigurations],
          }
        : {}),
    };

    let query = this.pgQuery
      .insertInto('user_management_service.user_interface_settings')
      .values({
        user_id: userId,
        ...dbFormattedInterfaceSettings,
      });

    // Only add onConflict if there are fields to update
    if (Object.keys(dbFormattedInterfaceSettings).length > 0) {
      query = query.onConflict((oc) =>
        oc.column('user_id').doUpdateSet({
          ...dbFormattedInterfaceSettings,
        }),
      );
    } else {
      // If no update fields, just do nothing on conflict
      query = query.onConflict((oc) => oc.column('user_id').doNothing());
    }

    return query.returningAll().execute();
  }

  async getOrgDefaultUserInterfaceSettings(orgId: string) {
    const row = await this.pgQuery
      .selectFrom('user_management_service.org_default_user_interface_settings')
      .selectAll()
      .where('org_id', '=', orgId)
      // Every org should have a default interface settings row
      .executeTakeFirstOrThrow();

    return {
      moderatorSafetyGrayscale: row.moderator_safety_grayscale,
      moderatorSafetySepia: row.moderator_safety_sepia,
      moderatorSafetyBlurLevel: row.moderator_safety_blur_level,
      moderatorSafetyMuteVideo: row.moderator_safety_mute_video,
    };
  }

  async upsertOrgDefaultUserInterfaceSettings(opts: {
    orgId: string;
    // If you don't provide these values, they will be set to the default values
    // configured on the pg table definition
    moderatorSafetyGrayscale?: boolean;
    moderatorSafetySepia?: boolean;
    moderatorSafetyBlurLevel?: number;
    moderatorSafetyMuteVideo?: boolean;
  }) {
    const {
      orgId,
      moderatorSafetyGrayscale,
      moderatorSafetySepia,
      moderatorSafetyBlurLevel,
      moderatorSafetyMuteVideo,
    } = opts;
    const updateFields = {
      ...(moderatorSafetyGrayscale !== undefined
        ? { moderator_safety_grayscale: moderatorSafetyGrayscale }
        : {}),
      ...(moderatorSafetySepia !== undefined
        ? { moderator_safety_sepia: moderatorSafetySepia }
        : {}),
      ...(moderatorSafetyBlurLevel !== undefined
        ? { moderator_safety_blur_level: moderatorSafetyBlurLevel }
        : {}),
      ...(moderatorSafetyMuteVideo !== undefined
        ? { moderator_safety_mute_video: moderatorSafetyMuteVideo }
        : {}),
    };

    let query = this.pgQuery
      .insertInto('user_management_service.org_default_user_interface_settings')
      .values([
        {
          org_id: orgId,
          moderator_safety_grayscale: moderatorSafetyGrayscale,
          moderator_safety_sepia: moderatorSafetySepia,
          moderator_safety_blur_level: moderatorSafetyBlurLevel,
          moderator_safety_mute_video: moderatorSafetyMuteVideo,
        },
      ]);

    // Only add onConflict if there are fields to update
    if (Object.keys(updateFields).length > 0) {
      query = query.onConflict((oc) =>
        // Explicitly check for undefined because these values are booleans and
        // numbers, so they can be falsey
        oc.column('org_id').doUpdateSet(updateFields),
      );
    } else {
      // If no update fields, just do nothing on conflict
      query = query.onConflict((oc) => oc.column('org_id').doNothing());
    }

    await query.execute();
  }

  async updateUserRole(input: {
    userId: string;
    newRole: UserRole;
    orgId: string;
    invoker: Invoker;
  }): Promise<void> {
    const { userId, newRole, orgId, invoker } = input;
    if (orgId !== invoker.orgId) {
      throw makeUnauthorizedError(
        'User does not have permission to change roles in another org',
        { shouldErrorSpan: true },
      );
    }

    if (!invoker.permissions.includes(UserPermission.MANAGE_USERS)) {
      throw makeUnauthorizedError(
        'User does not have permission to change roles',
        { shouldErrorSpan: true },
      );
    }

    const newRoleId = await this.#lookupSystemRoleId({
      orgId: invoker.orgId,
      role: newRole,
    });

    const result = await this.pgQuery
      .updateTable('public.users')
      .set({ role: newRole, role_id: newRoleId })
      .where('id', '=', userId)
      .where('org_id', '=', invoker.orgId)
      .executeTakeFirst();

    if (result.numUpdatedRows === 0n) {
      throw makeNotFoundError('User not found', { shouldErrorSpan: true });
    }
  }

  /**
   * NB: this function is a no-op if the email does not exist in the database.
   * However, in that case, we do return earlier than if the email had been
   * found, so callers should NOT await this function, and return to the end
   * user before the call completes in order to prevent timing attacks.
   */
  async sendPasswordResetEmail(opts: { email: string }) {
    const { email } = opts;

    const existingUser = await this.pgQuery
      .selectFrom('public.users')
      .select(['id as userId', 'org_id as orgId'])
      .where('email', '=', email)
      .executeTakeFirst();

    if (existingUser == null) {
      return;
    }

    const { userId, orgId } = existingUser;
    const token = await this.#createPasswordResetToken({ userId, orgId });

    const url = new URL(`${this.configService.uiUrl}/reset_password/` + token);
    const msg = {
      to: email,
      from: CoopEmailAddress.NoReply,
      subject: '[Coop] Reset your password',
      html: `You recently indicated that you forgot your Coop password. Click on <a href='${url.href}'>this link</a> to create a new password. The link expires in 1 hour, so please make sure to sign up soon.
      <br /><br />
      Best,<br />
      Coop Support Team`,
    };

    await this.sendEmail(msg);
  }

  /**
   * Generate a password reset token for a specific user (for admin use).
   * Returns the raw token that can be shared with the user.
   * Also sends an email to the user using the standard password reset flow.
   */
  async generatePasswordResetTokenForUser(opts: {
    userId: string;
    invokerOrgId: string;
  }) {
    const { userId, invokerOrgId } = opts;

    const existingUser = await this.pgQuery
      .selectFrom('public.users')
      .select(['email', 'org_id as orgId'])
      .where('id', '=', userId)
      .executeTakeFirst();

    if (existingUser == null) {
      throw makeNotFoundError('User not found', { shouldErrorSpan: true });
    }

    // Security check: ensure admin can only reset passwords for users in their own org
    if (existingUser.orgId !== invokerOrgId) {
      throw makeUnauthorizedError(
        'You can only reset passwords for users in your organization',
        { shouldErrorSpan: true },
      );
    }

    const { email } = existingUser;

    const token = await this.#createPasswordResetToken({
      userId,
      orgId: existingUser.orgId,
    });

    // Send email using the standard flow (will be no-op if SendGrid not configured)
    const url = new URL(`${this.configService.uiUrl}/reset_password/` + token);
    const msg = {
      to: email,
      from: CoopEmailAddress.NoReply,
      subject: '[Coop] Reset your password',
      html: `Your organization administrator has initiated a password reset for your account. Click on <a href='${url.href}'>this link</a> to create a new password. The link expires in 1 hour, so please make sure to reset your password soon.
      <br /><br />
      Best,<br />
      Coop Support Team`,
    };

    await this.sendEmail(msg);

    return token;
  }

  async #createPasswordResetToken(opts: { userId: string; orgId: string }) {
    const { userId, orgId } = opts;
    const token = (await asyncRandomBytes(32)).toString('hex');
    const hashedToken = crypto.createHash('sha256').update(token).digest('hex');

    // delete tokens for the user
    await this.pgQuery
      .deleteFrom('user_management_service.password_reset_tokens')
      .where('user_id', '=', userId)
      .execute();

    // create new token
    await this.pgQuery
      .insertInto('user_management_service.password_reset_tokens')
      .values({
        user_id: userId,
        org_id: orgId,
        hashed_token: hashedToken,
        created_at: new Date(),
      })
      .execute();

    return token;
  }

  /**
   * NB: we use a hashed token instead of comparing the token directly to
   * prevent timing attacks (by 'removing' any attempts at direct manipulation
   * of the input via the hash function). If we were not hashing the token, we
   * would be vulnerable to timing attacks because an attacker could measure the
   * time to compare the token and use that information to iteratively guess the
   * token.
   */
  async resetPasswordForToken(opts: { token: string; newPassword: string }) {
    const { token, newPassword } = opts;

    const hashedToken = crypto.createHash('sha256').update(token).digest('hex');

    // Step 1: Validate token
    const fetchedToken = await this.pgQuery
      .selectFrom('user_management_service.password_reset_tokens')
      .selectAll()
      .where('hashed_token', '=', hashedToken)
      .executeTakeFirst();

    if (fetchedToken == null) {
      return;
    }

    // NB: Tokens expire one hour after creation
    if (Date.now() - new Date(fetchedToken.created_at).getTime() > HOUR_MS) {
      return;
    }

    const hashedPassword = await hashPassword(newPassword);
    // Atomic: a committed password change must not leave the user's sessions
    // or reset tokens alive.
    await makeKyselyTransactionWithRetry(this.pgQuery)(async (trx) => {
      await trx
        .updateTable('public.users')
        .set({ password: hashedPassword })
        .where('id', '=', fetchedToken.user_id)
        .execute();
      await deleteSessionsForUser(trx, fetchedToken.user_id);
      await trx
        .deleteFrom('user_management_service.password_reset_tokens')
        .where('user_id', '=', fetchedToken.user_id)
        .execute();
    });
  }

  async getUsersForOrg(orgId: string) {
    return this.pgQuery
      .selectFrom('public.users')
      .select([
        'id',
        'email',
        'first_name as firstName',
        'last_name as lastName',
        'role',
      ])
      .where('org_id', '=', orgId)
      .where('rejected_by_admin', '=', false)
      .where('approved_by_admin', '=', true)
      .execute();
  }
}

export default inject(
  ['KyselyPg', 'sendEmail', 'ConfigService'],
  UserManagementService,
);
export { type UserManagementService };
