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

import { auth } from '@/lib/auth';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { db } from '@/lib/db';
import { crmUserInvites } from '@/lib/db/schema/crm-invites';
import { userProfiles } from '@/lib/db/schema/users';
import { env } from '@/lib/env';
import { sendEmail } from '@/lib/email';
import { crmInviteEmail } from '@/lib/email/templates/crm-invite';
import { resolveSubject } from '@/lib/email/resolve-subject';
import { getBrandingShell } from '@/lib/email/branding-resolver';
import { CodedError, ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { hashToken, mintToken } from '@/lib/portal/passwords';

const INVITE_TTL_HOURS = 72;
const MIN_PASSWORD_LENGTH = 9;

export async function createCrmInvite(args: {
  email: string;
  name?: string;
  isSuperAdmin?: boolean;
  /**
   * Caller identity. Required when minting a super-admin invitation so the
   * service can fail closed if the caller isn't already a super-admin -
   * defense-in-depth for the route's authorization gate.
   */
  invitedBy?: { userId: string; isSuperAdmin: boolean };
}): Promise<{ inviteId: string; link: string }> {
  const email = args.email.toLowerCase().trim();
  const isSuperAdmin = args.isSuperAdmin ?? false;

  if (isSuperAdmin && !args.invitedBy?.isSuperAdmin) {
    throw new ValidationError('Only super admins can mint super-admin invitations');
  }

  // Reject if there's already a better-auth user with this email - they
  // should reset their password instead.
  const sql = postgres(env.DATABASE_URL);
  try {
    const existing = await sql<{ id: string }[]>`
      SELECT id FROM "user" WHERE email = ${email} LIMIT 1
    `;
    if (existing.length > 0) {
      throw new ConflictError(`A CRM user already exists for ${email}`);
    }
  } finally {
    await sql.end();
  }

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

  const [row] = await db
    .insert(crmUserInvites)
    .values({
      email,
      name: args.name ?? null,
      tokenHash: hash,
      isSuperAdmin,
      expiresAt,
    })
    .returning({ id: crmUserInvites.id });

  if (!row)
    throw new CodedError('INSERT_RETURNING_EMPTY', {
      internalMessage: 'Failed to create CRM invite',
    });

  // H-03: token moves to the URL fragment so it never lands in nginx/Caddy
  // access logs (or any HTTP-Referer-leaking middlebox). The fragment is
  // browser-only; the server only sees the path. set-password/page.tsx
  // reads either `#token=` or `?token=` (back-compat for outstanding
  // links). encodeURIComponent guards against `#`/`&` in the token.
  const link = `${env.APP_URL}/set-password#token=${encodeURIComponent(raw)}`;
  const result = await crmInviteEmail({
    link,
    ttlHours: INVITE_TTL_HOURS,
    recipientName: args.name,
    isSuperAdmin,
  });
  // CRM invites are global (no portId at create-invite time). The
  // override resolver returns the fallback when portId is null.
  const subject = await resolveSubject({
    key: 'crm_invite',
    portId: null,
    fallback: result.subject,
    tokens: {
      portName: 'Port Nimara',
      recipientName: args.name ?? '',
      ttlHours: INVITE_TTL_HOURS,
    },
  });

  await sendEmail(email, subject, result.html, undefined, result.text);

  return { inviteId: row.id, link };
}

export async function consumeCrmInvite(args: {
  token: string;
  password: string;
}): Promise<{ userId: string; email: string }> {
  if (args.password.length < MIN_PASSWORD_LENGTH) {
    throw new ValidationError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`);
  }

  const tokenHash = hashToken(args.token);

  const invite = await db.query.crmUserInvites.findFirst({
    where: and(
      eq(crmUserInvites.tokenHash, tokenHash),
      isNull(crmUserInvites.usedAt),
      gt(crmUserInvites.expiresAt, new Date()),
    ),
  });
  if (!invite) {
    throw new NotFoundError('Invite link is invalid or has expired');
  }

  // Create the better-auth user with the chosen password.
  const result = await auth.api.signUpEmail({
    body: {
      email: invite.email,
      password: args.password,
      name: invite.name ?? invite.email.split('@')[0] ?? 'User',
    },
  });
  const userId = result.user.id;

  // Create the matching user_profiles extension row.
  await db
    .insert(userProfiles)
    .values({
      id: crypto.randomUUID(),
      userId,
      displayName: invite.name ?? invite.email,
      isSuperAdmin: invite.isSuperAdmin,
      isActive: true,
      preferences: {},
    })
    .onConflictDoNothing();

  await db
    .update(crmUserInvites)
    .set({ usedAt: new Date() })
    .where(eq(crmUserInvites.id, invite.id));

  return { userId, email: invite.email };
}

// ─── Admin operations ────────────────────────────────────────────────────────

export interface InviteRow {
  id: string;
  email: string;
  name: string | null;
  isSuperAdmin: boolean;
  expiresAt: Date;
  usedAt: Date | null;
  createdAt: Date;
  status: 'pending' | 'accepted' | 'expired';
}

export async function listCrmInvites(): Promise<InviteRow[]> {
  const rows = await db
    .select({
      id: crmUserInvites.id,
      email: crmUserInvites.email,
      name: crmUserInvites.name,
      isSuperAdmin: crmUserInvites.isSuperAdmin,
      expiresAt: crmUserInvites.expiresAt,
      usedAt: crmUserInvites.usedAt,
      createdAt: crmUserInvites.createdAt,
    })
    .from(crmUserInvites)
    .orderBy(desc(crmUserInvites.createdAt))
    .limit(200);

  const now = Date.now();
  return rows.map((r) => {
    let status: InviteRow['status'];
    if (r.usedAt) status = 'accepted';
    else if (r.expiresAt.getTime() < now) status = 'expired';
    else status = 'pending';
    return { ...r, status };
  });
}

export async function revokeCrmInvite(inviteId: string, meta: AuditMeta): Promise<void> {
  const invite = await db.query.crmUserInvites.findFirst({
    where: eq(crmUserInvites.id, inviteId),
  });
  if (!invite) throw new NotFoundError('Invite');
  if (invite.usedAt) throw new ConflictError('Invite already accepted - cannot revoke');

  // Force expiration; tokenHash stays in place so any in-flight click fails
  // the `expiresAt > now` check at consume time.
  await db
    .update(crmUserInvites)
    .set({ expiresAt: new Date(0) })
    .where(eq(crmUserInvites.id, inviteId));

  void createAuditLog({
    userId: meta.userId,
    portId: meta.portId,
    action: 'revoke_invite',
    entityType: 'crm_invite',
    entityId: inviteId,
    metadata: { email: invite.email },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });
}

export async function resendCrmInvite(
  inviteId: string,
  meta: AuditMeta,
): Promise<{ link: string }> {
  const invite = await db.query.crmUserInvites.findFirst({
    where: eq(crmUserInvites.id, inviteId),
  });
  if (!invite) throw new NotFoundError('Invite');
  if (invite.usedAt) throw new ConflictError('Invite already accepted - nothing to resend');

  // Mint a fresh token + push expiry forward so the resent link is the only
  // working one. The old token hash is overwritten so prior emails become
  // dead links.
  const { raw, hash } = mintToken();
  const expiresAt = new Date(Date.now() + INVITE_TTL_HOURS * 3600 * 1000);

  await db
    .update(crmUserInvites)
    .set({ tokenHash: hash, expiresAt })
    .where(eq(crmUserInvites.id, inviteId));

  // H-03: token moves to the URL fragment so it never lands in nginx/Caddy
  // access logs (or any HTTP-Referer-leaking middlebox). The fragment is
  // browser-only; the server only sees the path. set-password/page.tsx
  // reads either `#token=` or `?token=` (back-compat for outstanding
  // links). encodeURIComponent guards against `#`/`&` in the token.
  const link = `${env.APP_URL}/set-password#token=${encodeURIComponent(raw)}`;
  const branding = await getBrandingShell(meta.portId);
  const result = await crmInviteEmail(
    {
      link,
      ttlHours: INVITE_TTL_HOURS,
      recipientName: invite.name ?? undefined,
      isSuperAdmin: invite.isSuperAdmin,
    },
    { branding },
  );
  // Resend uses the dedicated portal_invite_resend key so admins can
  // word the resend differently from the original.
  const subject = await resolveSubject({
    key: 'portal_invite_resend',
    portId: meta.portId ?? null,
    fallback: result.subject,
    tokens: {
      portName: 'Port Nimara',
      recipientName: invite.name ?? '',
      ttlHours: INVITE_TTL_HOURS,
    },
  });
  await sendEmail(invite.email, subject, result.html, undefined, result.text, meta.portId);

  void createAuditLog({
    userId: meta.userId,
    portId: meta.portId,
    action: 'resend_invite',
    entityType: 'crm_invite',
    entityId: inviteId,
    metadata: { email: invite.email },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  return { link };
}
