import { sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import type { PageParams, Paginated } from '@/lib/api/types';
import { MOCK_SEND_FAIL_SENTINEL, MOCK_VIEWER_USER_ID } from '../constants';
import type {
  AdminTicketDetail,
  AdminTicketFilters,
  AdminTicketMessage,
  AdminTicketSummary,
  OpenTicketRequest,
  OpenTicketResult,
  PostAdminMessageRequest,
  PostMessageRequest,
  PostMessageResult,
  TicketAuthorRole,
  TicketCategory,
  TicketDetail,
  TicketListParams,
  TicketMessage,
  TicketParticipant,
  TicketStatus,
  TicketSummary,
  TicketsApi,
} from '../types';

/**
 * In-memory `TicketsApi` — **the primary implementation this phase** (`USE_TICKETS_MOCK = true`; see
 * `constants.ts` for why: the linked bookings are themselves mock-primary, and the wire summary lacks
 * `unreadCount`/`lastMessageAt`, REQ-028).
 *
 * It is engineered to demonstrate the whole ticket surface end-to-end:
 *  - **No internal-note leak.** A ticket stores an admin **internal** note that the user view (`getTicket`)
 *    **drops** — mimicking the server's `is_internal` stripping — so the phase §7 step-3 test (the user
 *    thread shows none of it, no styling, no affordance) is demonstrable against the mock.
 *  - **Booking-linked coordination.** A `coordination` ticket is seeded for booking 5001 (the f8 confirmed
 *    seed); `openTicket` is idempotent for `coordination + bookingId` so "Get support" from that booking
 *    **jumps to the existing thread** instead of spawning duplicates.
 *  - **Optimistic send.** `postMessage` appends as the current viewer (tracked from the last `getTicket`);
 *    posting the dev sentinel `MOCK_SEND_FAIL_SENTINEL` throws a `500` so the failure→retry, draft-preserved
 *    path is exercisable; posting to a **closed** ticket is a `403` (contract).
 *  - **Unread indicator.** Each ticket tracks an `unread` count the inbox renders; opening the thread
 *    (`getTicket`) clears it — mirroring the notification "mark read on open" feel.
 */

const MOCK_LATENCY_MS = 250;

/** A stored message. `internal` messages exist in the store but are **never** returned by the user view. */
interface StoredMessage {
  id: number;
  senderId: number;
  body: string;
  internal: boolean;
  sentAt: string;
}

interface StoredTicket {
  id: number;
  referenceCode: string;
  subject: string | null;
  status: TicketStatus;
  category: TicketCategory;
  bookingId: number | null;
  refundId: number | null;
  openedById: number;
  closedAt: string | null;
  participants: TicketParticipant[];
  messages: StoredMessage[];
  /** Unread-for-the-viewer count the inbox renders; cleared when the thread is opened. */
  unread: number;
}

const CUSTOMER = MOCK_VIEWER_USER_ID.customer;
const NURSE = MOCK_VIEWER_USER_ID.nurse;
const ADMIN = MOCK_VIEWER_USER_ID.admin;

const CUSTOMER_PARTICIPANT: TicketParticipant = { userId: CUSTOMER, roleOnTicket: 'customer' };
const NURSE_PARTICIPANT: TicketParticipant = { userId: NURSE, roleOnTicket: 'nurse' };
const ADMIN_PARTICIPANT: TicketParticipant = { userId: ADMIN, roleOnTicket: 'admin' };

/** The participant record for a viewer id (so an opener is attributed to the right actor). */
function participantFor(userId: number): TicketParticipant {
  if (userId === NURSE) return NURSE_PARTICIPANT;
  if (userId === ADMIN) return ADMIN_PARTICIPANT;
  return CUSTOMER_PARTICIPANT;
}

/** ISO instant `mins` in the past — seeded message timestamps (rendered Shamsi client-side). */
function isoMinsAgo(mins: number): string {
  return new Date(Date.now() - mins * 60_000).toISOString();
}

let nextTicketId = 1301;
let nextMessageId = 50_000;

/**
 * The viewer of the most recent `getTicket` — so `postMessage` (which the seam gives no viewer) appends the
 * message as the right sender, making it render as "mine" on the next fetch in whichever app is open.
 */
let lastViewerUserId = CUSTOMER;

/**
 * The viewer of the most recent `getAdminTicket` — tracked SEPARATELY from `lastViewerUserId` so an admin
 * reading a thread never re-attributes a user's `postMessage`. `postAdminMessage` appends as this id.
 */
let lastAdminViewerUserId = ADMIN;

const tickets: StoredTicket[] = [
  {
    id: 1201,
    referenceCode: 'TKT-9F3K2A7Q',
    subject: 'هماهنگی ویزیت',
    status: 'open',
    category: 'coordination',
    bookingId: 5001,
    refundId: null,
    openedById: ADMIN,
    closedAt: null,
    participants: [CUSTOMER_PARTICIPANT, NURSE_PARTICIPANT, ADMIN_PARTICIPANT],
    unread: 1,
    messages: [
      { id: 40_001, senderId: ADMIN, body: 'این گفتگو برای هماهنگی ویزیت شما ایجاد شد. در صورت نیاز اینجا پیام بگذارید.', internal: false, sentAt: isoMinsAgo(600) },
      { id: 40_002, senderId: CUSTOMER, body: 'سلام، لطفاً ساعت ویزیت را به عصر منتقل کنید.', internal: false, sentAt: isoMinsAgo(540) },
      // Internal admin note — stored, but the user view NEVER returns it (server-strip mimic; §5 no-leak).
      { id: 40_003, senderId: ADMIN, body: 'INTERNAL: customer requested reschedule, confirm nurse availability before replying.', internal: true, sentAt: isoMinsAgo(520) },
      { id: 40_004, senderId: NURSE, body: 'سلام، بله امکان‌پذیر است. ساعت ۵ عصر هماهنگ شد.', internal: false, sentAt: isoMinsAgo(120) },
    ],
  },
  {
    id: 1202,
    referenceCode: 'TKT-4B7X1M2P',
    subject: 'سوال درباره سرویس',
    status: 'open',
    category: 'support',
    bookingId: null,
    refundId: null,
    openedById: CUSTOMER,
    closedAt: null,
    participants: [CUSTOMER_PARTICIPANT, ADMIN_PARTICIPANT],
    unread: 0,
    messages: [
      { id: 40_010, senderId: CUSTOMER, body: 'آیا امکان انتخاب پرستار خانم برای ویزیت بعدی هست؟', internal: false, sentAt: isoMinsAgo(2_880) },
      { id: 40_011, senderId: ADMIN, body: 'بله، هنگام جست‌وجو می‌توانید جنسیت مراقب را انتخاب کنید.', internal: false, sentAt: isoMinsAgo(2_820) },
    ],
  },
  {
    id: 1203,
    referenceCode: 'TKT-7C2D9E1F',
    subject: 'پیگیری بازپرداخت',
    status: 'closed',
    category: 'refund',
    bookingId: 5004,
    refundId: 9001,
    openedById: CUSTOMER,
    closedAt: isoMinsAgo(4_000),
    participants: [CUSTOMER_PARTICIPANT, ADMIN_PARTICIPANT],
    unread: 0,
    messages: [
      { id: 40_020, senderId: CUSTOMER, body: 'بازپرداخت من چه زمانی انجام می‌شود؟', internal: false, sentAt: isoMinsAgo(5_760) },
      { id: 40_021, senderId: ADMIN, body: 'بازپرداخت شما ثبت و به کارت شما واریز شد. این گفتگو بسته می‌شود.', internal: false, sentAt: isoMinsAgo(4_010) },
    ],
  },
];

function findTicket(id: number): StoredTicket {
  const t = tickets.find((x) => x.id === id);
  if (!t) throw new ApiError(404, 'Ticket not found', 'ticket_not_found');
  return t;
}

/** Last non-internal message time — the inbox's "last activity" (REQ-028 stand-in for `lastMessageAt`). */
function lastMessageAt(t: StoredTicket): string {
  const visible = t.messages.filter((m) => !m.internal);
  return visible.length ? visible[visible.length - 1].sentAt : t.messages[0]?.sentAt ?? new Date().toISOString();
}

function toSummary(t: StoredTicket): TicketSummary {
  return {
    id: t.id,
    referenceCode: t.referenceCode,
    subject: t.subject,
    status: t.status,
    category: t.category,
    bookingId: t.bookingId,
    refundId: t.refundId,
    createdAt: t.messages[0]?.sentAt ?? new Date().toISOString(),
    lastMessageAt: lastMessageAt(t),
    unreadCount: t.unread,
  };
}

function toDetail(t: StoredTicket, viewerUserId: number): TicketDetail {
  const roleBySender = new Map<number, TicketAuthorRole>(t.participants.map((p) => [p.userId, p.roleOnTicket]));
  const messages: TicketMessage[] = t.messages
    // The user view NEVER contains an internal message (server-strip mimic; phase §5).
    .filter((m) => !m.internal)
    .map((m) => ({
      id: m.id,
      ticketId: t.id,
      body: m.body,
      authorRole: roleBySender.get(m.senderId) ?? 'system',
      createdAt: m.sentAt,
      isMine: m.senderId === viewerUserId,
      sendStatus: 'sent' as const,
    }));
  return {
    id: t.id,
    referenceCode: t.referenceCode,
    subject: t.subject,
    status: t.status,
    category: t.category,
    bookingId: t.bookingId,
    refundId: t.refundId,
    openedById: t.openedById,
    closedAt: t.closedAt,
    participants: t.participants,
    messages,
  };
}

/** Admin summary — the queue row (no unread/last-activity; that's a user-inbox concern). */
function toAdminSummary(t: StoredTicket): AdminTicketSummary {
  return {
    id: t.id,
    referenceCode: t.referenceCode,
    subject: t.subject,
    status: t.status,
    category: t.category,
    bookingId: t.bookingId,
    refundId: t.refundId,
    createdAt: t.messages[0]?.sentAt ?? new Date().toISOString(),
  };
}

/**
 * Admin detail — the INVERSE of `toDetail`: it KEEPS internal messages and carries `isInternal`, so the
 * admin thread shows the internal-styled bubble the user view drops (the no-leak test is demonstrable both
 * ways against the same store).
 */
function toAdminDetail(t: StoredTicket, viewerUserId: number): AdminTicketDetail {
  const roleBySender = new Map<number, TicketAuthorRole>(t.participants.map((p) => [p.userId, p.roleOnTicket]));
  const messages: AdminTicketMessage[] = t.messages.map((m) => ({
    id: m.id,
    ticketId: t.id,
    body: m.body,
    authorRole: roleBySender.get(m.senderId) ?? 'system',
    createdAt: m.sentAt,
    isMine: m.senderId === viewerUserId,
    isInternal: m.internal,
    sendStatus: 'sent' as const,
  }));
  return {
    id: t.id,
    referenceCode: t.referenceCode,
    subject: t.subject,
    status: t.status,
    category: t.category,
    bookingId: t.bookingId,
    refundId: t.refundId,
    openedById: t.openedById,
    closedAt: t.closedAt,
    participants: t.participants,
    messages,
  };
}

/** `TKT-XXXXXXXX` — a stable, unique-looking reference (base36 of the id, not a real random code). */
function makeReferenceCode(id: number): string {
  return `TKT-${(id * 2_654_435_761 % 0xffffffff).toString(36).toUpperCase().padStart(8, '0').slice(-8)}`;
}

export const ticketsMockApi: TicketsApi = {
  listMyTickets: async (params: TicketListParams): Promise<Paginated<TicketSummary>> => {
    await sleep(MOCK_LATENCY_MS);
    // Own-scoping is server-enforced; the mock returns every seeded ticket (a demo world), newest-activity
    // first, optionally narrowed by status.
    let all = [...tickets];
    if (params.status) all = all.filter((t) => t.status === params.status);
    all.sort((a, b) => Date.parse(lastMessageAt(b)) - Date.parse(lastMessageAt(a)));
    const page = Math.max(1, params.page ?? 1);
    const pageSize = Math.max(1, params.pageSize ?? all.length);
    const start = (page - 1) * pageSize;
    return {
      items: all.slice(start, start + pageSize).map(toSummary),
      total: all.length,
      page,
      pageSize,
    };
  },

  getTicket: async (ticketId: number, viewerUserId?: number): Promise<TicketDetail> => {
    await sleep(MOCK_LATENCY_MS);
    const t = findTicket(ticketId);
    lastViewerUserId = viewerUserId ?? CUSTOMER;
    t.unread = 0; // opening the thread clears its unread indicator
    return toDetail(t, lastViewerUserId);
  },

  openTicket: async (body: OpenTicketRequest, viewerUserId?: number): Promise<OpenTicketResult> => {
    await sleep(MOCK_LATENCY_MS);
    // Idempotent coordination: "Get support" from a booking jumps to its existing coordination thread.
    if (body.category === 'coordination' && body.bookingId != null) {
      const existing = tickets.find((t) => t.category === 'coordination' && t.bookingId === body.bookingId);
      if (existing) {
        return { ticketId: existing.id, referenceCode: existing.referenceCode, status: existing.status, category: existing.category };
      }
    }
    // The opener is the caller (nurse or customer) — attribute the opening message + participant to them so
    // it renders as "mine" in whichever app opened it. Falls back to the last-viewed thread's viewer.
    const opener = viewerUserId ?? lastViewerUserId;
    const id = nextTicketId++;
    const participants: TicketParticipant[] = [CUSTOMER_PARTICIPANT, ADMIN_PARTICIPANT];
    if (body.bookingId != null && !participants.some((p) => p.userId === NURSE)) participants.push(NURSE_PARTICIPANT);
    const openerParticipant = participantFor(opener);
    if (!participants.some((p) => p.userId === openerParticipant.userId)) participants.push(openerParticipant);
    const now = new Date().toISOString();
    const ticket: StoredTicket = {
      id,
      referenceCode: makeReferenceCode(id),
      subject: body.subject?.trim() || null,
      status: 'open',
      category: body.category,
      bookingId: body.bookingId ?? null,
      refundId: body.refundId ?? null,
      openedById: opener,
      closedAt: null,
      participants,
      unread: 0,
      messages: [{ id: nextMessageId++, senderId: opener, body: body.body, internal: false, sentAt: now }],
    };
    tickets.unshift(ticket); // new ticket lands at the top of the inbox (phase §7 step 1)
    return { ticketId: id, referenceCode: ticket.referenceCode, status: 'open', category: ticket.category };
  },

  postMessage: async (ticketId: number, body: PostMessageRequest): Promise<PostMessageResult> => {
    await sleep(MOCK_LATENCY_MS);
    const t = findTicket(ticketId);
    // Dev-only failure trigger for the optimistic-send rollback/retry test (phase §7 step 2).
    if (body.body.trim() === MOCK_SEND_FAIL_SENTINEL) {
      throw new ApiError(500, 'Simulated send failure', 'mock_send_failed');
    }
    // Contract: a non-staff caller posting to a closed ticket → 403.
    if (t.status === 'closed') throw new ApiError(403, 'Ticket is closed', 'ticket_closed');
    const id = nextMessageId++;
    const sentAt = new Date().toISOString();
    t.messages.push({ id, senderId: lastViewerUserId, body: body.body, internal: false, sentAt });
    return { messageId: id, ticketId, sentAt };
  },

  // ── Admin lens ──────────────────────────────────────────────────────────────────────────────────
  // The global queue over EVERY ticket (own-scoping is a user-view rule), filterable the way the b15
  // admin console filters it.
  listAdminTickets: async (
    filters: AdminTicketFilters,
    params: PageParams,
  ): Promise<Paginated<AdminTicketSummary>> => {
    await sleep(MOCK_LATENCY_MS);
    let all = [...tickets];
    if (filters.status) all = all.filter((t) => t.status === filters.status);
    if (filters.category) all = all.filter((t) => t.category === filters.category);
    if (filters.referenceCode) {
      const needle = filters.referenceCode.trim().toLowerCase();
      all = all.filter((t) => t.referenceCode.toLowerCase().includes(needle));
    }
    if (filters.bookingId != null) all = all.filter((t) => t.bookingId === filters.bookingId);
    if (filters.refundId != null) all = all.filter((t) => t.refundId === filters.refundId);
    all.sort((a, b) => Date.parse(lastMessageAt(b)) - Date.parse(lastMessageAt(a)));
    const page = Math.max(1, params.page ?? 1);
    const pageSize = Math.max(1, params.pageSize ?? all.length);
    const start = (page - 1) * pageSize;
    return {
      items: all.slice(start, start + pageSize).map(toAdminSummary),
      total: all.length,
      page,
      pageSize,
    };
  },

  // The admin thread — the FULL store INCLUDING the internal note (`isInternal: true`), so the admin
  // no-leak-inverse is demonstrable. Opening it does NOT clear a user's unread indicator.
  getAdminTicket: async (ticketId: number, viewerUserId?: number): Promise<AdminTicketDetail> => {
    await sleep(MOCK_LATENCY_MS);
    const t = findTicket(ticketId);
    lastAdminViewerUserId = viewerUserId ?? ADMIN;
    return toAdminDetail(t, lastAdminViewerUserId);
  },

  // Staff post — appends with the given `internal` flag (the one caller allowed to). A staff caller may
  // also post to a closed ticket (contract: only NON-staff get a 403 there), so no closed-guard here.
  postAdminMessage: async (ticketId: number, body: PostAdminMessageRequest): Promise<PostMessageResult> => {
    await sleep(MOCK_LATENCY_MS);
    const t = findTicket(ticketId);
    if (body.body.trim() === MOCK_SEND_FAIL_SENTINEL) {
      throw new ApiError(500, 'Simulated send failure', 'mock_send_failed');
    }
    const id = nextMessageId++;
    const sentAt = new Date().toISOString();
    t.messages.push({ id, senderId: lastAdminViewerUserId, body: body.body, internal: body.isInternal, sentAt });
    return { messageId: id, ticketId, sentAt };
  },
};
