import { and, eq, gt, isNull } from 'drizzle-orm';

import { db } from '@/lib/db';
import { clients } from '@/lib/db/schema/clients';
import { ports } from '@/lib/db/schema/ports';
import { portalAuthTokens, portalUsers } from '@/lib/db/schema/portal';
import { systemSettings } from '@/lib/db/schema/system';
import { env } from '@/lib/env';
import { sendEmail } from '@/lib/email';
import { activationEmail, resetEmail } from '@/lib/email/templates/portal-auth';
import { loadSubjectOverride } from '@/lib/email/template-overrides';
import { getBrandingShell } from '@/lib/email/branding-resolver';
import {
  CodedError,
  ConflictError,
  NotFoundError,
  UnauthorizedError,
  ValidationError,
} from '@/lib/errors';
import { logger } from '@/lib/logger';
import { createPortalToken } from '@/lib/portal/auth';
import { hashPassword, hashToken, mintToken, verifyPassword } from '@/lib/portal/passwords';
import { createAuditLog } from '@/lib/audit';

const ACTIVATION_TOKEN_TTL_HOURS = 72;
const RESET_TOKEN_TTL_MINUTES = 30;
const MIN_PASSWORD_LENGTH = 9;
const PORTAL_ENABLED_KEY = 'client_portal_enabled';

/**
 * Per-port toggle for the client portal feature. Default-on so existing
 * deployments behave the way they did before this setting existed.
 */
export async function isPortalEnabledForPort(portId: string): Promise<boolean> {
  const row = await db.query.systemSettings.findFirst({
    where: and(eq(systemSettings.key, PORTAL_ENABLED_KEY), eq(systemSettings.portId, portId)),
  });
  if (!row) return true;
  return row.value === true || row.value === 'true';
}

/**
 * Route-level gate for the (portal) layout. Returns true when every
 * configured per-port `client_portal_enabled` row evaluates to false,
 * i.e. the portal is off everywhere. In single-port deployments this is
 * exactly "the portal is off" (the admin toggle in System Settings).
 *
 * Used to render a "Portal not available" notice instead of the login
 * form when the kill switch is flipped, so guessed `/portal/*` URLs
 * don't surface a working-looking form that just rejects every submit.
 *
 * Default-OFF (returns false) when there are no setting rows - preserves
 * the legacy default-on behaviour for fresh installs / ports that never
 * touched the setting.
 *
 * For future multi-port routing (subdomain-per-port or path-prefix),
 * callers should pass the resolved portId to `isPortalEnabledForPort`
 * instead and not rely on the all-ports-off heuristic here.
 */
export async function isPortalDisabledGlobally(): Promise<boolean> {
  const rows = await db
    .select({ value: systemSettings.value })
    .from(systemSettings)
    .where(eq(systemSettings.key, PORTAL_ENABLED_KEY));
  if (rows.length === 0) return false;
  return rows.every((r) => r.value === false || r.value === 'false');
}

// ─── Admin-side: invite a client to the portal ───────────────────────────────

export async function createPortalUser(args: {
  clientId: string;
  portId: string;
  email: string;
  name?: string;
  createdBy: string;
}): Promise<{ portalUserId: string }> {
  const normalizedEmail = args.email.toLowerCase().trim();

  const client = await db.query.clients.findFirst({
    where: and(eq(clients.id, args.clientId), eq(clients.portId, args.portId)),
  });
  if (!client) throw new NotFoundError('Client');

  if (!(await isPortalEnabledForPort(args.portId))) {
    throw new ConflictError('Client portal is disabled for this port');
  }

  // Email uniqueness check is enforced at the DB level too, but we do a
  // friendlier preflight so the admin sees a clear conflict error.
  const existing = await db.query.portalUsers.findFirst({
    where: eq(portalUsers.email, normalizedEmail),
  });
  if (existing) {
    throw new ConflictError(`A portal user already exists for ${normalizedEmail}`);
  }

  const [user] = await db
    .insert(portalUsers)
    .values({
      portId: args.portId,
      clientId: args.clientId,
      email: normalizedEmail,
      name: args.name ?? client.fullName,
      createdBy: args.createdBy,
    })
    .returning({ id: portalUsers.id });

  if (!user) {
    throw new CodedError('INSERT_RETURNING_EMPTY', {
      internalMessage: 'Failed to create portal user',
    });
  }

  await issueActivationToken(user.id, normalizedEmail, args.portId);

  void createAuditLog({
    portId: args.portId,
    userId: args.createdBy,
    action: 'portal_invite',
    entityType: 'portal_user',
    entityId: user.id,
    metadata: { clientId: args.clientId, email: normalizedEmail },
  });

  return { portalUserId: user.id };
}

async function issueActivationToken(
  portalUserId: string,
  email: string,
  portId: string,
): Promise<void> {
  const { raw, hash } = mintToken();
  const expiresAt = new Date(Date.now() + ACTIVATION_TOKEN_TTL_HOURS * 3600 * 1000);

  await db.insert(portalAuthTokens).values({
    portalUserId,
    tokenHash: hash,
    type: 'activation',
    expiresAt,
  });

  const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
  const portName = port?.name ?? 'Port Nimara';

  // URL fragment (#token=…) instead of query string - keeps the
  // activation token out of server logs, proxy logs, Referer header,
  // and any CDN/edge cache. The portal /activate page reads the token
  // client-side via `window.location.hash`. See PRE-DEPLOY-PLAN § 1.2.5.
  const link = `${env.APP_URL}/portal/activate#token=${encodeURIComponent(raw)}`;
  const subjectOverride = await loadSubjectOverride(portId, 'portal_activation');
  const branding = await getBrandingShell(portId);
  const { subject, html, text } = await activationEmail(
    {
      portName,
      link,
      ttlHours: ACTIVATION_TOKEN_TTL_HOURS,
    },
    { subject: subjectOverride, branding },
  );

  try {
    // M-EM01: pass portId so per-port SMTP is used. Without it the call
    // falls back to the global SMTP transport and the from-address won't
    // carry the port's branding.
    await sendEmail(email, subject, html, undefined, text, portId);
  } catch (err) {
    logger.error({ err, email }, 'Failed to send portal activation email');
    // Re-throw - the admin should know if their invite mail bounced.
    throw err;
  }
}

export async function resendActivation(portalUserId: string, portId: string): Promise<void> {
  if (!(await isPortalEnabledForPort(portId))) {
    throw new ConflictError('Client portal is disabled for this port');
  }
  const user = await db.query.portalUsers.findFirst({
    where: and(eq(portalUsers.id, portalUserId), eq(portalUsers.portId, portId)),
  });
  if (!user) throw new NotFoundError('Portal user');
  if (user.passwordHash) {
    throw new ConflictError('Portal user has already activated their account');
  }
  await issueActivationToken(user.id, user.email, user.portId);

  void createAuditLog({
    portId: user.portId,
    userId: null,
    action: 'resend_invite',
    entityType: 'portal_user',
    entityId: user.id,
    metadata: { email: user.email },
  });
}

// ─── Self-service password change (logged-in portal user) ───────────────────

export async function changePortalPassword(args: {
  portalUserId: string;
  currentPassword: string;
  newPassword: string;
}): Promise<void> {
  if (args.newPassword.length < MIN_PASSWORD_LENGTH) {
    throw new ValidationError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`);
  }
  const user = await db.query.portalUsers.findFirst({
    where: eq(portalUsers.id, args.portalUserId),
  });
  if (!user || !user.isActive || !user.passwordHash) {
    throw new UnauthorizedError('Account not found');
  }
  const ok = await verifyPassword(args.currentPassword, user.passwordHash);
  if (!ok) {
    void createAuditLog({
      userId: null,
      portId: user.portId,
      action: 'password_change',
      entityType: 'portal_user',
      entityId: user.id,
      metadata: { ok: false, reason: 'wrong_current_password' },
      severity: 'warning',
      source: 'auth',
    });
    throw new UnauthorizedError('Current password is incorrect');
  }
  const passwordHash = await hashPassword(args.newPassword);
  await db
    .update(portalUsers)
    .set({ passwordHash, passwordChangedAt: new Date(), updatedAt: new Date() })
    .where(eq(portalUsers.id, user.id));

  void createAuditLog({
    userId: null,
    portId: user.portId,
    action: 'password_change',
    entityType: 'portal_user',
    entityId: user.id,
    metadata: { ok: true },
    severity: 'info',
    source: 'auth',
  });
}

// ─── Activation: client sets their initial password ──────────────────────────

export async function activateAccount(rawToken: string, password: string): Promise<void> {
  if (password.length < MIN_PASSWORD_LENGTH) {
    throw new ValidationError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`);
  }
  const tokenRow = await consumeToken(rawToken, 'activation');
  const portalUser = await db.query.portalUsers.findFirst({
    where: eq(portalUsers.id, tokenRow.portalUserId),
  });
  if (!portalUser) throw new ValidationError('Invalid or expired token');
  if (!(await isPortalEnabledForPort(portalUser.portId))) {
    throw new ValidationError('Client portal is disabled for this port');
  }
  const passwordHash = await hashPassword(password);
  await db
    .update(portalUsers)
    .set({ passwordHash, passwordChangedAt: new Date(), updatedAt: new Date() })
    .where(eq(portalUsers.id, tokenRow.portalUserId));

  void createAuditLog({
    portId: portalUser.portId,
    userId: null,
    action: 'portal_activate',
    entityType: 'portal_user',
    entityId: portalUser.id,
  });
}

// ─── Sign in (email + password) ──────────────────────────────────────────────

export async function signIn(args: {
  email: string;
  password: string;
}): Promise<{ token: string; clientId: string; portId: string; email: string }> {
  const normalizedEmail = args.email.toLowerCase().trim();

  // Always do the same amount of work regardless of whether the email
  // exists, so timing doesn't leak account presence.
  const user = await db.query.portalUsers.findFirst({
    where: eq(portalUsers.email, normalizedEmail),
  });

  // Dummy hash with the right shape - used to keep verifyPassword's compute
  // cost identical when the user doesn't exist.
  const dummyHash =
    '0000000000000000000000000000000000000000000000000000000000000000:00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000';

  const ok = user?.passwordHash
    ? await verifyPassword(args.password, user.passwordHash)
    : (await verifyPassword(args.password, dummyHash), false);

  if (!user || !user.isActive || !user.passwordHash || !ok) {
    void createAuditLog({
      userId: null,
      portId: user?.portId ?? null,
      action: 'login',
      entityType: 'portal_session',
      entityId: user?.id ?? normalizedEmail,
      metadata: { ok: false, attemptedEmail: normalizedEmail, reason: 'invalid_credentials' },
      severity: 'warning',
      source: 'auth',
    });
    throw new UnauthorizedError('Invalid email or password');
  }

  // Disabled-port check happens AFTER the credential check so that a wrong
  // password on a disabled-port account still surfaces "invalid email or
  // password" - we never leak which ports have the portal turned off.
  if (!(await isPortalEnabledForPort(user.portId))) {
    void createAuditLog({
      userId: null,
      portId: user.portId,
      action: 'login',
      entityType: 'portal_session',
      entityId: user.id,
      metadata: { ok: false, attemptedEmail: normalizedEmail, reason: 'portal_disabled' },
      severity: 'warning',
      source: 'auth',
    });
    throw new UnauthorizedError('Invalid email or password');
  }

  const token = await createPortalToken({
    portalUserId: user.id,
    clientId: user.clientId,
    portId: user.portId,
    email: user.email,
  });

  await db.update(portalUsers).set({ lastLoginAt: new Date() }).where(eq(portalUsers.id, user.id));

  void createAuditLog({
    userId: null,
    portId: user.portId,
    action: 'login',
    entityType: 'portal_session',
    entityId: user.id,
    metadata: { ok: true, email: user.email },
    severity: 'info',
    source: 'auth',
  });

  return { token, clientId: user.clientId, portId: user.portId, email: user.email };
}

// ─── Forgot password ─────────────────────────────────────────────────────────

export async function requestPasswordReset(email: string): Promise<void> {
  const normalizedEmail = email.toLowerCase().trim();

  const user = await db.query.portalUsers.findFirst({
    where: eq(portalUsers.email, normalizedEmail),
  });

  if (!user || !user.isActive) {
    // Silently no-op so unknown emails don't leak through timing or
    // response shape. Caller surfaces "if the email matches an account…".
    logger.debug({ email: normalizedEmail }, 'Password reset for unknown email');
    void createAuditLog({
      userId: null,
      portId: null,
      action: 'portal_password_reset_request',
      entityType: 'portal_user',
      entityId: 'unknown',
      metadata: { email: normalizedEmail, reason: 'unknown_or_inactive' },
      severity: 'warning',
      source: 'auth',
    });
    return;
  }

  // Same silent no-op when the port has the portal disabled - keeps the
  // disabled-state from leaking through the public reset endpoint.
  if (!(await isPortalEnabledForPort(user.portId))) {
    logger.debug({ portId: user.portId }, 'Password reset on disabled-portal port');
    void createAuditLog({
      userId: null,
      portId: user.portId,
      action: 'portal_password_reset_request',
      entityType: 'portal_user',
      entityId: user.id,
      metadata: { email: normalizedEmail, reason: 'portal_disabled' },
      severity: 'warning',
      source: 'auth',
    });
    return;
  }

  const { raw, hash } = mintToken();
  const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MINUTES * 60 * 1000);

  await db.insert(portalAuthTokens).values({
    portalUserId: user.id,
    tokenHash: hash,
    type: 'reset',
    expiresAt,
  });

  void createAuditLog({
    portId: user.portId,
    userId: null,
    action: 'portal_password_reset_request',
    entityType: 'portal_user',
    entityId: user.id,
    metadata: { email: user.email },
  });

  const port = await db.query.ports.findFirst({ where: eq(ports.id, user.portId) });
  const portName = port?.name ?? 'Port Nimara';
  // Same URL-fragment treatment as activation links - token never
  // travels server-side. See PRE-DEPLOY-PLAN § 1.2.5.
  const link = `${env.APP_URL}/portal/reset-password#token=${encodeURIComponent(raw)}`;
  const subjectOverride = await loadSubjectOverride(user.portId, 'portal_reset');
  const branding = await getBrandingShell(user.portId);
  const { subject, html, text } = await resetEmail(
    {
      portName,
      link,
      ttlMinutes: RESET_TOKEN_TTL_MINUTES,
    },
    { subject: subjectOverride, branding },
  );

  try {
    // M-EM01: pass portId so per-port SMTP + from-address are used.
    await sendEmail(user.email, subject, html, undefined, text, user.portId);
  } catch (err) {
    logger.error({ err, email: user.email }, 'Failed to send password-reset email');
    // Don't propagate - the public route returns 200 either way.
  }
}

export async function resetPassword(rawToken: string, password: string): Promise<void> {
  if (password.length < MIN_PASSWORD_LENGTH) {
    throw new ValidationError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`);
  }
  const tokenRow = await consumeToken(rawToken, 'reset');
  const portalUser = await db.query.portalUsers.findFirst({
    where: eq(portalUsers.id, tokenRow.portalUserId),
  });
  if (!portalUser) throw new ValidationError('Invalid or expired token');
  if (!(await isPortalEnabledForPort(portalUser.portId))) {
    throw new ValidationError('Client portal is disabled for this port');
  }
  const passwordHash = await hashPassword(password);
  await db
    .update(portalUsers)
    .set({ passwordHash, passwordChangedAt: new Date(), updatedAt: new Date() })
    .where(eq(portalUsers.id, tokenRow.portalUserId));

  void createAuditLog({
    portId: portalUser.portId,
    userId: null,
    action: 'portal_password_reset',
    entityType: 'portal_user',
    entityId: portalUser.id,
  });
}

// ─── Token consumption (shared between activation + reset) ───────────────────

async function consumeToken(
  rawToken: string,
  type: 'activation' | 'reset',
): Promise<{ portalUserId: string }> {
  const tokenHash = hashToken(rawToken);
  const now = new Date();

  const row = await db.query.portalAuthTokens.findFirst({
    where: and(
      eq(portalAuthTokens.tokenHash, tokenHash),
      eq(portalAuthTokens.type, type),
      isNull(portalAuthTokens.usedAt),
      gt(portalAuthTokens.expiresAt, now),
    ),
  });

  if (!row) {
    void createAuditLog({
      userId: null,
      portId: null,
      action: type === 'reset' ? 'portal_password_reset' : 'portal_activate',
      entityType: 'portal_auth_token',
      entityId: 'invalid',
      metadata: { type, reason: 'invalid_or_expired_token' },
      severity: 'warning',
      source: 'auth',
    });
    throw new ValidationError('Invalid or expired token');
  }

  await db.update(portalAuthTokens).set({ usedAt: now }).where(eq(portalAuthTokens.id, row.id));

  return { portalUserId: row.portalUserId };
}

// Activation + reset email templates live in src/lib/email/templates/portal-auth.ts
