import type { DevicePlatform } from '@chat-app/shared/supabase';

import { type SyncCtx, wrapForOneDevice } from './conversationKeySync';
import { supabase } from './supabase';

// Device-approval flow (Phase 1).
// ---------------------------------------------------------------------------
// When the user registers a brand-new device on top of an existing one, the
// existing device must explicitly approve it before any conv-key wraps are
// created. This module:
//
//   1. On startup, fetches every device row owned by the user and surfaces
//      the ones that aren't this device, aren't already approved, and aren't
//      dismissed. Covers the "I was offline when the new device registered"
//      case.
//   2. Subscribes to realtime INSERTs on `devices` for the user's id, so a
//      device that registers WHILE this client is online raises a banner
//      immediately.
//   3. Persists approve/deny decisions in localStorage so a reload doesn't
//      ask again for a device the user already answered for.
//
// Approval call: re-uses `wrapForOneDevice` from conversationKeySync — that
// helper already walks every shared conversation and writes the key bundle
// for the target device.

export interface PendingApproval {
  deviceId: string;
  userId: string;
  name: string;
  platform: DevicePlatform | string;
  createdAt: string;
  publicKey: string; // pg-hex-encoded bytea
}

export interface DeviceApprovalListenerCtx {
  ownUserId: string;
  ownDeviceId: string;
  // Lazy getter so we never hold the privkey in memory for longer than the
  // approve action that needs it. Returns null if the key isn't loadable
  // (e.g. fresh-restored device that hasn't unsealed yet).
  getPriv: () => Promise<Uint8Array | null>;
}

const APPROVED_KEY = 'chatapp.approvedDeviceIds';
const DISMISSED_KEY = 'chatapp.dismissedDeviceIds';

// In-process state. Module-level so the banner component and the listener
// share one source of truth without prop-drilling through context.
let pending: PendingApproval[] = [];
const subscribers = new Set<(list: PendingApproval[]) => void>();
let listenerCtx: DeviceApprovalListenerCtx | null = null;

function readIdSet(key: string): Set<string> {
  try {
    const raw = window.localStorage.getItem(key);
    if (!raw) return new Set();
    const parsed: unknown = JSON.parse(raw);
    if (!Array.isArray(parsed)) return new Set();
    return new Set(parsed.filter((v): v is string => typeof v === 'string'));
  } catch {
    return new Set();
  }
}

function writeIdSet(key: string, set: Set<string>): void {
  try {
    window.localStorage.setItem(key, JSON.stringify([...set]));
  } catch {
    /* storage unavailable — non-fatal */
  }
}

function persistApproved(deviceId: string): void {
  const s = readIdSet(APPROVED_KEY);
  s.add(deviceId);
  writeIdSet(APPROVED_KEY, s);
}

function persistDismissed(deviceId: string): void {
  const s = readIdSet(DISMISSED_KEY);
  s.add(deviceId);
  writeIdSet(DISMISSED_KEY, s);
}

function notify(): void {
  const snapshot = [...pending];
  for (const cb of subscribers) {
    try {
      cb(snapshot);
    } catch (err) {
      console.warn('deviceApproval: subscriber threw', err);
    }
  }
}

export function getPendingApprovals(): PendingApproval[] {
  return [...pending];
}

export function subscribePendingApprovals(
  cb: (list: PendingApproval[]) => void,
): () => void {
  subscribers.add(cb);
  // Fire once with current state so the consumer can initialise without
  // waiting for the next change.
  try {
    cb([...pending]);
  } catch (err) {
    console.warn('deviceApproval: initial subscriber call threw', err);
  }
  return () => {
    subscribers.delete(cb);
  };
}

function shouldSurface(deviceId: string, ownDeviceId: string): boolean {
  if (deviceId === ownDeviceId) return false;
  const approved = readIdSet(APPROVED_KEY);
  if (approved.has(deviceId)) return false;
  const dismissed = readIdSet(DISMISSED_KEY);
  if (dismissed.has(deviceId)) return false;
  return true;
}

interface DeviceRowLite {
  id: string;
  user_id: string;
  name: string;
  platform: DevicePlatform | string;
  created_at: string;
  public_key: string;
}

function rowToPending(row: DeviceRowLite): PendingApproval {
  return {
    deviceId: row.id,
    userId: row.user_id,
    name: row.name,
    platform: row.platform,
    createdAt: row.created_at,
    publicKey: row.public_key,
  };
}

function upsertPending(req: PendingApproval): void {
  if (pending.some((p) => p.deviceId === req.deviceId)) return;
  pending = [...pending, req];
  notify();
}

function removePending(deviceId: string): void {
  const next = pending.filter((p) => p.deviceId !== deviceId);
  if (next.length === pending.length) return;
  pending = next;
  notify();
}

async function loadInitialPending(ctx: DeviceApprovalListenerCtx): Promise<void> {
  const { data, error } = await supabase
    .from('devices')
    .select('id, user_id, name, platform, created_at, public_key')
    .eq('user_id', ctx.ownUserId);
  if (error) {
    console.warn('deviceApproval: initial devices lookup failed', error);
    return;
  }
  const rows = (data ?? []) as DeviceRowLite[];

  // Pre-filter: anything already in approved/dismissed sets, plus own
  // device, never surfaces.
  const candidates = rows.filter((r) => shouldSurface(r.id, ctx.ownDeviceId));
  if (candidates.length === 0) return;

  // Semantic skip: any candidate that already has a `conversation_keys`
  // wrap somewhere is by definition legit — it was either auto-shared
  // back when that path was enabled (pre-0.16.2) or explicitly approved
  // earlier. Surfacing it now would just nag the user about something
  // already taken care of. Bulk query against `conversation_keys` for
  // all candidate device IDs at once; cheap, avoids N+1.
  const candidateIds = candidates.map((c) => c.id);
  const wrappedIds = new Set<string>();
  try {
    const { data: keyRows, error: kErr } = await (
      supabase as unknown as {
        from: (t: string) => {
          select: (cols: string) => {
            in: (
              col: string,
              vals: string[],
            ) => Promise<{ data: { recipient_device_id: string }[] | null; error: unknown }>;
          };
        };
      }
    )
      .from('conversation_keys')
      .select('recipient_device_id')
      .in('recipient_device_id', candidateIds);
    if (!kErr && keyRows) {
      for (const r of keyRows) wrappedIds.add(r.recipient_device_id);
    }
  } catch (err) {
    console.warn('deviceApproval: conv-key existence probe failed', err);
  }

  // Defense in depth: also keep the created_at guard. Devices older than
  // own can't reasonably need approval — when own came online a fanout
  // pass already covered them. Helps when the conv_keys probe returns
  // partial data due to RLS.
  const own = rows.find((r) => r.id === ctx.ownDeviceId);
  const ownCreatedAt = own ? Date.parse(own.created_at) : Number.NEGATIVE_INFINITY;

  for (const row of candidates) {
    if (wrappedIds.has(row.id)) {
      persistApproved(row.id);
      continue;
    }
    const rowCreatedAt = Date.parse(row.created_at);
    if (Number.isFinite(rowCreatedAt) && rowCreatedAt <= ownCreatedAt) {
      persistApproved(row.id);
      continue;
    }
    upsertPending(rowToPending(row));
  }
}

// Starts the approval listener for the given user/device. Returns an
// unsubscribe function — call it on shell unmount to tear down the realtime
// channel and clear in-memory state.
export function startDeviceApprovalListener(
  ctx: DeviceApprovalListenerCtx,
): () => void {
  listenerCtx = ctx;

  void loadInitialPending(ctx);

  const channel = supabase
    .channel(`device-approval:${ctx.ownUserId}`)
    .on(
      'postgres_changes',
      {
        event: 'INSERT',
        schema: 'public',
        table: 'devices',
        filter: `user_id=eq.${ctx.ownUserId}`,
      },
      (payload: { new: Record<string, unknown> }) => {
        const row = payload.new as unknown as DeviceRowLite;
        if (!row?.id) return;
        if (!shouldSurface(row.id, ctx.ownDeviceId)) return;
        upsertPending(rowToPending(row));
      },
    )
    .subscribe();

  return () => {
    void supabase.removeChannel(channel).catch(() => {
      /* ignore — channel might already be gone */
    });
    pending = [];
    listenerCtx = null;
    notify();
  };
}

// Approves a pending device: walks every conversation the current user is
// in and writes a conv-key bundle for the new device. On success the request
// is removed from the pending list and the deviceId is persisted in
// localStorage so a reload doesn't re-prompt.
export async function approveDevice(req: PendingApproval): Promise<void> {
  const ctx = listenerCtx;
  if (!ctx) {
    throw new Error('deviceApproval: listener not started');
  }
  if (req.userId !== ctx.ownUserId) {
    // Phase 1 only handles same-user approvals (own new device). Friend-side
    // approval is a later phase.
    throw new Error('deviceApproval: cross-user approval not supported yet');
  }

  const priv = await ctx.getPriv();
  if (!priv) {
    throw new Error('deviceApproval: own private key unavailable');
  }

  const sync: SyncCtx = {
    myUserId: ctx.ownUserId,
    myDeviceId: ctx.ownDeviceId,
    priv,
  };

  try {
    await wrapForOneDevice(sync, req.deviceId, req.userId, req.publicKey);
  } finally {
    // Wipe the priv copy we asked for. The original lives in the secret
    // store; this is the transient working copy.
    for (let i = 0; i < priv.length; i++) priv[i] = 0;
  }

  persistApproved(req.deviceId);
  removePending(req.deviceId);
}

// Denies a pending device: just remembers the deviceId in the dismissed-set
// and removes the request. No server-side change — the new device simply
// stays without any conv-key wraps until the user changes their mind (e.g.
// from a settings screen later).
export function denyDevice(deviceId: string): void {
  persistDismissed(deviceId);
  removePending(deviceId);
}

// Bulk-deny: persists every currently-pending deviceId into the dismissed
// set and clears the in-memory list in one notify. Useful when a user has
// accumulated stale entries from old test devices / migrations.
export function denyAllPending(): void {
  if (pending.length === 0) return;
  const dismissed = readIdSet(DISMISSED_KEY);
  for (const p of pending) dismissed.add(p.deviceId);
  writeIdSet(DISMISSED_KEY, dismissed);
  pending = [];
  notify();
}
