import {
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import * as argon2 from 'argon2';
import { createHash, randomBytes } from 'node:crypto';
import type { ChangePasswordRequest, LoginRequest, SessionUser } from '@ali-ismail/contracts';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { LoginThrottleService } from './login-throttle.service';

const SESSION_TTL_MS = 1000 * 60 * 60 * 12;

type RequestMeta = {
  ip?: string | undefined;
  userAgent?: string | undefined;
  correlationId?: string | undefined;
};

// Short-lived in-process authorization cache. Against the remote MySQL host each
// request otherwise pays ~8-10 sequential round trips (~250ms each) to rebuild
// roles/permissions. Every cache hit still validates the shared session, user,
// and organization row so revocation/status changes are authoritative across
// API instances; only the expensive authorization projection is cached.
const SESSION_CACHE_TTL_MS = Number(process.env.SESSION_CACHE_TTL_MS ?? 30_000);
const LAST_SEEN_THROTTLE_MS = 60_000;

type CachedSession = {
  sessionUser: SessionUser;
  sessionId: string;
  userId: string;
  expiresAt: number; // cache entry expiry
  sessionExpiresAt: number; // real session expiry
};

@Injectable()
export class AuthService {
  private readonly sessionCache = new Map<string, CachedSession>();
  private readonly lastSeenWrittenAt = new Map<string, number>();
  // Invalidation timestamps close the race where a resolver reads a not-yet-
  // revoked session, a logout lands mid-resolution, and the resolver would
  // otherwise repopulate the cache with the now-revoked session.
  private readonly tokenInvalidatedAt = new Map<string, number>();
  private readonly userInvalidatedAt = new Map<string, number>();

  constructor(
    private readonly prisma: PrismaService,
    private readonly audit: AuditService,
    private readonly loginThrottle: LoginThrottleService,
  ) {}

  private hashToken(token: string) {
    return createHash('sha256').update(token).digest('hex');
  }

  async buildSessionUser(userId: string): Promise<SessionUser> {
    const user = await this.prisma.user.findUniqueOrThrow({
      where: { id: userId },
      include: {
        userRoles: {
          include: {
            role: {
              include: {
                rolePermissions: { include: { permission: true } },
              },
            },
          },
        },
        permissionOverrides: { include: { permission: { select: { code: true } } } },
      },
    });

    // Archived/inactive roles are never an authorization source, even for a
    // still-valid cached session after a role was archived.
    const activeUserRoles = user.userRoles.filter((ur) => ur.role.isActive && !ur.role.archivedAt);
    const roles = activeUserRoles.map((ur) => ur.role.code);
    // Start from the union of role grants. Explicit ALLOWs add to that union;
    // explicit DENYs are deliberately evaluated last and always win.
    const permissions = new Set(
      activeUserRoles.flatMap((ur) => ur.role.rolePermissions.map((rp) => rp.permission.code)),
    );
    for (const override of user.permissionOverrides) {
      if (override.effect === 'ALLOW') permissions.add(override.permission.code);
    }
    for (const override of user.permissionOverrides) {
      if (override.effect === 'DENY') permissions.delete(override.permission.code);
    }

    return {
      id: user.id,
      organizationId: user.organizationId,
      username: user.username,
      fullName: user.fullName,
      email: user.email ?? null,
      isSuperAdmin: user.isSuperAdmin,
      mustResetPassword: user.mustResetPassword,
      permissions: [...permissions].sort(),
      roles,
    };
  }

  async login(dto: LoginRequest, meta: RequestMeta) {
    // Reserve the attempt atomically before any user lookup or password work.
    // This closes the parallel-request race across API replicas.
    await this.loginThrottle.reserveAttempt(dto.username, meta.ip);

    const user = await this.prisma.user.findFirst({
      where: {
        username: dto.username,
        isActive: true,
        archivedAt: null,
        organization: { isActive: true, archivedAt: null },
      },
    });

    if (!user) {
      throw new UnauthorizedException('Invalid username or password');
    }

    const ok = await argon2.verify(user.passwordHash, dto.password);
    if (!ok) {
      await this.audit.write({
        organizationId: user.organizationId,
        actorUserId: user.id,
        action: 'auth.login_failed',
        entityType: 'user',
        entityId: user.id,
        ipAddress: meta.ip ?? null,
        userAgent: meta.userAgent ?? null,
        correlationId: meta.correlationId ?? null,
      });
      throw new UnauthorizedException('Invalid username or password');
    }

    // Successful login — clear the throttle bucket.
    await this.loginThrottle.clearBucket(dto.username, meta.ip);

    const rawToken = randomBytes(32).toString('base64url');
    const tokenHash = this.hashToken(rawToken);
    const expiresAt = new Date(Date.now() + SESSION_TTL_MS);

    const session = await this.prisma.session.create({
      data: {
        organizationId: user.organizationId,
        userId: user.id,
        tokenHash,
        expiresAt,
        ipAddress: meta.ip ?? null,
        userAgent: meta.userAgent ?? null,
      },
    });

    await this.prisma.user.update({
      where: { id: user.id },
      data: { lastLoginAt: new Date() },
    });

    const sessionUser = await this.buildSessionUser(user.id);

    await this.audit.write({
      organizationId: user.organizationId,
      actorUserId: user.id,
      action: 'auth.login',
      entityType: 'session',
      entityId: session.id,
      after: { username: user.username },
      ipAddress: meta.ip ?? null,
      userAgent: meta.userAgent ?? null,
      correlationId: meta.correlationId ?? null,
    });

    return {
      rawToken,
      expiresAt,
      user: sessionUser,
      sessionId: session.id,
    };
  }

  async logout(token: string | undefined, meta: RequestMeta) {
    if (!token) return;
    const tokenHash = this.hashToken(token);
    const session = await this.prisma.session.findUnique({ where: { tokenHash } });
    this.invalidateSessionCache({ token });
    if (!session || session.revokedAt) return;

    await this.prisma.session.update({
      where: { id: session.id },
      data: { revokedAt: new Date() },
    });

    await this.audit.write({
      organizationId: session.organizationId,
      actorUserId: session.userId,
      action: 'auth.logout',
      entityType: 'session',
      entityId: session.id,
      ipAddress: meta.ip ?? null,
      userAgent: meta.userAgent ?? null,
      correlationId: meta.correlationId ?? null,
    });
  }

  async logoutAll(userId: string, organizationId: string) {
    this.invalidateSessionCache({ userId });
    await this.prisma.session.updateMany({
      where: { userId, organizationId, revokedAt: null },
      data: { revokedAt: new Date() },
    });
  }

  /**
   * Rotate a password after verifying the current credential. All sessions,
   * including the one making this request, are revoked to prevent fixation.
   * Password material is intentionally absent from audit data and return data.
   */
  async changePassword(
    userId: string,
    organizationId: string,
    dto: ChangePasswordRequest,
    meta: RequestMeta,
  ) {
    const user = await this.prisma.user.findFirst({
      where: { id: userId, organizationId, isActive: true, archivedAt: null },
      select: { id: true, username: true, passwordHash: true },
    });
    if (!user || !(await argon2.verify(user.passwordHash, dto.currentPassword))) {
      throw new UnauthorizedException('Current password is incorrect');
    }

    const passwordHash = await argon2.hash(dto.newPassword);
    await this.prisma.$transaction(async (tx) => {
      await tx.user.update({
        where: { id: user.id },
        data: { passwordHash, mustResetPassword: false },
      });
      await tx.session.updateMany({
        where: { userId: user.id, organizationId, revokedAt: null },
        data: { revokedAt: new Date() },
      });
      await this.audit.write({
        organizationId,
        actorUserId: user.id,
        action: 'auth.password_changed',
        entityType: 'user',
        entityId: user.id,
        after: { mustResetPassword: false },
        ipAddress: meta.ip ?? null,
        userAgent: meta.userAgent ?? null,
        correlationId: meta.correlationId ?? null,
      }, tx);
    });
    this.invalidateSessionCache({ userId: user.id });
  }

  async resolveSession(token: string | undefined) {
    if (!token) return null;
    const tokenHash = this.hashToken(token);
    const now = Date.now();

    const cached = this.sessionCache.get(tokenHash);
    if (cached && cached.expiresAt > now && cached.sessionExpiresAt > now) {
      const authoritative = await this.prisma.session.findUnique({
        where: { tokenHash },
        select: {
          id: true,
          userId: true,
          expiresAt: true,
          revokedAt: true,
          user: {
            select: {
              isActive: true,
              archivedAt: true,
              organization: { select: { isActive: true, archivedAt: true } },
            },
          },
        },
      });
      if (
        !authoritative ||
        authoritative.id !== cached.sessionId ||
        authoritative.userId !== cached.userId ||
        authoritative.revokedAt ||
        authoritative.expiresAt.getTime() <= now ||
        !authoritative.user.isActive ||
        authoritative.user.archivedAt ||
        !authoritative.user.organization.isActive ||
        authoritative.user.organization.archivedAt
      ) {
        this.sessionCache.delete(tokenHash);
        this.lastSeenWrittenAt.delete(tokenHash);
        return null;
      }
      this.touchLastSeen(tokenHash, cached.sessionId);
      return { sessionUser: cached.sessionUser, sessionId: cached.sessionId };
    }

    const session = await this.prisma.session.findUnique({
      where: { tokenHash },
      include: { user: { include: { organization: true } } },
    });
    if (!session || session.revokedAt) {
      this.sessionCache.delete(tokenHash);
      return null;
    }
    if (session.expiresAt.getTime() <= now) {
      this.sessionCache.delete(tokenHash);
      return null;
    }
    if (
      !session.user.isActive ||
      session.user.archivedAt ||
      !session.user.organization.isActive ||
      session.user.organization.archivedAt
    ) {
      this.sessionCache.delete(tokenHash);
      return null;
    }

    const resolveStartedAt = now;
    const sessionUser = await this.buildSessionUser(session.userId);
    // If a logout/logoutAll landed while we were resolving, this session is
    // revoked (or about to be): do not cache, do not authenticate from stale
    // pre-revocation reads.
    if (this.wasInvalidatedSince(tokenHash, session.userId, resolveStartedAt)) {
      this.sessionCache.delete(tokenHash);
      return null;
    }
    this.sessionCache.set(tokenHash, {
      sessionUser,
      sessionId: session.id,
      userId: session.userId,
      expiresAt: Date.now() + SESSION_CACHE_TTL_MS,
      sessionExpiresAt: session.expiresAt.getTime(),
    });
    this.pruneCaches();
    this.touchLastSeen(tokenHash, session.id);
    return { sessionUser, sessionId: session.id };
  }

  private wasInvalidatedSince(tokenHash: string, userId: string, sinceMs: number) {
    const t = this.tokenInvalidatedAt.get(tokenHash);
    const u = this.userInvalidatedAt.get(userId);
    return (t !== undefined && t >= sinceMs) || (u !== undefined && u >= sinceMs);
  }

  /** Hard-bound all auth maps: sweep expired, then evict oldest if oversized. */
  private pruneCaches() {
    const now = Date.now();
    if (this.sessionCache.size > 5000) {
      for (const [k, v] of this.sessionCache) if (v.expiresAt <= now) this.sessionCache.delete(k);
      while (this.sessionCache.size > 5000) {
        const oldest = this.sessionCache.keys().next().value as string | undefined;
        if (oldest === undefined) break;
        this.sessionCache.delete(oldest);
      }
    }
    // lastSeen throttle map: entries older than the throttle window are useless
    if (this.lastSeenWrittenAt.size > 10000) {
      for (const [k, v] of this.lastSeenWrittenAt) {
        if (now - v > LAST_SEEN_THROTTLE_MS) this.lastSeenWrittenAt.delete(k);
      }
    }
    // invalidation markers only matter for in-flight resolves (< cache TTL old)
    for (const m of [this.tokenInvalidatedAt, this.userInvalidatedAt]) {
      if (m.size > 10000) {
        for (const [k, v] of m) if (now - v > SESSION_CACHE_TTL_MS) m.delete(k);
      }
    }
  }

  /** Write lastSeenAt at most once per minute per session, off the request path. */
  private touchLastSeen(tokenHash: string, sessionId: string) {
    const now = Date.now();
    const last = this.lastSeenWrittenAt.get(tokenHash) ?? 0;
    if (now - last < LAST_SEEN_THROTTLE_MS) return;
    this.lastSeenWrittenAt.set(tokenHash, now);
    void this.prisma.session
      .update({ where: { id: sessionId }, data: { lastSeenAt: new Date() } })
      .catch(() => {
        // best-effort telemetry write; never fail the request for it
        this.lastSeenWrittenAt.delete(tokenHash);
      });
  }

  /** Drop cached session state for a token (logout) or all sessions of a user. */
  invalidateSessionCache(opts: { token?: string; userId?: string }) {
    const now = Date.now();
    if (opts.token) {
      const hash = this.hashToken(opts.token);
      this.sessionCache.delete(hash);
      this.lastSeenWrittenAt.delete(hash);
      this.tokenInvalidatedAt.set(hash, now);
    }
    if (opts.userId) {
      this.userInvalidatedAt.set(opts.userId, now);
      for (const [k, v] of this.sessionCache) {
        if (v.userId === opts.userId) {
          this.sessionCache.delete(k);
          this.lastSeenWrittenAt.delete(k);
        }
      }
    }
  }
}