/**
 * Admin auth orchestration (PLANNING.md §8). Controllers stay HTTP-only; this is
 * the testable part. Login failures are deliberately uniform — same message,
 * roughly the same latency — so the endpoint is not a phone-enumeration oracle.
 */
import { randomBytes } from 'node:crypto';
import { AppError } from '../../lib/AppError';
import { env } from '../../env';
import { logger } from '../../lib/logger';
import { hashIp, hashToken } from '../../lib/hash';
import { fromSqlUtc } from '../../lib/dates';
import { createWindowLimiter } from '../../lib/rateLimit';
import { renderTemplate, sendMail } from '../../lib/mailer';
import { passwordResetTemplate } from '../../emails/passwordReset';
import { adminInviteTemplate } from '../../emails/adminInvite';
import { normalizePhone } from '../../lib/phone';
import {
  clearLockout,
  consumeResetToken,
  findAdminByEmail,
  findAdminById,
  findAdminByPhone,
  findValidResetToken,
  insertResetToken,
  invalidatePriorResetTokens,
  recordFailedLogin,
  recordSuccessfulLogin,
  updateAdminPassword,
  type AdminUserRow,
} from './auth.repository';
import { DUMMY_HASH, hashPassword, verifyPassword } from './password.service';
import {
  issueRefreshToken,
  revokeAllForUser,
  revokeRefreshToken,
  rotateRefreshToken,
  signAccessToken,
} from './token.service';

const WINDOW_MS = 15 * 60 * 1000;
// IP limit is the broad backstop against credential-spraying many accounts from
// one host; the per-phone limit sits just above the 10-failure account lockout,
// so for a focused attack on one account the persistent lockout is what bites
// first and the transient 429 is only a velocity brake (PLANNING.md §8).
const IP_LIMIT = 30;
const PHONE_LIMIT = 12;
const LOCK_THRESHOLD = 10;
const LOCK_MINUTES = 15;
const RESET_TTL_SECONDS = 60 * 60;
const INVITE_TTL_SECONDS = 72 * 60 * 60;

const loginLimiter = createWindowLimiter();

const GENERIC_LOGIN_FAILURE = 'Invalid phone or password';

export interface AuthCtx {
  ip: string;
  userAgent?: string;
}

export interface PublicAdmin {
  id: string;
  name: string;
  phone: string;
  email: string | null;
  role: 'superadmin' | 'admin';
  status: 'active' | 'inactive';
}

function publicAdmin(row: AdminUserRow): PublicAdmin {
  return {
    id: row.id,
    name: row.name,
    phone: row.phone_display,
    email: row.email,
    role: row.role,
    status: row.status,
  };
}

function isLocked(row: AdminUserRow): boolean {
  if (!row.locked_until) return false;
  return fromSqlUtc(row.locked_until).getTime() > Date.now();
}

export interface SessionResult {
  accessToken: string;
  refreshToken: string;
  refreshExpiresAt: Date;
  admin: PublicAdmin;
}

export async function login(
  phoneInput: string,
  password: string,
  ctx: AuthCtx,
): Promise<SessionResult> {
  const ipHash = hashIp(ctx.ip);

  if (!loginLimiter.hit(`login:ip:${ipHash}`, IP_LIMIT, WINDOW_MS).allowed) {
    throw new AppError(429, 'Too many attempts. Please try again later.', { expose: true });
  }

  // A malformed phone is just a failed login, not a 400 — no oracle.
  let e164: string;
  try {
    e164 = normalizePhone(phoneInput).e164;
  } catch {
    await verifyPassword(password, DUMMY_HASH);
    throw AppError.unauthorized(GENERIC_LOGIN_FAILURE);
  }

  if (!loginLimiter.hit(`login:ph:${e164}`, PHONE_LIMIT, WINDOW_MS).allowed) {
    throw new AppError(429, 'Too many attempts. Please try again later.', { expose: true });
  }

  const row = await findAdminByPhone(e164);

  if (!row || !row.password_hash || row.status !== 'active' || isLocked(row)) {
    // Equalise timing whether or not the account exists / is usable.
    await verifyPassword(password, row?.password_hash ?? DUMMY_HASH);
    throw AppError.unauthorized(GENERIC_LOGIN_FAILURE);
  }

  if (!(await verifyPassword(password, row.password_hash))) {
    const attempts = row.failed_attempts + 1;
    await recordFailedLogin(row.id, attempts, attempts >= LOCK_THRESHOLD ? LOCK_MINUTES : null);
    throw AppError.unauthorized(GENERIC_LOGIN_FAILURE);
  }

  await recordSuccessfulLogin(row.id);
  loginLimiter.reset(`login:ip:${ipHash}`);
  loginLimiter.reset(`login:ph:${e164}`);

  return buildSession(row, ctx, ipHash);
}

export async function refresh(rawRefreshToken: string, ctx: AuthCtx): Promise<SessionResult> {
  const rotated = await rotateRefreshToken(rawRefreshToken, {
    userAgent: ctx.userAgent,
    ipHash: hashIp(ctx.ip),
  });
  const row = await findAdminById(rotated.adminUserId);
  if (!row || row.status !== 'active') throw AppError.unauthorized('Session no longer valid');

  return {
    accessToken: signAccessToken(row),
    refreshToken: rotated.raw,
    refreshExpiresAt: rotated.expiresAt,
    admin: publicAdmin(row),
  };
}

export async function logout(rawRefreshToken: string | undefined): Promise<void> {
  if (rawRefreshToken) await revokeRefreshToken(rawRefreshToken);
}

export async function forgotPassword(emailInput: string): Promise<void> {
  // admin_users.email is case-insensitive (utf8mb4_..._ci collation), so no
  // manual case-folding is needed here.
  const row = await findAdminByEmail(emailInput.trim());
  if (!row || row.status !== 'active' || !row.email) return;

  await issueSetupToken({ id: row.id, name: row.name, email: row.email }, 'reset');
}

export type SetupPurpose = 'reset' | 'invite';

/**
 * Mint a single-use set-password token and email the link (PLANNING.md §8).
 * Shared by forgot-password ('reset', 60 min), admin-user creation ('invite',
 * 72 h), and superadmin force-reset ('reset'). The consuming endpoint is
 * `POST /admin/auth/reset-password` — it does not care which purpose minted the
 * token. An email failure is logged, not thrown (the token is already stored).
 */
export async function issueSetupToken(
  admin: { id: string; name: string; email: string },
  purpose: SetupPurpose,
): Promise<void> {
  const ttlSeconds = purpose === 'invite' ? INVITE_TTL_SECONDS : RESET_TTL_SECONDS;

  await invalidatePriorResetTokens(admin.id);
  const raw = randomBytes(32).toString('base64url');
  await insertResetToken({
    adminUserId: admin.id,
    tokenHash: hashToken(raw),
    purpose,
    expiresInSeconds: ttlSeconds,
  });

  const url = `${env.ADMIN_PORTAL_URL.replace(/\/+$/, '')}/reset-password?token=${raw}`;
  if (env.NODE_ENV !== 'production') {
    logger.debug({ adminUserId: admin.id, purpose, url }, `${purpose} link (dev only)`);
  }

  const message =
    purpose === 'invite'
      ? {
          subject: "You've been invited to the Genesis admin portal",
          html: renderTemplate(adminInviteTemplate, {
            name: admin.name,
            url,
            expiryHours: INVITE_TTL_SECONDS / 3600,
          }),
        }
      : {
          subject: 'Reset your Genesis admin password',
          html: renderTemplate(passwordResetTemplate, {
            name: admin.name,
            resetUrl: url,
            expiryMinutes: RESET_TTL_SECONDS / 60,
          }),
        };

  try {
    await sendMail({ to: admin.email, ...message });
  } catch (err) {
    logger.error({ err, adminUserId: admin.id, purpose }, 'setup-token email failed');
  }
}

export async function resetPassword(token: string, newPassword: string): Promise<void> {
  const match = await findValidResetToken(hashToken(token));
  if (!match) throw AppError.badRequest('Invalid or expired token');

  await updateAdminPassword(match.admin_user_id, await hashPassword(newPassword));
  await consumeResetToken(match.id);
  await revokeAllForUser(match.admin_user_id);
  await clearLockout(match.admin_user_id);
}

export async function me(adminId: string): Promise<PublicAdmin> {
  const row = await findAdminById(adminId);
  if (!row) throw AppError.unauthorized();
  return publicAdmin(row);
}

async function buildSession(
  row: AdminUserRow,
  ctx: AuthCtx,
  ipHash: string,
): Promise<SessionResult> {
  const refreshToken = await issueRefreshToken(row.id, {
    userAgent: ctx.userAgent,
    ipHash,
  });
  return {
    accessToken: signAccessToken(row),
    refreshToken: refreshToken.raw,
    refreshExpiresAt: refreshToken.expiresAt,
    admin: publicAdmin(row),
  };
}
