/**
 * Lists router — list CRUD with scoped access control (LIST-01).
 *
 * Security (threat model T-04-02, T-04-05, T-04-07, T-04-08):
 *   - All endpoints resolve currentUserId via resolveUserId (returns null → 401).
 *   - GET /: scoped WHERE owner_id = caller OR id IN list_shares.userId = caller (T-04-02, D-04).
 *   - POST /: auto-populates list_shares for all other members when isShared=true (D-01, D-02).
 *     list_shares are server-managed only — no client-writable shares endpoint (T-04-08).
 *   - PATCH /:id / DELETE /:id: ownership OR share membership verified before mutation (T-04-05).
 *   - zod patchListSchema whitelists name/isShared only (T-04-07).
 *   - Drizzle parameterized queries prevent SQL injection.
 *
 * Fan-out seam (Plan 06):
 *   publishListEvent calls are left as commented seams below each mutation.
 *   Plan 06 adds live-sync SSE triggers once the SSE endpoint exists.
 *
 * Mounted under /api/* in index.ts — behind oidcAuthMiddleware (or dev-bypass).
 */

import { Hono } from 'hono';
import type { Context } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { and, asc, eq, inArray, sql } from 'drizzle-orm';
import { db } from '../db/client.js';
import { lists, listShares, listItems, users } from '../db/schema.js';
import { getAuth } from '../auth/middleware.js';
import { upsertUser, deriveDisplayName } from '../auth/user.js';
import { rankForAppend } from '../lib/rank.js';
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
import '../auth/devBypass.js';

import { publishListEvent } from '../lib/listEmitter.js';
import { notifyListChange } from '../lib/listChangeDispatcher.js';

export const listsRouter = new Hono();

/**
 * listItemsRouter — single-item mutation routes.
 *
 * Mounted at /api (not /api/lists) in index.ts so the URLs resolve as:
 *   PATCH  /api/list-items/:itemId
 *   DELETE /api/list-items/:itemId
 *
 * Separate from listsRouter (mounted at /api/lists) per the RESEARCH.md
 * architecture diagram.
 */
export const listItemsRouter = new Hono();

// ---------------------------------------------------------------------------
// Auth helper — copied verbatim from events.ts per project convention.
// Duplicated per router (not extracted to shared module).
//
// Resolution order (D-10):
//   1. Dev-bypass path: c.get('user') is set by devAuthBypass() when DEV_AUTH_BYPASS=true.
//   2. OIDC path: call getAuth(c). If null → unauthenticated, return null.
// ---------------------------------------------------------------------------
async function resolveUserId(c: Context): Promise<number | null> {
  const devUser = c.get('user') as { id: number } | undefined;
  if (devUser) return devUser.id;

  const auth = await getAuth(c);
  if (!auth) return null;

  const iss = (auth.iss as string | undefined) ?? '';
  const sub = auth.sub ?? '';
  const displayName = deriveDisplayName(auth);
  const user = await upsertUser(iss, sub, displayName);
  return user?.id ?? null;
}

// ---------------------------------------------------------------------------
// Zod schemas
// ---------------------------------------------------------------------------

/**
 * Create list — name (1..255), isShared defaults to true per D-01.
 */
const createListSchema = z.object({
  name: z.string().min(1).max(255),
  isShared: z.boolean().default(true),
});

/**
 * Patch list — name and/or isShared; at least one field required (T-04-07).
 */
const patchListSchema = z
  .object({
    name: z.string().min(1).max(255),
    isShared: z.boolean(),
  })
  .partial()
  .refine((obj) => Object.keys(obj).length >= 1, {
    message: 'PATCH must update at least one field (name or isShared)',
  });

/**
 * Create item — text 1..500 (T-04-06 XSS: plain-text only, no HTML).
 */
const createItemSchema = z.object({
  text: z.string().min(1).max(500),
});

/**
 * Per-field PATCH for list items (D-08, T-04-07).
 * Exactly one field must be present — enforces single-field last-write-wins.
 * Fields: checked (boolean), text (string), position (fractional rank string).
 */
const patchItemSchema = z
  .object({
    checked: z.boolean(),
    text: z.string().min(1).max(500),
    position: z.string().min(1).max(255),
  })
  .partial()
  .refine((obj) => Object.keys(obj).length === 1, {
    message: 'PATCH must update exactly one field (checked, text, or position)',
  });

// ---------------------------------------------------------------------------
// Helper: check list access (owner OR sharee)
// Returns: { allowed: true, isOwner: boolean } | { allowed: false }
// ---------------------------------------------------------------------------
async function checkListAccess(
  listId: number,
  userId: number,
): Promise<
  | { allowed: true; isOwner: boolean; listRow: typeof lists.$inferSelect }
  | { allowed: false; notFound: boolean }
> {
  const [listRow] = await db.select().from(lists).where(eq(lists.id, listId)).limit(1);

  if (!listRow) {
    return { allowed: false, notFound: true };
  }

  if (listRow.ownerId === userId) {
    return { allowed: true, isOwner: true, listRow };
  }

  // Check list_shares
  const [shareRow] = await db
    .select({ listId: listShares.listId })
    .from(listShares)
    .where(and(eq(listShares.listId, listId), eq(listShares.userId, userId)))
    .limit(1);

  if (shareRow) {
    return { allowed: true, isOwner: false, listRow };
  }

  return { allowed: false, notFound: false };
}

// ---------------------------------------------------------------------------
// GET /api/lists
//
// Scoped response: returns only lists the current user owns or has been shared.
// Includes item counts (activeCount, doneCount) per list for the card badge.
//
// Security: T-04-02 — WHERE owner_id = caller OR id IN list_shares.userId = caller.
// ---------------------------------------------------------------------------
listsRouter.get('/', async (c) => {
  const currentUserId = await resolveUserId(c);
  if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);

  try {
    // Collect all list IDs accessible to this user: owned + shared
    const owned = await db
      .select({ id: lists.id })
      .from(lists)
      .where(eq(lists.ownerId, currentUserId));

    const shared = await db
      .select({ listId: listShares.listId })
      .from(listShares)
      .where(eq(listShares.userId, currentUserId));

    const accessibleIds = [...new Set([...owned.map((r) => r.id), ...shared.map((r) => r.listId)])];

    if (accessibleIds.length === 0) {
      return c.json({ lists: [] });
    }

    // Fetch list rows for accessible IDs
    const listRows = await db.select().from(lists).where(inArray(lists.id, accessibleIds));

    // Compute item counts per list
    const countRows = await db
      .select({
        listId: listItems.listId,
        checked: listItems.checked,
        count: sql<number>`COUNT(*)`,
      })
      .from(listItems)
      .where(inArray(listItems.listId, accessibleIds))
      .groupBy(listItems.listId, listItems.checked);

    // Build counts map: { listId -> { active, done } }
    const countsMap = new Map<number, { active: number; done: number }>();
    for (const row of countRows) {
      if (!countsMap.has(row.listId)) {
        countsMap.set(row.listId, { active: 0, done: 0 });
      }
      const entry = countsMap.get(row.listId)!;
      if (row.checked) {
        entry.done += Number(row.count);
      } else {
        entry.active += Number(row.count);
      }
    }

    const result = listRows.map((list) => {
      const counts = countsMap.get(list.id) ?? { active: 0, done: 0 };
      return {
        id: list.id,
        name: list.name,
        isShared: list.isShared,
        ownerId: list.ownerId,
        activeCount: counts.active,
        doneCount: counts.done,
        createdAt: list.createdAt,
        updatedAt: list.updatedAt,
      };
    });

    return c.json({ lists: result });
  } catch (err) {
    console.error('[lists/GET /] DB query failed:', err);
    return c.json({ error: 'Service unavailable' }, 503);
  }
});

// ---------------------------------------------------------------------------
// POST /api/lists
//
// Create a named list. If isShared=true, auto-insert list_shares rows for
// ALL other members in the users table (YAGNI auto-share per D-01/D-02/OQ-3).
// Creator is the owner — no self-share row.
//
// Security: T-04-08 — shares are server-managed only; no client endpoint for shares.
// ---------------------------------------------------------------------------
listsRouter.post('/', zValidator('json', createListSchema), async (c) => {
  const currentUserId = await resolveUserId(c);
  if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);

  const { name, isShared } = c.req.valid('json');

  try {
    // Insert the list
    const [inserted] = await db
      .insert(lists)
      .values({ ownerId: currentUserId, name, isShared })
      .$returningId();

    const listId = inserted.id;

    // Auto-populate list_shares for all other members when isShared=true (D-01, D-02, OQ-3)
    if (isShared) {
      // Fetch all users EXCEPT the creator
      const otherUsers = await db
        .select({ id: users.id })
        .from(users)
        .where(sql`${users.id} != ${currentUserId}`);

      if (otherUsers.length > 0) {
        await db.insert(listShares).values(otherUsers.map((u) => ({ listId, userId: u.id })));
      }
    }

    // Re-fetch the newly created list to return canonical shape
    const [newList] = await db.select().from(lists).where(eq(lists.id, listId)).limit(1);

    // Fan-out: notify accessible subscribers that this list was created/updated (LIST-04)
    publishListEvent(listId, {
      type: 'list:updated',
      listId,
      payload: { id: listId, name: newList.name },
    });

    return c.json(
      {
        id: newList.id,
        name: newList.name,
        isShared: newList.isShared,
        ownerId: newList.ownerId,
        activeCount: 0,
        doneCount: 0,
        createdAt: newList.createdAt,
        updatedAt: newList.updatedAt,
      },
      201,
    );
  } catch (err) {
    console.error('[lists/POST /] DB operation failed:', err);
    return c.json({ error: 'Service unavailable' }, 503);
  }
});

// ---------------------------------------------------------------------------
// PATCH /api/lists/:id
//
// Update name and/or isShared. Caller must be owner or sharee.
// Visibility change reconciles list_shares:
//   false → true: insert shares for all other members
//   true → false: delete all non-owner shares
//
// Security: T-04-05 — ownership/share check before mutation.
//           T-04-07 — zod whitelists name/isShared only.
// ---------------------------------------------------------------------------
listsRouter.patch('/:id', zValidator('json', patchListSchema), async (c) => {
  const currentUserId = await resolveUserId(c);
  if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);

  const listId = Number(c.req.param('id'));
  const patch = c.req.valid('json');

  try {
    const access = await checkListAccess(listId, currentUserId);

    if (!access.allowed) {
      if (access.notFound) return c.json({ error: 'Not found' }, 404);
      return c.json({ error: 'Access denied' }, 403);
    }

    // T-04-08 / T-04-05: owner-only guard for isShared mutations.
    // A sharee may rename a list (patch.name) but must never mutate list_shares.
    if (patch.isShared !== undefined && !access.isOwner) {
      return c.json({ error: 'Only the list owner can change sharing settings' }, 403);
    }

    const prevIsShared = access.listRow.isShared;
    const newIsShared = patch.isShared ?? prevIsShared;

    // Apply field updates
    const updateValues: Partial<typeof lists.$inferInsert> = {};
    if (patch.name !== undefined) updateValues.name = patch.name;
    if (patch.isShared !== undefined) updateValues.isShared = patch.isShared;

    await db.update(lists).set(updateValues).where(eq(lists.id, listId));

    // Owner-only: reconcile list_shares on visibility change
    if (patch.isShared !== undefined && patch.isShared !== prevIsShared) {
      if (newIsShared) {
        // false → true: insert shares for all other members (except owner)
        const otherUsers = await db
          .select({ id: users.id })
          .from(users)
          .where(sql`${users.id} != ${access.listRow.ownerId}`);

        if (otherUsers.length > 0) {
          // Use INSERT IGNORE semantics by catching duplicate key errors gracefully
          for (const u of otherUsers) {
            try {
              await db.insert(listShares).values({ listId, userId: u.id });
            } catch {
              // Duplicate key — share already exists, skip
            }
          }
        }
      } else {
        // true → false: remove all non-owner shares
        await db.delete(listShares).where(eq(listShares.listId, listId));
      }
    }

    // Re-fetch and return the updated list
    const [updated] = await db.select().from(lists).where(eq(lists.id, listId)).limit(1);

    // Fan-out: notify accessible subscribers that this list metadata changed (LIST-04)
    publishListEvent(listId, {
      type: 'list:updated',
      listId,
      payload: { id: listId, name: updated.name },
    });
    // Push: coalesced list-change notification to other accessible members (NOTIF-02)
    notifyListChange(listId, currentUserId);

    return c.json({
      id: updated.id,
      name: updated.name,
      isShared: updated.isShared,
      ownerId: updated.ownerId,
      createdAt: updated.createdAt,
      updatedAt: updated.updatedAt,
    });
  } catch (err) {
    console.error('[lists/PATCH /:id] DB operation failed:', err);
    return c.json({ error: 'Service unavailable' }, 503);
  }
});

// ---------------------------------------------------------------------------
// DELETE /api/lists/:id
//
// Owner-only delete (safe default per plan spec). Cascade handles items+shares
// via the FK onDelete: 'cascade' constraints in schema.ts.
//
// Security: T-04-05 — only the owner can delete; non-owner/non-sharee → 403.
// ---------------------------------------------------------------------------
listsRouter.delete('/:id', async (c) => {
  const currentUserId = await resolveUserId(c);
  if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);

  const listId = Number(c.req.param('id'));

  try {
    const access = await checkListAccess(listId, currentUserId);

    if (!access.allowed) {
      if (access.notFound) return c.json({ error: 'Not found' }, 404);
      return c.json({ error: 'Access denied' }, 403);
    }

    // Owner-only delete (plan spec: "owner-only delete is the safe default")
    if (!access.isOwner) {
      return c.json({ error: 'Access denied' }, 403);
    }

    await db.delete(lists).where(eq(lists.id, listId));

    // Fan-out: notify accessible subscribers that this list was deleted (LIST-04)
    publishListEvent(listId, { type: 'list:deleted', listId, payload: { id: listId } });
    // Push: coalesced list-change notification to other accessible members (NOTIF-02)
    // Note: list is already deleted from DB; notifyListChange handles missing list gracefully.
    notifyListChange(listId, currentUserId);

    return c.json({ id: listId });
  } catch (err) {
    console.error('[lists/DELETE /:id] DB operation failed:', err);
    return c.json({ error: 'Service unavailable' }, 503);
  }
});

// ===========================================================================
// Item routes (LIST-02)
//
// Route paths (per RESEARCH.md architecture diagram):
//   Items-by-list:   /:id/items   (nested under lists)
//   Single-item ops: /list-items/:itemId (at listsRouter root, mounts under /api)
//
// Security: T-04-05 — all item handlers verify list membership (owner OR sharee).
//           T-04-06 — item text rendered plain-text in UI; no HTML/dangerouslySetInnerHTML.
//           T-04-07 — patchItemSchema enforces exactly one field per D-08.
//           T-04-09 — DELETE is final; PATCH on deleted item 404s (no upsert path).
// ===========================================================================

// ---------------------------------------------------------------------------
// POST /api/lists/:id/items
//
// Add an item to the list, assigning a fractional rank at the bottom of the
// active section. Rank is generateKeyBetween(lastActiveRank, null) per D-13.
//
// Security: list access check before insert.
// ---------------------------------------------------------------------------
listsRouter.post('/:id/items', zValidator('json', createItemSchema), async (c) => {
  const currentUserId = await resolveUserId(c);
  if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);

  const listId = Number(c.req.param('id'));
  const { text } = c.req.valid('json');

  try {
    const access = await checkListAccess(listId, currentUserId);
    if (!access.allowed) {
      if (access.notFound) return c.json({ error: 'Not found' }, 404);
      return c.json({ error: 'Access denied' }, 403);
    }

    // Find the last active item's rank (unchecked, sorted DESC by rank, limit 1)
    const [lastActive] = await db
      .select({ rank: listItems.rank })
      .from(listItems)
      .where(and(eq(listItems.listId, listId), eq(listItems.checked, false)))
      .orderBy(sql`${listItems.rank} DESC`)
      .limit(1);

    const newRank = rankForAppend(lastActive?.rank ?? null);

    const [inserted] = await db
      .insert(listItems)
      .values({ listId, text, rank: newRank })
      .$returningId();

    const [newItem] = await db
      .select()
      .from(listItems)
      .where(eq(listItems.id, inserted.id))
      .limit(1);

    // Fan-out: notify accessible subscribers that an item was added (LIST-04)
    publishListEvent(listId, {
      type: 'item:added',
      listId,
      payload: { id: newItem.id, listId, text: newItem.text },
    });
    // Push: coalesced list-change notification to other accessible members (NOTIF-02)
    notifyListChange(listId, currentUserId);

    return c.json(
      {
        id: newItem.id,
        listId: newItem.listId,
        text: newItem.text,
        checked: newItem.checked,
        rank: newItem.rank,
        createdAt: newItem.createdAt,
        updatedAt: newItem.updatedAt,
      },
      201,
    );
  } catch (err) {
    console.error('[lists/POST /:id/items] DB operation failed:', err);
    return c.json({ error: 'Service unavailable' }, 503);
  }
});

// ---------------------------------------------------------------------------
// GET /api/lists/:id/items
//
// Return all items for the list, ordered by rank ASC.
// Access-gated: owner OR sharee only (T-04-05).
// ---------------------------------------------------------------------------
listsRouter.get('/:id/items', async (c) => {
  const currentUserId = await resolveUserId(c);
  if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);

  const listId = Number(c.req.param('id'));

  try {
    const access = await checkListAccess(listId, currentUserId);
    if (!access.allowed) {
      if (access.notFound) return c.json({ error: 'Not found' }, 404);
      return c.json({ error: 'Access denied' }, 403);
    }

    const items = await db
      .select()
      .from(listItems)
      .where(eq(listItems.listId, listId))
      .orderBy(asc(listItems.rank));

    const result = items.map((item) => ({
      id: item.id,
      listId: item.listId,
      text: item.text,
      checked: item.checked,
      rank: item.rank,
      createdAt: item.createdAt,
      updatedAt: item.updatedAt,
    }));

    return c.json({ items: result });
  } catch (err) {
    console.error('[lists/GET /:id/items] DB query failed:', err);
    return c.json({ error: 'Service unavailable' }, 503);
  }
});

// ---------------------------------------------------------------------------
// PATCH /api/list-items/:itemId
//
// Per-field last-write-wins update (D-08). Exactly one of: checked, text, position.
//
// Special case: { checked: false } (uncheck) recomputes rank to append-to-active-bottom
// in the same write — per Open Question 2 resolution.
//
// Security: T-04-05 — item must belong to a list the caller can access.
//           T-04-07 — patchItemSchema enforces exactly-one-field.
//           T-04-09 — if item row is missing (deleted), 404 (no upsert).
// ---------------------------------------------------------------------------
listItemsRouter.patch('/:itemId', zValidator('json', patchItemSchema), async (c) => {
  const currentUserId = await resolveUserId(c);
  if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);

  const itemId = Number(c.req.param('itemId'));
  const patch = c.req.valid('json');

  try {
    // Fetch the item to get its listId for access verification
    const [item] = await db.select().from(listItems).where(eq(listItems.id, itemId)).limit(1);

    // T-04-09: if deleted, 404 (no resurrection)
    if (!item) return c.json({ error: 'Not found' }, 404);

    const access = await checkListAccess(item.listId, currentUserId);
    if (!access.allowed) {
      if (access.notFound) return c.json({ error: 'Not found' }, 404);
      return c.json({ error: 'Access denied' }, 403);
    }

    // Build the update payload — single-field write with updatedAt=NOW()
    const updateValues: {
      checked?: boolean;
      text?: string;
      rank?: string;
      updatedAt?: Date;
    } = {};

    if (patch.checked !== undefined) {
      updateValues.checked = patch.checked;

      // Open Question 2: uncheck → recompute rank to active-bottom
      if (patch.checked === false) {
        const [lastActive] = await db
          .select({ rank: listItems.rank })
          .from(listItems)
          .where(
            and(
              eq(listItems.listId, item.listId),
              eq(listItems.checked, false),
              sql`${listItems.id} != ${itemId}`,
            ),
          )
          .orderBy(sql`${listItems.rank} DESC`)
          .limit(1);

        updateValues.rank = rankForAppend(lastActive?.rank ?? null);
      }
    } else if (patch.text !== undefined) {
      updateValues.text = patch.text;
    } else if (patch.position !== undefined) {
      updateValues.rank = patch.position;
    }

    await db.update(listItems).set(updateValues).where(eq(listItems.id, itemId));

    // Re-fetch to return the updated row
    const [updated] = await db.select().from(listItems).where(eq(listItems.id, itemId)).limit(1);

    // Fan-out: notify accessible subscribers that an item was updated (LIST-04)
    publishListEvent(item.listId, {
      type: 'item:updated',
      listId: item.listId,
      payload: { id: updated.id, listId: updated.listId },
    });
    // Push: coalesced list-change notification — only for meaningful changes (D-01).
    // Reorder (position) patches do NOT trigger a push; only checked/text changes do.
    if (patch.position === undefined) {
      notifyListChange(item.listId, currentUserId);
    }

    return c.json({
      id: updated.id,
      listId: updated.listId,
      text: updated.text,
      checked: updated.checked,
      rank: updated.rank,
      createdAt: updated.createdAt,
      updatedAt: updated.updatedAt,
    });
  } catch (err) {
    console.error('[lists/PATCH /list-items/:itemId] DB operation failed:', err);
    return c.json({ error: 'Service unavailable' }, 503);
  }
});

// ---------------------------------------------------------------------------
// DELETE /api/list-items/:itemId
//
// Delete an individual item instantly — no confirmation (D-06).
// Delete-wins semantics (D-09): no rollback path, no resurrection via in-flight edits.
//
// Security: T-04-05 — list access check (owner OR sharee) before delete.
// ---------------------------------------------------------------------------
listItemsRouter.delete('/:itemId', async (c) => {
  const currentUserId = await resolveUserId(c);
  if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);

  const itemId = Number(c.req.param('itemId'));

  try {
    // Fetch item to get listId for access check
    const [item] = await db
      .select({ id: listItems.id, listId: listItems.listId })
      .from(listItems)
      .where(eq(listItems.id, itemId))
      .limit(1);

    if (!item) return c.json({ error: 'Not found' }, 404);

    const access = await checkListAccess(item.listId, currentUserId);
    if (!access.allowed) {
      if (access.notFound) return c.json({ error: 'Not found' }, 404);
      return c.json({ error: 'Access denied' }, 403);
    }

    // Delete-wins (D-09): delete is final; no rollback path.
    await db.delete(listItems).where(eq(listItems.id, itemId));

    // Fan-out: notify accessible subscribers that an item was deleted (LIST-04)
    publishListEvent(item.listId, {
      type: 'item:deleted',
      listId: item.listId,
      payload: { id: itemId },
    });
    // Push: coalesced list-change notification to other accessible members (NOTIF-02)
    notifyListChange(item.listId, currentUserId);

    return c.json({ id: itemId });
  } catch (err) {
    console.error('[lists/DELETE /list-items/:itemId] DB operation failed:', err);
    return c.json({ error: 'Service unavailable' }, 503);
  }
});
