import {
  Injectable,
  UnauthorizedException,
  ForbiddenException,
  BadRequestException,
  NotFoundException,
  Logger,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull, MoreThan } from 'typeorm';
import * as bcrypt from 'bcrypt';
import * as crypto from 'node:crypto';
import { UserEntity } from '../entities/user.entity';
import { TenantEntity } from '../entities/tenant.entity';
import { TenantSettingsEntity } from '../entities/tenant-settings.entity';
import { RolePermissionEntity } from '../entities/role-permission.entity';
import { PasswordResetEntity } from '../entities/password-reset.entity';
import { RefreshTokenEntity } from '../entities/refresh-token.entity';
import { RedisService } from '../redis/redis.service';
import { MailerService } from '../modules/mailer/mailer.service';
import { passwordResetTemplate } from '../modules/mailer/templates/password-reset.template';
import { LoginDto, AuthResponseDto, ForgotPasswordDto, ResetPasswordDto } from './dto';
import { serviceMessages } from '../common/constants/messages';
import { ConfigService } from '@nestjs/config';
import { refreshTtlSeconds } from './cookie-config';

const MSG = serviceMessages('User');

export interface JwtPayload {
  sub: string;
  email: string;
  tenant_id: string;
  /** Set only on a read-only "View as user" session: the real admin's
   *  id behind the impersonation. Absent on normal sessions. */
  impersonator_id?: string;
  /** True only on a view-as session — the global interceptor blocks all
   *  writes while this is set. */
  read_only?: boolean;
}

/**
 * Lockout config:
 * - MAX_ATTEMPTS: 5 failed login attempts
 * - LOCKOUT_DURATION: 15 minutes (900 seconds)
 * - Redis key: lockout:{tenantId}:{email}
 */
const MAX_ATTEMPTS = 5;
const LOCKOUT_DURATION = 60; // 1 minute in seconds
const LOCKOUT_PREFIX = 'lockout:';

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

  constructor(
    @InjectRepository(UserEntity)
    private readonly userRepo: Repository<UserEntity>,
    @InjectRepository(TenantEntity)
    private readonly tenantRepo: Repository<TenantEntity>,
    @InjectRepository(TenantSettingsEntity)
    private readonly settingsRepo: Repository<TenantSettingsEntity>,
    @InjectRepository(RolePermissionEntity)
    private readonly rolePermRepo: Repository<RolePermissionEntity>,
    @InjectRepository(PasswordResetEntity)
    private readonly resetRepo: Repository<PasswordResetEntity>,
    @InjectRepository(RefreshTokenEntity)
    private readonly refreshRepo: Repository<RefreshTokenEntity>,
    private readonly jwtService: JwtService,
    private readonly redisService: RedisService,
    private readonly mailer: MailerService,
    private readonly configService: ConfigService,
  ) {}

  /**
   * Build permissions map for a role: { module: access_level }
   */
  private async getPermissionsMap(
    roleId: string,
    tenantId: string,
  ): Promise<Record<string, string>> {
    const perms = await this.rolePermRepo.find({
      where: {
        role: { id: roleId },
        tenant_id: tenantId,
        is_deleted: false,
      },
      select: {
        module: true,
        access_level: true,
      },
    });

    const map: Record<string, string> = {};
    for (const p of perms) {
      map[p.module] = p.access_level;
    }
    return map;
  }

  /**
   * Get lockout Redis key for a tenant+email combination.
   */
  private getLockoutKey(tenantId: string, email: string): string {
    return `${LOCKOUT_PREFIX}${tenantId}:${email.toLowerCase()}`;
  }

  /**
   * Check if account is locked and throw if so.
   */
  private async checkLockout(tenantId: string, email: string): Promise<void> {
    const key = this.getLockoutKey(tenantId, email);
    const data = await this.redisService.get(key);

    if (data) {
      const { lockedUntil } = JSON.parse(data);

      if (lockedUntil) {
        const remainingMs = new Date(lockedUntil).getTime() - Date.now();
        if (remainingMs > 0) {
          const remainingMin = Math.ceil(remainingMs / 60000);
          throw new ForbiddenException(
            `Account locked due to too many failed attempts. Try again in ${remainingMin} minute${remainingMin > 1 ? 's' : ''}.`,
          );
        }
        // Lock expired — clear it
        await this.redisService.del(key);
      }
    }
  }

  /**
   * Record a failed login attempt. Lock account after MAX_ATTEMPTS.
   */
  private async recordFailedAttempt(tenantId: string, email: string): Promise<void> {
    const key = this.getLockoutKey(tenantId, email);
    const data = await this.redisService.get(key);

    let attempts = 1;
    if (data) {
      const parsed = JSON.parse(data);
      attempts = (parsed.attempts || 0) + 1;
    }

    const payload: { attempts: number; lockedUntil?: string } = { attempts };

    if (attempts >= MAX_ATTEMPTS) {
      // Lock the account
      payload.lockedUntil = new Date(Date.now() + LOCKOUT_DURATION * 1000).toISOString();
    }

    await this.redisService.set(key, JSON.stringify(payload), LOCKOUT_DURATION);
  }

  /**
   * Clear failed attempts on successful login.
   */
  private async clearFailedAttempts(tenantId: string, email: string): Promise<void> {
    await this.redisService.del(this.getLockoutKey(tenantId, email));
  }

  async login(dto: LoginDto, tenantId: string): Promise<AuthResponseDto> {
    // Check lockout BEFORE any DB query
    await this.checkLockout(tenantId, dto.email);

    const user = await this.userRepo.findOne({
      where: {
        email: dto.email,
        tenant_id: tenantId,
        is_deleted: false,
      },
      relations: {
        role: true,
      },
    });

    if (!user) {
      await this.recordFailedAttempt(tenantId, dto.email);
      throw new UnauthorizedException('Invalid email or password');
    }

    if (!user.is_active) {
      throw new UnauthorizedException('Account is deactivated');
    }

    const isPasswordValid = await bcrypt.compare(dto.password, user.password_hash);
    if (!isPasswordValid) {
      await this.recordFailedAttempt(tenantId, dto.email);
      throw new UnauthorizedException('Invalid email or password');
    }

    // Success — clear any failed attempts
    await this.clearFailedAttempts(tenantId, dto.email);

    await this.userRepo.update(user.id, { last_login_at: new Date() });

    const payload: JwtPayload = {
      sub: user.id,
      email: user.email,
      tenant_id: user.tenant_id,
    };

    const accessToken = this.jwtService.sign(payload);

    // Fetch tenant name + settings + permissions
    const tenant = await this.tenantRepo.findOne({
      where: { id: user.tenant_id },
      select: {
        name: true,
      },
    });

    const tenantSettings = await this.settingsRepo.findOne({
      where: { tenant_id: user.tenant_id, is_deleted: false },
      select: {
        company_name: true,
        primary_color: true,
        secondary_color: true,
        logo_url: true,
      },
    });

    const permissions = user.role_id
      ? await this.getPermissionsMap(user.role_id, user.tenant_id)
      : {};

    return {
      access_token: accessToken,
      user: {
        id: user.id,
        name: user.name,
        email: user.email,
        tenant_id: user.tenant_id,
        tenant_name: tenantSettings?.company_name || tenant?.name || null,
        role: user.role?.name || null,
        role_id: user.role_id || null,
        permissions,
        view_all_bookings_queries: user.role?.view_all_bookings_queries === true,
      },
      theme: {
        primary_color: tenantSettings?.primary_color || '#10b981',
        secondary_color: tenantSettings?.secondary_color || '#1e293b',
        logo_url: tenantSettings?.logo_url || null,
      },
    };
  }

  /**
   * Start a read-only "View as user" (impersonation) session. The caller
   * (an admin whose role holds impersonate:view — enforced by the
   * controller's PermissionGuard) gets a NEW access token acting as the
   * target user but tagged with `impersonator_id` + `read_only: true`.
   * The global ReadOnlyInterceptor blocks every write while that token is
   * in use. Same-tenant only; cannot impersonate yourself or a
   * deactivated user. Returns the token (the controller sets it as the
   * access cookie) + the target's display info for the FE banner.
   */
  async startImpersonation(
    adminUserId: string,
    tenantId: string,
    targetUserId: string,
    reqContext?: { ip?: string | null },
  ): Promise<{ accessToken: string; target: { id: string; name: string; email: string } }> {
    if (adminUserId === targetUserId) {
      throw new BadRequestException('You cannot impersonate yourself.');
    }
    const target = await this.userRepo.findOne({
      where: { id: targetUserId, tenant_id: tenantId, is_deleted: false },
    });
    if (!target) {
      throw new NotFoundException('User not found in this tenant.');
    }
    if (!target.is_active) {
      throw new BadRequestException('Cannot impersonate a deactivated user.');
    }

    const payload: JwtPayload = {
      sub: target.id,
      email: target.email,
      tenant_id: tenantId,
      impersonator_id: adminUserId,
      read_only: true,
    };
    const accessToken = this.jwtService.sign(payload);

    // Auth-event log (security.md A09). A view-as session is a sensitive
    // action; record who acted as whom. Writes are blocked during the
    // session, so this start event is the audit anchor.
    this.logger.warn(
      `IMPERSONATION START: admin=${adminUserId} → target=${targetUserId} tenant=${tenantId} ip=${reqContext?.ip ?? '-'}`,
    );

    return {
      accessToken,
      target: { id: target.id, name: target.name, email: target.email },
    };
  }

  /**
   * End a view-as session: mint a fresh access token for the original
   * admin (read from the view-as token's `impersonator_id`). The
   * controller sets it back as the access cookie. Validates the admin
   * still exists and is active.
   */
  async stopImpersonation(
    adminUserId: string,
    tenantId: string,
    reqContext?: { ip?: string | null },
  ): Promise<{ accessToken: string }> {
    const admin = await this.userRepo.findOne({
      where: { id: adminUserId, tenant_id: tenantId, is_deleted: false },
    });
    if (!admin || !admin.is_active) {
      throw new UnauthorizedException(
        'Your original session is no longer valid — please log in again.',
      );
    }
    const payload: JwtPayload = {
      sub: admin.id,
      email: admin.email,
      tenant_id: tenantId,
    };
    const accessToken = this.jwtService.sign(payload);
    this.logger.warn(
      `IMPERSONATION STOP: admin=${adminUserId} tenant=${tenantId} ip=${reqContext?.ip ?? '-'}`,
    );
    return { accessToken };
  }

  async validateUserFromToken(payload: JwtPayload): Promise<UserEntity | null> {
    return this.userRepo.findOne({
      where: {
        id: payload.sub,
        tenant_id: payload.tenant_id,
        is_deleted: false,
        is_active: true,
      },
      relations: {
        role: true,
      },
    });
  }

  async getProfile(userId: string, tenantId: string) {
    const user = await this.userRepo.findOne({
      where: {
        id: userId,
        tenant_id: tenantId,
        is_deleted: false,
      },
      relations: {
        role: true,
      },
    });

    if (!user) {
      throw new UnauthorizedException(MSG.NOT_FOUND);
    }

    const permissions = user.role_id
      ? await this.getPermissionsMap(user.role_id, user.tenant_id)
      : {};

    // Workspace hydration — every authenticated response carries the
    // tenant's destinations (with current USD rates + base currency)
    // plus the user's picker preference. Frontend's <DestinationPicker>
    // + useWorkspace() read from this, no per-page lookup needed.
    const destinations = await this.userRepo.manager.query(
      `
      SELECT d.id, d.name, d.short_code, d.country, d.city,
             d.currency_id, d.usd_to_base_rate, d.rate_updated_at,
             c.code AS base_currency_code, c.symbol AS base_currency_symbol
      FROM destinations d
      LEFT JOIN currencies c ON c.id = d.currency_id
      WHERE d.tenant_id = $1 AND d.is_deleted = false
      ORDER BY d.name ASC
    `,
      [tenantId],
    );

    return {
      id: user.id,
      name: user.name,
      email: user.email,
      phone: user.phone,
      tenant_id: user.tenant_id,
      role: user.role?.name || null,
      role_id: user.role_id || null,
      permissions,
      view_all_bookings_queries: user.role?.view_all_bookings_queries === true,
      // Workspace
      active_destination_id:
        (user as { active_destination_id?: string | null }).active_destination_id ?? null,
      destinations,
    };
  }

  // ── Password reset ──────────────────────────────────────────────
  // Stakeholder policy locked 2026-05-08 (Q1=A Brevo / Q2=A magic
  // link / Q3=security.md defaults / Q4=enumeration-safe).
  //
  // Token shape:
  //   raw    32 bytes (256 bits) of randomness, base64url-encoded
  //          → ~43 chars; only ever held in memory long enough to
  //          email it.
  //   hash   SHA-256 hex digest of the raw token; what we persist
  //          and lookup by. Constant-time-safe via the 64-char hex
  //          equality check inside Postgres.
  //
  // Lifecycle:
  //   - 30-min expiry per security.md A07
  //   - Single-use: used_at filled on first redemption
  //   - Prior unused tokens for the same user are soft-invalidated
  //     when a new request comes in (avoids race + lets the user
  //     re-request without their stale link still working)

  private static readonly RESET_TOKEN_BYTES = 32;
  private static readonly RESET_TOKEN_TTL_MIN = 30;

  private hashResetToken(raw: string): string {
    return crypto.createHash('sha256').update(raw).digest('hex');
  }

  /**
   * Initiate a password reset for `email` on the given tenant.
   *
   * Per Q4 (security.md A07 — account enumeration): always returns
   * the same generic shape, regardless of whether the email exists,
   * the user is deactivated, or the mailer fails. Errors are logged
   * server-side; the API never reveals user existence.
   *
   * Per security.md A09: emits an audit log entry on the auth event
   * channel for every request, success or not.
   */
  async forgotPassword(
    dto: ForgotPasswordDto,
    tenantId: string,
    ctx: { ip?: string | null; userAgent?: string | null; resetUrlBase: string },
  ): Promise<void> {
    const email = dto.email.toLowerCase().trim();

    // Resolve user. Missing user / deactivated user / mailer failure
    // all fall through to the same generic response — the controller
    // doesn't distinguish.
    const user = await this.userRepo.findOne({
      where: { email, tenant_id: tenantId, is_deleted: false },
      relations: {
        role: true,
      },
    });

    if (!user || !user.is_active) {
      this.logger.warn(
        `password.reset_request: ignored (no active user) email=${email} tenant=${tenantId} ip=${ctx.ip || ''}`,
      );
      return; // generic "we sent it if it exists" — caller emits 200
    }

    // Soft-invalidate any prior unused tokens for this user. Keeps
    // the redeem-by-hash lookup unique-ish per user and stops a
    // user from accidentally redeeming a stale link from a previous
    // request. Marking is_deleted=true rather than DELETE so the
    // history stays auditable.
    await this.resetRepo.update(
      { user_id: user.id, used_at: IsNull(), is_deleted: false },
      { is_deleted: true },
    );

    const rawToken = crypto.randomBytes(AuthService.RESET_TOKEN_BYTES).toString('base64url');
    const tokenHash = this.hashResetToken(rawToken);
    const expiresAt = new Date(Date.now() + AuthService.RESET_TOKEN_TTL_MIN * 60 * 1000);

    await this.resetRepo.save({
      tenant_id: tenantId,
      user_id: user.id,
      token_hash: tokenHash,
      expires_at: expiresAt,
      used_at: null,
      request_ip: ctx.ip || null,
      request_user_agent: ctx.userAgent || null,
    });

    // Build the magic link. resetUrlBase comes from the controller
    // (resolved from the tenant subdomain so the user lands on the
    // right tenant's reset page).
    const resetUrl = `${ctx.resetUrlBase}?token=${encodeURIComponent(rawToken)}`;

    // Company name from Settings → General for the email letterhead.
    // Falls back to the onboarding tenant name, then the platform name.
    const settings = await this.settingsRepo.findOne({
      where: { tenant_id: tenantId, is_deleted: false },
      select: {
        company_name: true,
      },
    });
    const tenant = settings?.company_name
      ? null
      : await this.tenantRepo.findOne({
          where: { id: tenantId },
          select: {
            name: true,
          },
        });
    const companyName = settings?.company_name || tenant?.name || 'DMC Ops';

    const { html, text } = passwordResetTemplate({
      recipientName: user.name,
      resetUrl,
      expiresIn: '30 minutes',
      companyName,
    });

    try {
      await this.mailer.send({
        to: { email: user.email, name: user.name },
        subject: `Reset your ${companyName} password`,
        html,
        text,
        fromName: companyName,
      });
      this.logger.log(
        `password.reset_request: sent user=${user.id} tenant=${tenantId} ip=${ctx.ip || ''}`,
      );
    } catch (err) {
      // Per Q4: swallow the mail failure — generic success response
      // either way. Logged loudly for ops to triage Brevo / SES /
      // SMTP outages without leaking the email's existence.
      this.logger.error(
        `password.reset_request: mailer failed user=${user.id} tenant=${tenantId} err=${(err as Error).message}`,
      );
    }
  }

  /**
   * Consume a reset token and set a new password.
   *
   * Strictly token-scoped — no email or tenant_id input from the
   * client. The token itself carries the (tenant, user) identity
   * since lookups go by token_hash and the row stores tenant_id +
   * user_id. Prevents cross-tenant token redemption even on a
   * subdomain-spoofed request.
   *
   * Throws BadRequestException for any invalid path (bad / used /
   * expired / unknown token). Same generic message — never
   * differentiates so an attacker can't probe a token state.
   */
  async resetPassword(
    dto: ResetPasswordDto,
    ctx: { ip?: string | null; userAgent?: string | null },
  ): Promise<void> {
    const tokenHash = this.hashResetToken(dto.token);

    const row = await this.resetRepo.findOne({
      where: {
        token_hash: tokenHash,
        used_at: IsNull(),
        is_deleted: false,
        expires_at: MoreThan(new Date()),
      },
    });

    if (!row) {
      this.logger.warn(`password.reset_redeem: rejected (no match) ip=${ctx.ip || ''}`);
      throw new BadRequestException('This reset link is invalid or has expired.');
    }

    const user = await this.userRepo.findOne({
      where: { id: row.user_id, tenant_id: row.tenant_id, is_deleted: false },
    });
    if (!user || !user.is_active) {
      this.logger.warn(
        `password.reset_redeem: rejected (user inactive) user=${row.user_id} tenant=${row.tenant_id}`,
      );
      throw new BadRequestException('This reset link is invalid or has expired.');
    }

    const passwordHash = await bcrypt.hash(dto.new_password, 12);

    await this.userRepo.update(user.id, { password_hash: passwordHash });
    await this.resetRepo.update(row.id, { used_at: new Date() });

    // Login lockout reset — if the account was lockout-flagged in
    // Redis, the new password makes the lockout meaningless, so
    // wipe it preemptively. Avoids "I just reset, why am I locked?"
    await this.clearFailedAttempts(user.tenant_id, user.email);

    this.logger.log(
      `password.reset_redeem: success user=${user.id} tenant=${user.tenant_id} ip=${ctx.ip || ''}`,
    );
  }

  // ── Refresh-token session store ─────────────────────────────────
  // Per security.md A02 (refresh ≤ 7d, single-use, stored hashed) and
  // the OWASP "rotation with reuse detection" pattern.
  //
  // Token shape:
  //   raw    32 bytes (256 bits) base64url → ~43 chars, only ever
  //          returned to the caller once (as an httpOnly cookie).
  //   hash   SHA-256 hex; what we persist and look up by.
  //
  // Rotation invariants:
  //   - Every rotation issues a NEW row with the same family_id.
  //   - The old row's used_at is set atomically before the new row
  //     is returned; if the caller loses the response mid-flight,
  //     the family is left dangling but not vulnerable (rotating a
  //     used token triggers reuse-detection revocation).
  //   - Reuse detection: if a used_at IS NOT NULL row is presented,
  //     we revoke the entire family (revoked_at set on every row).
  //     Legitimate holder AND attacker are forced to re-login. This
  //     is intentional — you can't tell them apart.

  private static readonly REFRESH_TOKEN_BYTES = 32;

  private hashRefreshToken(raw: string): string {
    return crypto.createHash('sha256').update(raw).digest('hex');
  }

  /**
   * Issue a fresh refresh token row and return the raw token string.
   * Caller (controller) puts it in an httpOnly cookie — never logs or
   * returns it in the response body.
   */
  async issueRefreshToken(
    user: { id: string; tenant_id: string },
    ctx: { ip?: string | null; userAgent?: string | null; familyId?: string },
  ): Promise<{ raw: string; familyId: string; expiresAt: Date }> {
    const raw = crypto.randomBytes(AuthService.REFRESH_TOKEN_BYTES).toString('base64url');
    const familyId = ctx.familyId ?? crypto.randomUUID();
    const ttl = refreshTtlSeconds(this.configService);
    const expiresAt = new Date(Date.now() + ttl * 1000);

    await this.refreshRepo.save({
      tenant_id: user.tenant_id,
      user_id: user.id,
      family_id: familyId,
      token_hash: this.hashRefreshToken(raw),
      expires_at: expiresAt,
      used_at: null,
      revoked_at: null,
      issued_ip: ctx.ip || null,
      issued_user_agent: ctx.userAgent || null,
    });

    return { raw, familyId, expiresAt };
  }

  /**
   * Rotate a refresh token — validate, mark used, issue new, return
   * a fresh access token + refresh token pair.
   *
   * Throws UnauthorizedException for any invalid state (unknown /
   * expired / already-used / revoked). Same generic message so an
   * attacker can't probe which case they hit.
   *
   * On reuse detection (used_at IS NOT NULL), revokes the whole
   * family before throwing.
   */
  async rotateRefreshToken(
    rawToken: string,
    ctx: { ip?: string | null; userAgent?: string | null },
  ): Promise<{
    accessToken: string;
    refreshToken: string;
    refreshExpiresAt: Date;
    user: UserEntity;
  }> {
    const tokenHash = this.hashRefreshToken(rawToken);
    const row = await this.refreshRepo.findOne({
      where: { token_hash: tokenHash, is_deleted: false },
    });

    if (!row) {
      this.logger.warn(`auth.refresh: rejected (no match) ip=${ctx.ip || ''}`);
      throw new UnauthorizedException('Invalid session');
    }

    // Reuse detection — used token being presented again = family
    // compromised. Revoke everything under the family.
    if (row.used_at) {
      this.logger.warn(
        `auth.refresh: REUSE DETECTED family=${row.family_id} user=${row.user_id} ip=${ctx.ip || ''}`,
      );
      await this.refreshRepo.update(
        { family_id: row.family_id, revoked_at: IsNull() },
        { revoked_at: new Date() },
      );
      throw new UnauthorizedException('Invalid session');
    }

    if (row.revoked_at || row.expires_at.getTime() <= Date.now()) {
      throw new UnauthorizedException('Invalid session');
    }

    const user = await this.userRepo.findOne({
      where: {
        id: row.user_id,
        tenant_id: row.tenant_id,
        is_deleted: false,
        is_active: true,
      },
      relations: { role: true },
    });
    if (!user) {
      throw new UnauthorizedException('Invalid session');
    }

    // Mark used, then issue a new row in the same family.
    await this.refreshRepo.update(row.id, { used_at: new Date() });

    const { raw: newRaw, expiresAt } = await this.issueRefreshToken(user, {
      ip: ctx.ip,
      userAgent: ctx.userAgent,
      familyId: row.family_id,
    });

    const accessToken = this.jwtService.sign({
      sub: user.id,
      email: user.email,
      tenant_id: user.tenant_id,
    } satisfies JwtPayload);

    return {
      accessToken,
      refreshToken: newRaw,
      refreshExpiresAt: expiresAt,
      user,
    };
  }

  /**
   * Revoke a single refresh token (logout). Silent — never throws on
   * unknown tokens (logout of an already-expired session should
   * still clear cookies cleanly).
   */
  async revokeRefreshToken(rawToken: string | null | undefined): Promise<void> {
    if (!rawToken) return;
    const tokenHash = this.hashRefreshToken(rawToken);
    await this.refreshRepo.update(
      { token_hash: tokenHash, revoked_at: IsNull() },
      { revoked_at: new Date() },
    );
  }
}
