import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import {
  fullModuleAccess,
  isModuleName,
  MODULE_ACCESS_LEVELS,
  type ModuleAccessLevel,
  type ModuleName,
  noModuleAccess,
} from '../../common/access/modules';
import { ModuleAccessRepository } from '../../common/access/module-access.repository';
import type { AuthenticatedUser } from '../../common/auth/authenticated-user';
import { hashPassword } from '../../common/auth/password';
import { AuthSessionsService } from '../auth/auth-sessions.service';
import type { CreateUserDto } from './dto/create-user.dto';
import type { UpdateUserDto } from './dto/update-user.dto';
import {
  type ManagedUser,
  type ManagedUserPatch,
  type ManagedUserWithAccess,
  UsersRepository,
} from './users.repository';

/** The pre-auth placeholder owner — never editable through the admin UI. */
const SENTINEL_USER_ID = '00000000-0000-0000-0000-000000000001';

@Injectable()
export class UsersService {
  constructor(
    private readonly repository: UsersRepository,
    private readonly sessions: AuthSessionsService,
    private readonly moduleAccess: ModuleAccessRepository,
  ) {}

  async list(): Promise<Array<ManagedUserWithAccess & { stateRows: number }>> {
    const managed = await this.repository.list();
    const access = await this.moduleAccess.forUsers(managed.map((user) => user.id));
    return Promise.all(
      managed.map(async (user) => ({
        ...user,
        // Administrators hold everything by role; showing stored rows for them
        // would suggest the checkboxes mean something they do not.
        modules: user.role === 'ADMIN' ? fullModuleAccess() : (access.get(user.id) ?? noModuleAccess()),
        stateRows: await this.repository.countStateRows(user.id),
      })),
    );
  }

  /**
   * Grant or revoke modules for one account. Partial by design — the cockpit
   * sends the single toggle that changed, not the whole map.
   */
  async setModuleAccess(
    id: string,
    changes: Partial<Record<ModuleName, ModuleAccessLevel>>,
    actor: AuthenticatedUser,
  ): Promise<ManagedUserWithAccess> {
    const target = await this.repository.findById(id);
    if (!target) throw new NotFoundException('User not found.');
    if (target.id === SENTINEL_USER_ID) {
      throw new BadRequestException('The legacy default account cannot be modified.');
    }
    if (target.id === actor.id) {
      throw new BadRequestException('You cannot change your own module access.');
    }
    if (target.role === 'ADMIN') {
      throw new BadRequestException(
        'Administrators always hold every module. Change the role instead.',
      );
    }

    const entries = Object.entries(changes);
    if (!entries.length) {
      throw new BadRequestException('No changes supplied.');
    }
    for (const [module, level] of entries) {
      if (!isModuleName(module)) {
        throw new BadRequestException(`Unknown module: ${module}`);
      }
      if (!MODULE_ACCESS_LEVELS.includes(level)) {
        throw new BadRequestException(`Unknown access level: ${String(level)}`);
      }
    }

    await this.moduleAccess.set(id, changes);
    return { ...target, modules: await this.moduleAccess.forUser(id) };
  }

  async create(input: CreateUserDto): Promise<ManagedUser> {
    const email = input.email.trim().toLowerCase();
    if (await this.repository.findByEmail(email)) {
      throw new ConflictException('An account with this email already exists.');
    }

    try {
      return await this.repository.create({
        email,
        passwordHash: await hashPassword(input.password),
        displayName: input.displayName?.trim() || null,
        role: input.role ?? 'USER',
      });
    } catch (error: unknown) {
      if (isUniqueViolation(error)) {
        throw new ConflictException('An account with this email already exists.');
      }
      throw error;
    }
  }

  async update(id: string, input: UpdateUserDto, actor: AuthenticatedUser): Promise<ManagedUser> {
    const target = await this.repository.findById(id);
    if (!target) throw new NotFoundException('User not found.');
    if (target.id === SENTINEL_USER_ID) {
      throw new BadRequestException('The legacy default account cannot be modified.');
    }

    const losesAdmin =
      (input.role !== undefined && input.role !== 'ADMIN' && target.role === 'ADMIN') ||
      (input.isActive === false && target.role === 'ADMIN');

    // Refuse any change that would leave nobody able to administer the system.
    if (losesAdmin && (await this.repository.countOtherActiveAdmins(target.id)) === 0) {
      throw new BadRequestException(
        'This is the last active administrator. Promote another administrator first.',
      );
    }

    // Self-lockout guard: an admin editing their own row cannot drop their own
    // access, even when other admins exist.
    if (target.id === actor.id) {
      if (input.role !== undefined && input.role !== target.role) {
        throw new BadRequestException('You cannot change your own role.');
      }
      if (input.isActive === false) {
        throw new BadRequestException('You cannot deactivate your own account.');
      }
    }

    const patch: ManagedUserPatch = {};
    if (input.displayName !== undefined) patch.displayName = input.displayName.trim() || null;
    if (input.role !== undefined) patch.role = input.role;
    if (input.isActive !== undefined) patch.isActive = input.isActive;
    if (input.password !== undefined) patch.passwordHash = await hashPassword(input.password);

    if (!Object.keys(patch).length) {
      throw new BadRequestException('No changes supplied.');
    }

    const updated = await this.repository.update(id, patch);

    // A new password or a deactivation must not leave old sessions usable.
    // This includes your own: changing your password logs you out everywhere,
    // which is the safe default when a password may have been compromised.
    if (patch.passwordHash !== undefined || patch.isActive === false) {
      await this.sessions.revokeAllForUser(id);
    }

    return updated;
  }
}

function isUniqueViolation(error: unknown): boolean {
  return typeof error === 'object' && error !== null && 'code' in error && error.code === '23505';
}
