import { Injectable, UnauthorizedException, Logger } from '@nestjs/common';
import * as argon2 from 'argon2';
import { and, eq, isNull, gt } from 'drizzle-orm';
import { AuthTokens, AuthUserProfile, LoginDto, PlatformLoginDto, hasPermission, SYSTEM_ROLES } from '@wms/shared';
import { DbService, Tx } from '@/common/db/db.service';
import { tenants, users, roles, userRoles, userWarehouses, warehouses, sessions, featureFlags, platformUsers } from '@/db/schema';
import { TokenService, AccessTokenPayload } from '@/common/auth/token.service';
import { AuditService } from '@/common/audit/audit.service';
import { loadEnv } from '@/config/env';
import { getRequestContext } from '@/common/context/request-context';
import { DomainError } from '@/common/errors/domain-error';

/** In-process lockout for platform logins (no DB counter on platform_users). */
const platformAttempts = new Map<string, { attempts: number; lockedUntil: number }>();
/** Sessions revoked on this node since start; the guard also re-checks the database periodically. */
export const revokedSessions = new Set<string>();

const MAX_FAILED_ATTEMPTS = 5;
const LOCK_MINUTES = 15;

@Injectable()
export class AuthService {
  private readonly logger = new Logger(AuthService.name);
  private readonly env = loadEnv();

  constructor(
    private readonly db: DbService,
    private readonly tokens: TokenService,
    private readonly audit: AuditService,
  ) {}

  /** Tenant user login. Tenant is identified by code so emails may repeat across tenants. */
  async login(dto: LoginDto): Promise<{ tokens: AuthTokens; profile: AuthUserProfile }> {
    const ctx = getRequestContext();
    return this.db.runBypass(async (tx) => {
      const [tenant] = await tx.select().from(tenants).where(eq(tenants.code, dto.tenantCode.toLowerCase())).limit(1);
      if (!tenant) throw new UnauthorizedException('Invalid credentials');
      if (tenant.status === 'SUSPENDED' || tenant.status === 'CLOSED') throw new UnauthorizedException('Tenant is not active');

      const [user] = await tx.select().from(users).where(and(eq(users.tenantId, tenant.id), eq(users.email, dto.email.toLowerCase()))).limit(1);
      if (!user || !user.passwordHash || user.status !== 'ACTIVE') throw new UnauthorizedException('Invalid credentials');
      if (user.lockedUntil && user.lockedUntil > new Date()) throw new UnauthorizedException('Account temporarily locked. Try again later.');

      const ok = await argon2.verify(user.passwordHash, dto.password);
      if (!ok) {
        const attempts = user.failedLoginAttempts + 1;
        await tx
          .update(users)
          .set({ failedLoginAttempts: attempts, lockedUntil: attempts >= MAX_FAILED_ATTEMPTS ? new Date(Date.now() + LOCK_MINUTES * 60000) : null })
          .where(eq(users.id, user.id));
        await this.audit.log({ action: 'auth.login_failed', entityType: 'user', entityId: user.id, tenantId: tenant.id }, tx);
        throw new UnauthorizedException('Invalid credentials');
      }

      await tx.update(users).set({ failedLoginAttempts: 0, lockedUntil: null, lastLoginAt: new Date() }).where(eq(users.id, user.id));
      const profile = await this.buildProfile(user.id, tx);
      const session = await this.createSession(user.id, dto.deviceId, tx);
      const tokens = this.issueTokens({ profile, sessionId: session.id, refreshRaw: session.raw, tenantCode: tenant.code });
      await this.audit.log({ action: 'auth.login', entityType: 'user', entityId: user.id, tenantId: tenant.id, after: { deviceId: dto.deviceId, ip: ctx?.ip } }, tx);
      return { tokens, profile };
    });
  }

  async platformLogin(dto: PlatformLoginDto): Promise<AuthTokens & { name: string; role: string }> {
    return this.db.runBypass(async (tx) => {
      const email = dto.email.toLowerCase();
      const lock = platformAttempts.get(email);
      if (lock && lock.lockedUntil > Date.now()) throw new UnauthorizedException('Account temporarily locked. Try again later.');
      const [pu] = await tx.select().from(platformUsers).where(eq(platformUsers.email, email)).limit(1);
      if (!pu || pu.status !== 'ACTIVE' || !(await argon2.verify(pu.passwordHash, dto.password))) {
        const attempts = (lock?.attempts ?? 0) + 1;
        platformAttempts.set(email, { attempts, lockedUntil: attempts >= MAX_FAILED_ATTEMPTS ? Date.now() + LOCK_MINUTES * 60000 : 0 });
        if (pu) await this.audit.log({ action: 'auth.platform_login_failed', entityType: 'platform_user', entityId: pu.id, tenantId: null }, tx);
        throw new UnauthorizedException('Invalid credentials');
      }
      platformAttempts.delete(email);
      await tx.update(platformUsers).set({ lastLoginAt: new Date() }).where(eq(platformUsers.id, pu.id));
      const payload: AccessTokenPayload = { sub: pu.id, typ: 'platform', tid: null, sid: 'platform', name: pu.name, email: pu.email, storer: null, admin: true, prole: pu.role, perm: ['*'], wh: 'ALL', feat: [] };
      const { token, expiresIn } = this.tokens.signAccessToken(payload);
      await this.audit.log({ action: 'auth.platform_login', entityType: 'platform_user', entityId: pu.id, tenantId: null }, tx);
      // Platform sessions are short-lived access tokens only (no refresh) by design.
      return { accessToken: token, refreshToken: '', expiresIn, name: pu.name, role: pu.role };
    });
  }

  /** Rotate refresh token; re-build profile so permission changes propagate. */
  async refresh(refreshRaw: string): Promise<{ tokens: AuthTokens; profile: AuthUserProfile }> {
    const hash = TokenService.hash(refreshRaw);
    return this.db.runBypass(async (tx) => {
      const [session] = await tx.select().from(sessions).where(and(eq(sessions.refreshTokenHash, hash), isNull(sessions.revokedAt), gt(sessions.expiresAt, new Date()))).limit(1);
      if (!session) {
        // Replay of an already-rotated token means the token leaked: revoke every session of that user.
        const [stale] = await tx.select({ userId: sessions.userId }).from(sessions).where(eq(sessions.refreshTokenHash, hash)).limit(1);
        if (stale) {
          await tx.update(sessions).set({ revokedAt: new Date() }).where(and(eq(sessions.userId, stale.userId), isNull(sessions.revokedAt)));
          await this.audit.log({ action: 'auth.refresh_reuse_detected', entityType: 'user', entityId: stale.userId, tenantId: null }, tx);
        }
        throw new UnauthorizedException('Refresh token invalid');
      }
      const [user] = await tx.select().from(users).where(eq(users.id, session.userId)).limit(1);
      if (!user || user.status !== 'ACTIVE') throw new UnauthorizedException('User inactive');
      const [tenant] = await tx.select().from(tenants).where(eq(tenants.id, user.tenantId)).limit(1);
      if (!tenant || tenant.status === 'SUSPENDED' || tenant.status === 'CLOSED') throw new UnauthorizedException('Tenant is not active');

      // rotate: revoke old, create new
      await tx.update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, session.id));
      const next = await this.createSession(user.id, session.deviceId ?? undefined, tx);
      const profile = await this.buildProfile(user.id, tx);
      const tokens = this.issueTokens({ profile, sessionId: next.id, refreshRaw: next.raw, tenantCode: tenant.code });
      return { tokens, profile };
    });
  }

  async logout(refreshRaw: string | undefined, userId: string | null, sessionId?: string | null) {
    await this.db.runBypass(async (tx) => {
      if (refreshRaw) {
        await tx.update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.refreshTokenHash, TokenService.hash(refreshRaw)));
      } else if (sessionId && sessionId !== 'platform') {
        // no refresh token presented (e.g. RF device without cookies): revoke the session the access token belongs to
        await tx.update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, sessionId));
      }
      if (sessionId) revokedSessions.add(sessionId);
      if (userId) await this.audit.log({ action: 'auth.logout', entityType: 'user', entityId: userId }, tx);
    });
  }

  async changePassword(userId: string, currentPassword: string, newPassword: string) {
    await this.db.runBypass(async (tx) => {
      const [user] = await tx.select().from(users).where(eq(users.id, userId)).limit(1);
      if (!user?.passwordHash || !(await argon2.verify(user.passwordHash, currentPassword))) throw new DomainError('INVALID_PASSWORD', 'Current password is incorrect');
      await tx.update(users).set({ passwordHash: await argon2.hash(newPassword) }).where(eq(users.id, userId));
      // revoke all other sessions
      await tx.update(sessions).set({ revokedAt: new Date() }).where(and(eq(sessions.userId, userId), isNull(sessions.revokedAt)));
      await this.audit.log({ action: 'auth.password_changed', entityType: 'user', entityId: userId, tenantId: user.tenantId }, tx);
    });
  }

  me(userId: string): Promise<AuthUserProfile> {
    return this.db.runBypass((tx) => this.buildProfile(userId, tx));
  }

  // ---- helpers ----
  async buildProfile(userId: string, tx: Tx): Promise<AuthUserProfile> {
    const [user] = await tx.select().from(users).where(eq(users.id, userId)).limit(1);
    if (!user) throw new UnauthorizedException('User not found');
    const [tenant] = await tx.select({ code: tenants.code }).from(tenants).where(eq(tenants.id, user.tenantId)).limit(1);
    const roleRows = await tx
      .select({ code: roles.code, name: roles.name, permissions: roles.permissions, warehouseId: userRoles.warehouseId })
      .from(userRoles)
      .innerJoin(roles, eq(roles.id, userRoles.roleId))
      .where(eq(userRoles.userId, userId));
    const whRows = await tx
      .select({ id: warehouses.id, code: warehouses.code, name: warehouses.name, isDefault: userWarehouses.isDefault })
      .from(userWarehouses)
      .innerJoin(warehouses, eq(warehouses.id, userWarehouses.warehouseId))
      .where(and(eq(userWarehouses.userId, userId), eq(warehouses.status, 'ACTIVE')));
    const flags = await tx.select({ key: featureFlags.key }).from(featureFlags).where(and(eq(featureFlags.tenantId, user.tenantId), eq(featureFlags.enabled, true)));

    // Permission model: union of role permissions. Warehouse-scoped roles are
    // unioned too (v1); warehouse scoping is enforced by the warehouse access list.
    let permissions = Array.from(new Set(roleRows.flatMap((r) => r.permissions)));
    if (user.isTenantAdmin) permissions = ['*'];
    // storer-portal users can never exceed the CUSTOMER role surface, whatever roles were assigned
    if (user.storerId) {
      const customer = SYSTEM_ROLES.find((r) => r.code === 'CUSTOMER')!;
      // A storer user's effective permissions are capped to the portal surface. Wildcards granted through a
      // role (e.g. inventory.*) are expanded against that surface instead of being dropped.
      const surface = customer.permissions as readonly string[];
      permissions = Array.from(new Set(permissions.flatMap((p) => (p === '*' || p.endsWith('.*') ? surface.filter((sp) => hasPermission([p], sp as never)) : surface.some((cp) => cp === p || hasPermission([cp], p as never)) ? [p] : []))));
    }

    return {
      id: user.id,
      tenantId: user.tenantId,
      tenantCode: tenant?.code ?? '',
      email: user.email,
      name: user.name,
      isTenantAdmin: user.isTenantAdmin,
      storerId: user.storerId,
      permissions,
      warehouses: whRows,
      roles: roleRows.map((r) => ({ code: r.code, name: r.name, warehouseId: r.warehouseId })),
      features: flags.map((f) => f.key),
    };
  }

  private async createSession(userId: string, deviceId: string | undefined, tx: Tx) {
    const ctx = getRequestContext();
    const { raw, hash } = this.tokens.generateRefreshToken();
    const [row] = await tx
      .insert(sessions)
      .values({ userId, refreshTokenHash: hash, userAgent: ctx?.userAgent, ip: ctx?.ip, deviceId, expiresAt: new Date(Date.now() + this.env.JWT_REFRESH_TTL_DAYS * 86400000) })
      .returning({ id: sessions.id });
    return { id: row.id, raw };
  }

  private issueTokens(input: { profile: AuthUserProfile; sessionId: string; refreshRaw: string; tenantCode: string }): AuthTokens {
    const p = input.profile;
    const payload: AccessTokenPayload = {
      sub: p.id,
      typ: 'user',
      tid: p.tenantId,
      tcode: input.tenantCode,
      sid: input.sessionId,
      name: p.name,
      email: p.email,
      storer: p.storerId,
      admin: p.isTenantAdmin,
      perm: p.permissions,
      wh: p.isTenantAdmin ? 'ALL' : p.warehouses.map((w) => w.id),
      feat: p.features,
    };
    const { token, expiresIn } = this.tokens.signAccessToken(payload);
    return { accessToken: token, refreshToken: input.refreshRaw, expiresIn };
  }
}
