import { bytesToUtf8, utf8ToBytes } from '../crypto/index.js';
import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea.js';
import type { AppSupabaseClient } from '../supabase/client.js';
import {
  decryptWithConvKey,
  encryptWithConvKey,
  getOrCreateConvKey,
  type OwnDeviceCtx,
  tryGetConvKey,
} from './convKeys.js';
import type { ChatMessage, DecryptedMessage } from './types.js';

const MESSAGE_COLS =
  'id, conversation_id, sender_id, sender_device_id, reply_to_id, edited_at, deleted_at, created_at, ciphertext, nonce, key_version';

interface MessageRow {
  id: string;
  conversation_id: string;
  sender_id: string;
  sender_device_id: string | null;
  reply_to_id: string | null;
  edited_at: string | null;
  deleted_at: string | null;
  created_at: string;
  ciphertext: string;
  nonce: string;
  key_version: number;
}

interface MessageWithCipher extends ChatMessage {
  ciphertext: Uint8Array;
  nonce: Uint8Array;
  keyVersion: number;
}

function mapMessage(row: MessageRow): MessageWithCipher {
  return {
    id: row.id,
    conversationId: row.conversation_id,
    senderId: row.sender_id,
    senderDeviceId: row.sender_device_id,
    replyToId: row.reply_to_id,
    editedAt: row.edited_at,
    deletedAt: row.deleted_at,
    createdAt: row.created_at,
    ciphertext: pgHexToBytes(row.ciphertext),
    nonce: pgHexToBytes(row.nonce),
    keyVersion: row.key_version,
  };
}

interface DeviceKey {
  deviceId: string;
  userId: string;
  publicKey: Uint8Array;
}

// All device public keys for accepted members of a conversation.
export async function listConversationDeviceKeys(
  client: AppSupabaseClient,
  conversationId: string,
): Promise<DeviceKey[]> {
  // 1. Members
  const { data: members, error: mErr } = await client
    .from('conversation_members')
    .select('user_id, accepted')
    .eq('conversation_id', conversationId);
  if (mErr) throw mErr;

  const memberIds = (members ?? []).filter((m) => m.accepted).map((m) => m.user_id);
  if (memberIds.length === 0) return [];

  // 2. Devices for those members
  const { data: devices, error: dErr } = await client
    .from('devices')
    .select('id, user_id, public_key')
    .in('user_id', memberIds);
  if (dErr) throw dErr;

  return (devices ?? []).map((d) => ({
    deviceId: d.id,
    userId: d.user_id,
    publicKey: pgHexToBytes(d.public_key),
  }));
}

export interface SendMessageParams {
  client: AppSupabaseClient;
  conversationId: string;
  plaintext: string;
  senderUserId: string;
  senderDeviceId: string;
  senderPrivateKey: Uint8Array;
  replyToId?: string;
  // Optional encrypted attachments — their handles are already materialised
  // via `encryptAndUploadAttachment`. The caller is responsible for creating
  // the corresponding message_attachments rows (see insertAttachmentRow) once
  // the returned message id is known.
  attachmentHandles?: import('./attachments.js').AttachmentHandle[];
}

// Encrypts and inserts a message using the shared per-conversation key
// (Sender-Key / Signal-style). The conv-key is generated lazily on first
// send and shared with every existing recipient device. New devices that
// register later receive their key bundle through `shareConvKeyToDevice`.
export async function sendEncryptedMessage(params: SendMessageParams): Promise<ChatMessage> {
  const ownCtx: OwnDeviceCtx = {
    userId: params.senderUserId,
    deviceId: params.senderDeviceId,
    privateKey: params.senderPrivateKey,
  };
  const handle = await getOrCreateConvKey(params.client, params.conversationId, ownCtx);

  const attachments = params.attachmentHandles ?? [];
  const payloadString =
    attachments.length === 0
      ? params.plaintext
      : JSON.stringify({ v: 1, text: params.plaintext, attachments });
  const plainBytes = utf8ToBytes(payloadString);

  const cipher = encryptWithConvKey(plainBytes, handle.key);

  const insertPayload: Record<string, unknown> = {
    conversation_id: params.conversationId,
    sender_id: params.senderUserId,
    sender_device_id: params.senderDeviceId,
    ciphertext: bytesToPgHex(cipher.ciphertext),
    nonce: bytesToPgHex(cipher.nonce),
    key_version: handle.keyVersion,
  };
  if (params.replyToId) insertPayload.reply_to_id = params.replyToId;

  const { data: messageRow, error: insertErr } = await params.client
    .from('messages')
    .insert(insertPayload as never)
    .select(MESSAGE_COLS)
    .single();
  if (insertErr) throw insertErr;
  return mapMessage(messageRow as unknown as MessageRow);
}

// Fetch the last `limit` messages of a conversation in ascending order.
export async function fetchConversationMessages(
  client: AppSupabaseClient,
  conversationId: string,
  limit = 100,
): Promise<MessageWithCipher[]> {
  const { data, error } = await client
    .from('messages')
    .select(MESSAGE_COLS)
    .eq('conversation_id', conversationId)
    .order('created_at', { ascending: false })
    .limit(limit);
  if (error) throw error;
  const rows = (data ?? []) as unknown as MessageRow[];
  return rows.map(mapMessage).reverse();
}

export type { MessageWithCipher };

// ---------------------------------------------------------------------------
// Edit + delete
// ---------------------------------------------------------------------------

export interface EditMessageParams {
  client: AppSupabaseClient;
  messageId: string;
  conversationId: string;
  newPlaintext: string;
  senderPrivateKey: Uint8Array;
}

// Re-encrypts the message body with the conv-key and updates the row.
// Server-side trigger enforces 24h window + sender-only rule.
export async function editEncryptedMessage(
  params: EditMessageParams & { senderUserId: string; senderDeviceId: string },
): Promise<void> {
  const ownCtx: OwnDeviceCtx = {
    userId: params.senderUserId,
    deviceId: params.senderDeviceId,
    privateKey: params.senderPrivateKey,
  };
  const handle = await getOrCreateConvKey(params.client, params.conversationId, ownCtx);

  const cipher = encryptWithConvKey(utf8ToBytes(params.newPlaintext), handle.key);

  const { error } = await params.client
    .from('messages')
    .update({
      ciphertext: bytesToPgHex(cipher.ciphertext),
      nonce: bytesToPgHex(cipher.nonce),
      key_version: handle.keyVersion,
      edited_at: new Date().toISOString(),
    } as never)
    .eq('id', params.messageId);
  if (error) throw error;
}

export async function softDeleteMessage(
  client: AppSupabaseClient,
  messageId: string,
): Promise<void> {
  const { error } = await client
    .from('messages')
    .update({ deleted_at: new Date().toISOString() } as never)
    .eq('id', messageId);
  if (error) throw error;
}

// ---------------------------------------------------------------------------
// Read receipts
// ---------------------------------------------------------------------------

// Upsert read rows for each message. Idempotent thanks to the composite PK.
export async function markMessagesRead(
  client: AppSupabaseClient,
  messageIds: string[],
): Promise<void> {
  if (messageIds.length === 0) return;
  const { data: session, error: aErr } = await client.auth.getUser();
  if (aErr) throw aErr;
  if (!session.user) return;
  const myId = session.user.id;
  const rows = messageIds.map((id) => ({ message_id: id, user_id: myId }));
  const { error } = await client
    .from('message_reads')
    .upsert(rows as never, { onConflict: 'message_id,user_id', ignoreDuplicates: true });
  if (error) throw error;
}

// Returns the set of message ids that `peerUserId` has read (among the given ids).
// RLS hides rows when either side has read receipts off.
export async function listPeerReadsForMessages(
  client: AppSupabaseClient,
  messageIds: string[],
  peerUserId: string,
): Promise<Set<string>> {
  if (messageIds.length === 0) return new Set();
  const { data, error } = await client
    .from('message_reads')
    .select('message_id')
    .eq('user_id', peerUserId)
    .in('message_id', messageIds);
  if (error) throw error;
  return new Set((data ?? []).map((r) => r.message_id));
}

// Group variant: returns reads from ALL users (other than `excludeUserId`,
// typically caller themselves) keyed by message id → user id → timestamp.
// RLS already filters out users whose receipts are off.
export async function listGroupReadsForMessages(
  client: AppSupabaseClient,
  messageIds: string[],
  excludeUserId: string,
): Promise<Map<string, Map<string, string>>> {
  if (messageIds.length === 0) return new Map();
  const { data, error } = await client
    .from('message_reads')
    .select('message_id, user_id, read_at')
    .neq('user_id', excludeUserId)
    .in('message_id', messageIds);
  if (error) throw error;
  const out = new Map<string, Map<string, string>>();
  for (const row of data ?? []) {
    const mid = row.message_id;
    const uid = row.user_id;
    const at = row.read_at;
    let inner = out.get(mid);
    if (!inner) {
      inner = new Map();
      out.set(mid, inner);
    }
    inner.set(uid, at);
  }
  return out;
}

// Group variant of delivery receipts. Same shape as listGroupReadsForMessages.
export async function listGroupDeliveriesForMessages(
  client: AppSupabaseClient,
  messageIds: string[],
  excludeUserId: string,
): Promise<Map<string, Map<string, string>>> {
  if (messageIds.length === 0) return new Map();
  const { data, error } = await (client as unknown as {
    from: (t: string) => {
      select: (cols: string) => {
        neq: (col: string, val: string) => {
          in: (
            col: string,
            vals: string[],
          ) => Promise<{
            data:
              | { message_id: string; user_id: string; delivered_at: string }[]
              | null;
            error: Error | null;
          }>;
        };
      };
    };
  })
    .from('message_deliveries')
    .select('message_id, user_id, delivered_at')
    .neq('user_id', excludeUserId)
    .in('message_id', messageIds);
  if (error) throw error;
  const out = new Map<string, Map<string, string>>();
  for (const row of data ?? []) {
    let inner = out.get(row.message_id);
    if (!inner) {
      inner = new Map();
      out.set(row.message_id, inner);
    }
    inner.set(row.user_id, row.delivered_at);
  }
  return out;
}

// ---------------------------------------------------------------------------
// Delivery receipts
// ---------------------------------------------------------------------------

// Records that the caller has received (fetched + decrypted) these messages.
// Idempotent; composite PK absorbs duplicates.
export async function markMessagesDelivered(
  client: AppSupabaseClient,
  messageIds: string[],
): Promise<void> {
  if (messageIds.length === 0) return;
  const { data: session, error: aErr } = await client.auth.getUser();
  if (aErr) throw aErr;
  if (!session.user) return;
  const myId = session.user.id;
  const rows = messageIds.map((id) => ({ message_id: id, user_id: myId }));
  // `message_deliveries` is a later migration and not yet in the generated
  // Supabase types; skip the strict table-name check for this call.
  const { error } = await (client as unknown as {
    from: (t: string) => {
      upsert: (
        rows: unknown,
        opts: { onConflict: string; ignoreDuplicates: boolean },
      ) => Promise<{ error: Error | null }>;
    };
  })
    .from('message_deliveries')
    .upsert(rows, { onConflict: 'message_id,user_id', ignoreDuplicates: true });
  if (error) throw error;
}

export async function listPeerDeliveriesForMessages(
  client: AppSupabaseClient,
  messageIds: string[],
  peerUserId: string,
): Promise<Set<string>> {
  if (messageIds.length === 0) return new Set();
  const { data, error } = await (client as unknown as {
    from: (t: string) => {
      select: (cols: string) => {
        eq: (col: string, val: string) => {
          in: (
            col: string,
            vals: string[],
          ) => Promise<{ data: { message_id: string }[] | null; error: Error | null }>;
        };
      };
    };
  })
    .from('message_deliveries')
    .select('message_id')
    .eq('user_id', peerUserId)
    .in('message_id', messageIds);
  if (error) throw error;
  return new Set((data ?? []).map((r) => r.message_id));
}

// ---------------------------------------------------------------------------
// Reactions
// ---------------------------------------------------------------------------

export interface MessageReaction {
  messageId: string;
  userId: string;
  emoji: string;
  createdAt: string;
}

export async function listReactionsForMessages(
  client: AppSupabaseClient,
  messageIds: string[],
): Promise<MessageReaction[]> {
  if (messageIds.length === 0) return [];
  const { data, error } = await client
    .from('message_reactions')
    .select('message_id, user_id, emoji, created_at')
    .in('message_id', messageIds);
  if (error) throw error;
  return (data ?? []).map((r) => ({
    messageId: r.message_id,
    userId: r.user_id,
    emoji: r.emoji,
    createdAt: r.created_at,
  }));
}

export async function addReaction(
  client: AppSupabaseClient,
  messageId: string,
  emoji: string,
): Promise<void> {
  const { data: session, error: aErr } = await client.auth.getUser();
  if (aErr) throw aErr;
  if (!session.user) throw new Error('not authenticated');
  const { error } = await client.from('message_reactions').insert({
    message_id: messageId,
    user_id: session.user.id,
    emoji,
  } as never);
  if (error) throw error;
}

export async function removeReaction(
  client: AppSupabaseClient,
  messageId: string,
  emoji: string,
): Promise<void> {
  const { data: session, error: aErr } = await client.auth.getUser();
  if (aErr) throw aErr;
  if (!session.user) throw new Error('not authenticated');
  const { error } = await client
    .from('message_reactions')
    .delete()
    .eq('message_id', messageId)
    .eq('user_id', session.user.id)
    .eq('emoji', emoji);
  if (error) throw error;
}

// ---------------------------------------------------------------------------
// Decrypt helpers
// ---------------------------------------------------------------------------

export interface DecryptParams {
  client: AppSupabaseClient;
  messages: MessageWithCipher[];
  ownDeviceId: string;
  ownPrivateKey: Uint8Array;
  /**
   * Optional delegate that performs the symmetric-decrypt + utf-8 decode
   * step for a batch of messages. When provided, the main thread only does
   * conv-key lookup; the CPU-heavy AEAD loop runs inside the delegate (e.g.
   * a Web Worker). Items arrive with their per-message conv-key attached.
   */
  aeadBatchDelegate?: (
    items: Array<{
      id: string;
      ciphertext: Uint8Array;
      nonce: Uint8Array;
      key: Uint8Array;
    }>,
  ) => Promise<Array<{ id: string; plaintext: string | null }>>;
}

// Decrypts messages using their conv-key (looked up + cached per
// keyVersion). Returns null `plaintext` when this device has no key bundle
// for that version yet (e.g. brand-new device waiting for share).
export async function decryptMessages(opts: DecryptParams): Promise<DecryptedMessage[]> {
  const versions = new Map<string, Map<number, Uint8Array | null>>(); // convId -> version -> key | null
  // First pass: resolve conv-keys for every message. Network-bound, stays on
  // caller's thread so Supabase client + session remain usable.
  interface Resolved {
    message: MessageWithCipher;
    key: Uint8Array | null;
  }
  const resolved: Resolved[] = [];
  for (const m of opts.messages) {
    let convCache = versions.get(m.conversationId);
    if (!convCache) {
      convCache = new Map();
      versions.set(m.conversationId, convCache);
    }
    let key: Uint8Array | null;
    if (convCache.has(m.keyVersion)) {
      key = convCache.get(m.keyVersion) ?? null;
    } else {
      const handle = await tryGetConvKey(
        opts.client,
        m.conversationId,
        opts.ownDeviceId,
        opts.ownPrivateKey,
        m.keyVersion,
      );
      key = handle?.key ?? null;
      convCache.set(m.keyVersion, key);
    }
    resolved.push({ message: m, key });
  }

  // Second pass: symmetric decrypt. If a delegate is supplied (Web Worker),
  // batch the CPU-heavy step to it; otherwise fall back to inline.
  if (opts.aeadBatchDelegate) {
    const batch = resolved
      .filter((r): r is Resolved & { key: Uint8Array } => r.key !== null)
      .map((r) => ({
        id: r.message.id,
        ciphertext: r.message.ciphertext,
        nonce: r.message.nonce,
        key: r.key,
      }));
    const plaintextById = new Map<string, string | null>();
    if (batch.length > 0) {
      const results = await opts.aeadBatchDelegate(batch);
      for (const result of results) plaintextById.set(result.id, result.plaintext);
    }
    return resolved.map((r) => ({
      ...r.message,
      plaintext: r.key ? plaintextById.get(r.message.id) ?? null : null,
    }));
  }

  return resolved.map((r) => {
    let plaintext: string | null = null;
    if (r.key) {
      try {
        const decoded = decryptWithConvKey(r.message.ciphertext, r.message.nonce, r.key);
        plaintext = bytesToUtf8(decoded);
      } catch {
        plaintext = null;
      }
    }
    return { ...r.message, plaintext };
  });
}
