import { Injectable } from '@nestjs/common';
import * as argon2 from 'argon2';
import { and, eq, ilike, isNull, or, sql, desc, inArray } from 'drizzle-orm';
import { CreateUserDto, UpdateUserDto, CreateRoleDto, CreateApiKeyDto, PaginationQueryDto, ALL_PERMISSIONS, UpdateRoleSchema, hasPermission } from '@wms/shared';
import { z } from 'zod';
import { DbService, Tx } from '@/common/db/db.service';
import { users, roles, userRoles, userWarehouses, warehouses, apiKeys, sessions } from '@/db/schema';
import { AuditService } from '@/common/audit/audit.service';
import { TokenService } from '@/common/auth/token.service';
import { requireRequestContext } from '@/common/context/request-context';
import { NotFoundError, DomainError, ValidationError, ForbiddenError } from '@/common/errors/domain-error';
import { paginate, offsetOf, serialize } from '@/common/util';
import { PlatformService } from '@/modules/platform/platform.service';

@Injectable()
export class UsersService {
  constructor(
    private readonly db: DbService,
    private readonly audit: AuditService,
    private readonly tokens: TokenService,
    private readonly platform: PlatformService,
  ) {}

  // ---------------- users ----------------
  async list(q: PaginationQueryDto) {
    return this.db.run(async (tx) => {
      const where = q.q ? or(ilike(users.name, `%${q.q}%`), ilike(users.email, `%${q.q}%`)) : undefined;
      const [{ count }] = await tx.select({ count: sql<number>`count(*)`.mapWith(Number) }).from(users).where(where);
      const rows = await tx.select().from(users).where(where).orderBy(users.name).limit(q.pageSize).offset(offsetOf(q));
      const ids = rows.map((r) => r.id);
      const roleRows = ids.length
        ? await tx.select({ userId: userRoles.userId, roleId: userRoles.roleId, code: roles.code, name: roles.name, warehouseId: userRoles.warehouseId }).from(userRoles).innerJoin(roles, eq(roles.id, userRoles.roleId)).where(inArray(userRoles.userId, ids))
        : [];
      const whRows = ids.length
        ? await tx.select({ userId: userWarehouses.userId, warehouseId: userWarehouses.warehouseId, code: warehouses.code, isDefault: userWarehouses.isDefault }).from(userWarehouses).innerJoin(warehouses, eq(warehouses.id, userWarehouses.warehouseId)).where(inArray(userWarehouses.userId, ids))
        : [];
      const items = rows.map((u) => ({
        ...stripSecret(u),
        roles: roleRows.filter((r) => r.userId === u.id).map(({ userId: _u, ...r }) => r),
        warehouses: whRows.filter((w) => w.userId === u.id).map(({ userId: _u, ...w }) => w),
      }));
      return paginate(items, count, q);
    });
  }

  get(id: string) {
    return this.db.run((tx) => this.getInTx(tx, id));
  }

  async getInTx(tx: Tx, id: string) {
    const [u] = await tx.select().from(users).where(eq(users.id, id)).limit(1);
    if (!u) throw new NotFoundError('User', id);
    const roleRows = await tx.select({ roleId: userRoles.roleId, code: roles.code, name: roles.name, warehouseId: userRoles.warehouseId }).from(userRoles).innerJoin(roles, eq(roles.id, userRoles.roleId)).where(eq(userRoles.userId, id));
    const whRows = await tx.select({ warehouseId: userWarehouses.warehouseId, code: warehouses.code, name: warehouses.name, isDefault: userWarehouses.isDefault }).from(userWarehouses).innerJoin(warehouses, eq(warehouses.id, userWarehouses.warehouseId)).where(eq(userWarehouses.userId, id));
    return { ...stripSecret(u), roles: roleRows, warehouses: whRows };
  }

  /**
   * Privilege-escalation guard: only tenant admins (permissions '*') may grant
   * tenant-admin, change storer scope, or assign roles whose permissions exceed
   * their own. Nobody may change their own roles/admin flag.
   */
  private async assertMayGrant(tx: Tx, targetUserId: string | null, dto: { isTenantAdmin?: boolean; storerId?: string | null; roleIds?: string[] }) {
    const ctx = requireRequestContext();
    const isAdmin = ctx.permissions.includes('*');
    if (targetUserId && targetUserId === ctx.userId && (dto.isTenantAdmin !== undefined || dto.roleIds !== undefined || dto.storerId !== undefined)) {
      throw new ForbiddenError('You cannot change your own roles, scope or admin flag', 'SELF_PRIVILEGE_CHANGE');
    }
    if (isAdmin) return;
    if (dto.isTenantAdmin === true) throw new ForbiddenError('Only a tenant administrator can grant tenant-admin', 'PRIVILEGE_ESCALATION');
    if (dto.storerId !== undefined) throw new ForbiddenError('Only a tenant administrator can change a user\'s storer scope', 'PRIVILEGE_ESCALATION');
    if (dto.roleIds?.length) {
      const rows = await tx.select({ id: roles.id, code: roles.code, permissions: roles.permissions }).from(roles).where(inArray(roles.id, dto.roleIds));
      for (const r of rows) {
        const beyond = (r.permissions as string[]).filter((p) => !hasPermission(ctx.permissions, p as never));
        if (beyond.length) throw new ForbiddenError(`Role ${r.code} grants permissions you do not hold (${beyond.slice(0, 3).join(', ')})`, 'PRIVILEGE_ESCALATION');
      }
    }
  }

  private assertRolePermissionsWithinOwn(perms: string[]) {
    const ctx = requireRequestContext();
    if (ctx.permissions.includes('*')) return;
    const beyond = perms.filter((p) => !hasPermission(ctx.permissions, p as never));
    if (beyond.length) throw new ForbiddenError(`Cannot grant permissions you do not hold (${beyond.slice(0, 3).join(', ')})`, 'PRIVILEGE_ESCALATION');
  }

  async create(dto: CreateUserDto) {
    const ctx = requireRequestContext();
    return this.db.run(async (tx) => {
      await this.assertMayGrant(tx, null, dto);
      const [{ count }] = await tx.select({ count: sql<number>`count(*)`.mapWith(Number) }).from(users).where(eq(users.status, 'ACTIVE'));
      await this.platform.assertWithinLimit(ctx.tenantId!, 'maxUsers', count, tx);
      const [u] = await tx
        .insert(users)
        .values({
          tenantId: ctx.tenantId!,
          email: dto.email.toLowerCase(),
          name: dto.name,
          phone: dto.phone,
          passwordHash: dto.password ? await argon2.hash(dto.password) : null,
          status: dto.password ? 'ACTIVE' : 'INVITED',
          isTenantAdmin: dto.isTenantAdmin,
          storerId: dto.storerId ?? null,
          locale: dto.locale,
        })
        .returning();
      await this.syncAssignments(u.id, dto.roleIds, dto.warehouseIds, dto.defaultWarehouseId, tx);
      await this.audit.log({ action: 'user.create', entityType: 'user', entityId: u.id, after: { ...dto, password: undefined } }, tx);
      return this.getInTx(tx, u.id);
    });
  }

  async update(id: string, dto: UpdateUserDto) {
    return this.db.run(async (tx) => {
      const [before] = await tx.select().from(users).where(eq(users.id, id)).limit(1);
      if (!before) throw new NotFoundError('User', id);
      await this.assertMayGrant(tx, id, dto);
      const patch: Partial<typeof users.$inferInsert> = {
        email: dto.email?.toLowerCase(),
        name: dto.name,
        phone: dto.phone,
        isTenantAdmin: dto.isTenantAdmin,
        storerId: dto.storerId === undefined ? undefined : dto.storerId,
        locale: dto.locale,
        status: dto.status,
      };
      if (dto.password) {
        patch.passwordHash = await argon2.hash(dto.password);
        if (before.status === 'INVITED') patch.status = 'ACTIVE';
      }
      await tx.update(users).set(patch).where(eq(users.id, id));
      if (dto.roleIds || dto.warehouseIds || dto.defaultWarehouseId) {
        await this.syncAssignments(id, dto.roleIds, dto.warehouseIds, dto.defaultWarehouseId, tx);
      }
      if (dto.status === 'DISABLED') {
        await tx.update(sessions).set({ revokedAt: new Date() }).where(and(eq(sessions.userId, id), isNull(sessions.revokedAt)));
      }
      await this.audit.log({ action: 'user.update', entityType: 'user', entityId: id, before: stripSecret(before), after: { ...dto, password: undefined } }, tx);
      return this.getInTx(tx, id);
    });
  }

  private async syncAssignments(userId: string, roleIds: string[] | undefined, warehouseIds: string[] | undefined, defaultWarehouseId: string | undefined, tx: Tx) {
    if (roleIds) {
      await tx.delete(userRoles).where(eq(userRoles.userId, userId));
      if (roleIds.length) await tx.insert(userRoles).values(roleIds.map((roleId) => ({ userId, roleId, warehouseId: null })));
    }
    if (warehouseIds) {
      await tx.delete(userWarehouses).where(eq(userWarehouses.userId, userId));
      if (warehouseIds.length) {
        await tx.insert(userWarehouses).values(warehouseIds.map((warehouseId) => ({ userId, warehouseId, isDefault: warehouseId === (defaultWarehouseId ?? warehouseIds[0]) })));
      }
    } else if (defaultWarehouseId) {
      await tx.update(userWarehouses).set({ isDefault: false }).where(eq(userWarehouses.userId, userId));
      await tx.update(userWarehouses).set({ isDefault: true }).where(and(eq(userWarehouses.userId, userId), eq(userWarehouses.warehouseId, defaultWarehouseId)));
    }
  }

  // ---------------- roles ----------------
  listRoles() {
    return this.db.run((tx) => tx.select().from(roles).orderBy(roles.code));
  }

  listPermissions() {
    return ALL_PERMISSIONS;
  }

  createRole(dto: CreateRoleDto) {
    const ctx = requireRequestContext();
    this.assertPermissionsValid(dto.permissions);
    this.assertRolePermissionsWithinOwn(dto.permissions);
    return this.db.run(async (tx) => {
      const [r] = await tx.insert(roles).values({ tenantId: ctx.tenantId!, ...dto }).returning();
      await this.audit.log({ action: 'role.create', entityType: 'role', entityId: r.id, after: dto }, tx);
      return r;
    });
  }

  updateRole(id: string, dto: z.infer<typeof UpdateRoleSchema>) {
    if (dto.permissions) {
      this.assertPermissionsValid(dto.permissions);
      this.assertRolePermissionsWithinOwn(dto.permissions);
    }
    return this.db.run(async (tx) => {
      const [before] = await tx.select().from(roles).where(eq(roles.id, id)).limit(1);
      if (!before) throw new NotFoundError('Role', id);
      if (before.isSystem && dto.permissions && !requireRequestContext().permissions.includes('*')) throw new ForbiddenError('Only a tenant administrator can change system role permissions', 'PRIVILEGE_ESCALATION');
      if (before.isSystem && dto.code && dto.code !== before.code) throw new DomainError('INVALID_STATE', 'System role codes cannot be changed');
      const [r] = await tx.update(roles).set(dto).where(eq(roles.id, id)).returning();
      await this.audit.log({ action: 'role.update', entityType: 'role', entityId: id, before, after: r }, tx);
      return r;
    });
  }

  deleteRole(id: string) {
    return this.db.run(async (tx) => {
      const [before] = await tx.select().from(roles).where(eq(roles.id, id)).limit(1);
      if (!before) throw new NotFoundError('Role', id);
      if (before.isSystem) throw new DomainError('INVALID_STATE', 'System roles cannot be deleted');
      await tx.delete(roles).where(eq(roles.id, id));
      await this.audit.log({ action: 'role.delete', entityType: 'role', entityId: id, before }, tx);
      return { ok: true };
    });
  }

  private assertPermissionsValid(perms: string[]) {
    const bad = perms.filter((p) => p !== '*' && !p.endsWith('.*') && !(ALL_PERMISSIONS as string[]).includes(p));
    if (bad.length) throw new ValidationError(`Unknown permissions: ${bad.join(', ')}`);
  }

  // ---------------- api keys ----------------
  listApiKeys() {
    return this.db.run((tx) =>
      tx
        .select({ id: apiKeys.id, name: apiKeys.name, prefix: apiKeys.prefix, storerId: apiKeys.storerId, scopes: apiKeys.scopes, rateLimitPerMinute: apiKeys.rateLimitPerMinute, lastUsedAt: apiKeys.lastUsedAt, expiresAt: apiKeys.expiresAt, revokedAt: apiKeys.revokedAt, createdAt: apiKeys.createdAt })
        .from(apiKeys)
        .orderBy(desc(apiKeys.createdAt)),
    );
  }

  /** The raw key is returned exactly once. */
  createApiKey(dto: CreateApiKeyDto) {
    const ctx = requireRequestContext();
    return this.db.run(async (tx) => {
      const { raw, hash, prefix } = this.tokens.generateApiKey();
      const [k] = await tx
        .insert(apiKeys)
        .values({ tenantId: ctx.tenantId!, storerId: dto.storerId ?? null, name: dto.name, prefix, keyHash: hash, scopes: dto.scopes, rateLimitPerMinute: dto.rateLimitPerMinute, expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null, createdById: ctx.userId })
        .returning();
      await this.audit.log({ action: 'apikey.create', entityType: 'api_key', entityId: k.id, after: { name: dto.name, scopes: dto.scopes, storerId: dto.storerId } }, tx);
      return { id: k.id, name: k.name, prefix: k.prefix, scopes: k.scopes, key: raw };
    });
  }

  revokeApiKey(id: string) {
    return this.db.run(async (tx) => {
      const [k] = await tx.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, id)).returning();
      if (!k) throw new NotFoundError('API key', id);
      await this.audit.log({ action: 'apikey.revoke', entityType: 'api_key', entityId: id }, tx);
      return { ok: true };
    });
  }

  serialize = serialize;
}

function stripSecret<T extends { passwordHash?: string | null }>(u: T): Omit<T, 'passwordHash'> {
  const { passwordHash: _p, ...rest } = u;
  return rest;
}
