import { PassThrough } from 'stream';
import csv from 'csv-parser';
import db from '@/db/models';
import UsersDBApi from '@/db/api/users';
import OrganizationsDBApi from '@/db/api/organizations';
import ValidationError from '@/shared/errors/validation';
import AuthService from '@/services/auth';
import { assertCanManageUserWithRole } from '@/services/shared/role-policy';
import { getOrganizationId, hasGlobalAccess } from '@/services/shared/access';
import { ROLE_NAMES } from '@/shared/constants/roles';
import type { AuthenticatedUser, CurrentUser } from '@/db/api/types';

/**
 * A non-global actor may only manage users in its own organization. Cross-tenant
 * targets are reported as not-found so existence is not leaked.
 */
function assertSameTenant(
  currentUser: CurrentUser | undefined,
  target: AuthenticatedUser,
): void {
  if (hasGlobalAccess(currentUser)) return;
  const actorOrg = getOrganizationId(currentUser);
  if (!actorOrg || actorOrg !== target.organizationId) {
    throw new ValidationError('iam.errors.userNotFound');
  }
}

type CreateData = Parameters<typeof UsersDBApi.create>[0]['data'];
type UpdateData = Parameters<typeof UsersDBApi.update>[1];
type ListFilter = Parameters<typeof UsersDBApi.findAll>[0];
type BulkRow = Parameters<typeof UsersDBApi.bulkImport>[0][number];

/** Parses an uploaded CSV buffer into bulk-import rows. */
function parseCsvRows(fileBuffer: Buffer): Promise<BulkRow[]> {
  const bufferStream = new PassThrough();
  const results: BulkRow[] = [];
  bufferStream.end(Buffer.from(fileBuffer));

  return new Promise((resolve, reject) => {
    bufferStream
      .pipe(csv())
      .on('data', (row: BulkRow) => results.push(row))
      .on('end', () => resolve(results))
      .on('error', reject);
  });
}

class UsersService {
  static async create(
    data: CreateData,
    currentUser?: CurrentUser,
    sendInvitationEmails = true,
    host?: string,
  ) {
    const transaction = await db.sequelize.transaction();
    const globalAccess = currentUser?.app_role?.globalAccess ?? false;

    const email = data.email;
    const emailsToInvite: string[] = [];

    try {
      if (email) {
        const user = await UsersDBApi.findBy({ email }, { transaction });
        if (user) {
          throw new ValidationError('iam.errors.userAlreadyExists');
        }

        // Relational policy: the actor must be allowed to assign this role.
        if (data.app_role) {
          const newRole = await db.roles.findByPk(data.app_role, {
            transaction,
          });
          assertCanManageUserWithRole(currentUser, newRole?.name ?? null);

          // §3.4 provisioning: creating an `owner` auto-creates the company and
          // links the owner to it. The org starts minimal; the owner fills it in.
          if (newRole?.name === ROLE_NAMES.OWNER && !data.organizations) {
            const organization = await OrganizationsDBApi.create(
              {},
              { currentUser, transaction },
            );
            data.organizations = organization.id;
          }
        }

        // Non-global actors create users only within their own organization.
        if (!globalAccess && !data.organizations) {
          const actorOrg = getOrganizationId(currentUser);
          if (actorOrg) data.organizations = actorOrg;
        }

        await UsersDBApi.create({ data }, globalAccess, {
          currentUser,
          transaction,
        });
        emailsToInvite.push(email);
      } else {
        throw new ValidationError('iam.errors.emailRequired');
      }
      await transaction.commit();
    } catch (error) {
      await transaction.rollback();
      throw error;
    }

    if (emailsToInvite.length) {
      if (!sendInvitationEmails) return;

      AuthService.sendPasswordResetEmail(emailsToInvite[0], 'invitation', host);
    }
  }

  static async bulkImport(
    fileBuffer: Buffer,
    currentUser?: CurrentUser,
    sendInvitationEmails = true,
    host?: string,
  ) {
    const rows = await parseCsvRows(fileBuffer);
    const emailsToInvite: string[] = [];
    const transaction = await db.sequelize.transaction();

    try {
      const hasAllEmails = rows.every((row) => row.email);

      if (!hasAllEmails) {
        throw new ValidationError('importer.errors.userEmailMissing');
      }

      await UsersDBApi.bulkImport(rows, {
        transaction,
        ignoreDuplicates: true,
        validate: true,
        currentUser,
      });

      for (const row of rows) {
        if (row.email) emailsToInvite.push(row.email);
      }

      await transaction.commit();
    } catch (error) {
      await transaction.rollback();
      throw error;
    }

    if (emailsToInvite.length && !sendInvitationEmails) {
      emailsToInvite.forEach((email) => {
        AuthService.sendPasswordResetEmail(email, 'invitation', host);
      });
    }
  }

  static async update(data: UpdateData, id: string, currentUser?: CurrentUser) {
    const transaction = await db.sequelize.transaction();
    const globalAccess = currentUser?.app_role?.globalAccess ?? false;

    try {
      const users = await UsersDBApi.findBy({ id }, { transaction });

      if (!users) {
        throw new ValidationError('iam.errors.userNotFound');
      }

      // Tenant + relational policy: the target must be in the actor's org, and
      // the actor must be allowed to manage the target's current role (and the
      // new role if it is being reassigned).
      assertSameTenant(currentUser, users);
      assertCanManageUserWithRole(currentUser, users.app_role?.name ?? null);
      if (data.app_role) {
        const newRole = await db.roles.findByPk(data.app_role, { transaction });
        assertCanManageUserWithRole(currentUser, newRole?.name ?? null);
      }

      const updatedUser = await UsersDBApi.update(id, data, globalAccess, {
        currentUser,
        transaction,
      });

      await transaction.commit();
      return updatedUser;
    } catch (error) {
      await transaction.rollback();
      throw error;
    }
  }

  static async remove(id: string, currentUser?: CurrentUser) {
    const transaction = await db.sequelize.transaction();

    try {
      if (currentUser?.id === id) {
        throw new ValidationError('iam.errors.deletingHimself');
      }

      const target = await UsersDBApi.findBy({ id }, { transaction });
      if (!target) {
        throw new ValidationError('iam.errors.userNotFound');
      }

      // Tenant + relational policy: the target must be in the actor's org, and
      // the actor must be allowed to delete the target's role (e.g. a
      // superintendent cannot delete an owner or another superintendent).
      assertSameTenant(currentUser, target);
      assertCanManageUserWithRole(currentUser, target.app_role?.name ?? null);

      await UsersDBApi.remove(id, { currentUser, transaction });

      await transaction.commit();
    } catch (error) {
      await transaction.rollback();
      throw error;
    }
  }

  static async deleteByIds(ids: string[], currentUser?: CurrentUser) {
    const transaction = await db.sequelize.transaction();
    try {
      if (currentUser?.id && ids.includes(currentUser.id)) {
        throw new ValidationError('iam.errors.deletingHimself');
      }

      for (const id of ids) {
        const target = await UsersDBApi.findBy({ id }, { transaction });
        if (target) {
          assertSameTenant(currentUser, target);
          assertCanManageUserWithRole(
            currentUser,
            target.app_role?.name ?? null,
          );
        }
      }

      await UsersDBApi.deleteByIds(ids, { currentUser, transaction });
      await transaction.commit();
    } catch (error) {
      await transaction.rollback();
      throw error;
    }
  }

  static list(
    filter: ListFilter,
    globalAccess: boolean,
    currentUser?: CurrentUser,
  ) {
    return UsersDBApi.findAll(filter, globalAccess, { currentUser });
  }

  static count(
    filter: ListFilter,
    globalAccess: boolean,
    currentUser?: CurrentUser,
  ) {
    return UsersDBApi.findAll(filter, globalAccess, {
      countOnly: true,
      currentUser,
    });
  }

  static autocomplete(
    query: string | undefined,
    limit: number | undefined,
    offset: number | undefined,
    globalAccess: boolean,
    organizationId?: string,
  ) {
    return UsersDBApi.findAllAutocomplete(
      query,
      limit,
      offset,
      globalAccess,
      organizationId,
    );
  }

  static findById(id: string) {
    return UsersDBApi.findBy({ id });
  }
}

export default UsersService;
