import type { Call, CalendarEvent, Message, Task } from "@slackwsh/contracts";
import { parseMentions } from "./mentions";

/**
 * "Should this interrupt someone, and what should it say?"
 *
 * One pure function for every notifiable realtime event. It lives here rather
 * than inline in the client's socket handler for two reasons: the rules are
 * the kind that rot silently (a duplicate toast for one edit, a notification
 * for your own action, ringing someone who is already looking at the call),
 * and a pure function is the only version of them that can be unit-tested
 * without a browser or a desktop build.
 *
 * Nothing in here touches `window`, `document` or the clock — everything
 * situational (who I am, what I'm looking at, what I just did, what I last
 * saw of this row) arrives in the context.
 */

export type MessageNotifyMode = "all" | "mentions" | "off";

export interface NotifyPrefs {
  /** "mentions" covers DMs too — a DM is addressed to you by definition. */
  messages: MessageNotifyMode;
  calls: boolean;
  tasks: boolean;
  calendar: boolean;
  sound: boolean;
  /** Right-side in-app flash toasts (2–3s). OS desktop toasts are separate. */
  inAppFlash: boolean;
}

/** Every message notified before preferences existed, so `all` is the
 * non-regressing default. */
export const DEFAULT_NOTIFY_PREFS: NotifyPrefs = {
  messages: "all",
  calls: true,
  tasks: true,
  calendar: true,
  sound: true,
  inAppFlash: true,
};

export type NotifiableEvent =
  | {
      type: "message:created";
      message: Message;
      workspaceId?: number | string | null;
      channelId?: number | string | null;
      /** Needed to tell a DM from a room; the wire message doesn't carry it. */
      channelType?: string | null;
      channelName?: string | null;
    }
  | { type: "call:started" | "call:updated" | "call:ended"; call: Call }
  | { type: "task:created" | "task:updated"; task: Task }
  | {
      type: "task:deleted";
      workspaceId: number | string;
      taskId: number | string;
      title: string;
      createdBy: number | string;
      deletedByUserId: number | string;
    }
  | { type: "event:created" | "event:updated"; event: CalendarEvent };

export interface NotifyContext {
  myUserId: number | null;
  /** Handle used to resolve @mentions; without it, only @here/@channel match. */
  myUsername?: string | null;
  prefs: NotifyPrefs;
  /** Current in-app path including query, e.g. "/channel?workspaceId=1&channelId=2". */
  currentRoute?: string | null;
  /** document.hidden — a backgrounded window is never "already looking at it". */
  hidden?: boolean;
  /** Display name for a user id, for notification copy. */
  nameFor?: (userId: number) => string | undefined;
  /**
   * The last version of this row this client saw. Realtime collapses every
   * mutation kind into one `*:updated` event, so "what changed" can only be
   * answered by diffing against what was already known.
   */
  previousTask?: Task | null;
  previousEvent?: CalendarEvent | null;
  previousCall?: Call | null;
  /**
   * True when this client itself caused the change. The server's `*:updated`
   * payload carries no actor, so an echo of your own edit is indistinguishable
   * from someone else's without this.
   */
  isSelfEcho?: (kind: "task" | "event" | "call" | "message", id: number) => boolean;
}

export type NotifySoundKind = "default" | "cheerful" | "short";

export interface NotificationDescription {
  title: string;
  body: string;
  /** In-app path to open on click. */
  route: string;
  /** Replaces an earlier notification about the same subject. */
  tag: string;
  group: string;
  /** Ignores the "I'm already looking at it" suppression. */
  urgent?: boolean;
  /** Post without a sound — something else is already making noise. */
  silent?: boolean;
  /** In-app chime. Omitted plays the default message ping. */
  sound?: NotifySoundKind;
  /** Play the assignment chime even if the Tasks page is already open. */
  chimeWhileLooking?: boolean;
  /** Skip the OS toast — used when the window is already on the target route. */
  skipToast?: boolean;
}

function firstName(name: string | undefined): string {
  return name?.split(" ")[0] ?? "Someone";
}

function preview(text: string | null | undefined): string {
  const trimmed = (text ?? "").trim();
  return (trimmed || "(attachment)").slice(0, 140);
}

/** Path equality ignoring parameter order, so "already looking at this" isn't
 * defeated by a query string the router happened to build differently. */
function isSameRoute(a: string | null | undefined, b: string): boolean {
  if (!a) return false;
  const parse = (value: string) => {
    const [path, query = ""] = value.split("?");
    const params = new URLSearchParams(query);
    const sorted = [...params.entries()].sort(([x], [y]) => x.localeCompare(y));
    return `${path}?${sorted.map(([k, v]) => `${k}=${v}`).join("&")}`;
  };
  try {
    return parse(a) === parse(b);
  } catch {
    return a === b;
  }
}

function whenLabel(iso: string): string {
  // Deliberately terse and locale-driven; the notification body has no room
  // for a full date and the OS already stamps its own arrival time.
  const date = new Date(iso);
  if (Number.isNaN(date.getTime())) return "";
  return date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
}

function describeMessage(
  event: Extract<NotifiableEvent, { type: "message:created" }>,
  ctx: NotifyContext,
): NotificationDescription | null {
  const { prefs, myUserId } = ctx;
  if (prefs.messages === "off" || myUserId == null) return null;
  // Your own message is never news.
  if (Number(event.message.authorId) === myUserId) return null;

  const isDm = event.channelType === "dm" || event.channelType === "group_dm";
  const mentions = parseMentions(event.message.text ?? "");
  const mentionsMe = mentions.some(
    (m) =>
      m.kind === "here" ||
      m.kind === "channel" ||
      m.kind === "everyone" ||
      (m.kind === "user" && ctx.myUsername != null && m.handle.toLowerCase() === ctx.myUsername.toLowerCase()),
  );
  if (prefs.messages === "mentions" && !isDm && !mentionsMe) return null;

  const author = firstName(ctx.nameFor?.(Number(event.message.authorId)));
  const workspaceId = event.workspaceId ?? event.message.workspaceId;
  const channelId = event.channelId ?? event.message.channelId;
  const isThreadReply = event.message.parentId != null;
  const route = isThreadReply
    ? `/channel?workspaceId=${workspaceId}&channelId=${channelId}&threadRootId=${event.message.parentId}`
    : `/channel?workspaceId=${workspaceId}&channelId=${channelId}`;

  return {
    title: isThreadReply
      ? event.channelName ? `${author} replied in #${event.channelName}` : `${author} replied in a thread`
      : isDm ? author : event.channelName ? `${author} in #${event.channelName}` : author,
    body: preview(event.message.text),
    route,
    tag: isThreadReply ? `thread-${event.message.parentId}` : `channel-${channelId}`,
    group: isThreadReply ? "threads" : "messages",
  };
}

function myParticipation(call: Call, myUserId: number) {
  return call.participants.find((p) => Number(p.userId) === myUserId);
}

function describeCall(
  event: Extract<NotifiableEvent, { type: "call:started" | "call:updated" | "call:ended" }>,
  ctx: NotifyContext,
): NotificationDescription | null {
  const { prefs, myUserId } = ctx;
  if (!prefs.calls || myUserId == null) return null;
  const call = event.call;
  const mine = myParticipation(call, myUserId);
  if (!mine) return null;
  const caller = firstName(ctx.nameFor?.(Number(call.startedBy)));
  const route = `/calls?workspaceId=${call.workspaceId}&callId=${call.id}`;

  if (event.type === "call:ended") {
    // A missed call is worth a notification precisely because you weren't
    // there to see the ringing one.
    if (mine.state !== "missed") return null;
    return {
      title: "Missed call",
      body: `${caller} tried to reach you`,
      route: `/calls?workspaceId=${call.workspaceId}`,
      tag: `call-${call.id}`,
      group: "calls",
    };
  }

  if (mine.state !== "ringing") return null;
  // Ringing is republished on every participant change, so without this the
  // same call would re-notify each time someone else's state moved.
  const wasAlreadyRinging =
    ctx.previousCall != null && myParticipation(ctx.previousCall, myUserId)?.state === "ringing";
  if (wasAlreadyRinging) return null;
  if (Number(call.startedBy) === myUserId) return null;

  return {
    title: call.kind === "video" ? "Incoming video call" : "Incoming call",
    body: `${caller} is calling${call.title ? ` · ${call.title}` : ""}`,
    route,
    tag: `call-${call.id}`,
    group: "calls",
    // Being rung interrupts even if you're in the app…
    urgent: true,
    // …but the in-app ringer is already making the noise.
    silent: true,
  };
}

function describeTask(
  event: Extract<NotifiableEvent, { type: "task:created" | "task:updated" }>,
  ctx: NotifyContext,
): NotificationDescription | null {
  const { prefs, myUserId } = ctx;
  if (!prefs.tasks || myUserId == null) return null;
  const task = event.task;
  if (ctx.isSelfEcho?.("task", task.id)) return null;

  const route = `/tasks?workspaceId=${task.workspaceId}`;
  const assignedToMe = task.assigneeUserId != null && Number(task.assigneeUserId) === myUserId;
  const previous = ctx.previousTask ?? null;
  const wasAssignedToMe = previous?.assigneeUserId != null && Number(previous.assigneeUserId) === myUserId;

  // Newly landed on my plate — the one task event that always deserves a say.
  // Creating a task assigned to yourself is not news; being given one later
  // (even a task you originally created) still is.
  if (assignedToMe && !wasAssignedToMe) {
    if (event.type === "task:created" && Number(task.createdBy) === myUserId) return null;
    const reassigned = event.type === "task:updated" && previous != null && previous.assigneeUserId != null;
    return {
      title: "New task assigned to you",
      body: task.title,
      route,
      tag: `task-${task.id}`,
      group: "tasks",
      sound: reassigned ? "short" : "cheerful",
      chimeWhileLooking: true,
    };
  }

  if (event.type !== "task:updated" || !previous) return null;

  const involvesMe = assignedToMe || Number(task.createdBy) === myUserId;
  if (!involvesMe) return null;

  if (previous.status !== task.status) {
    const label = task.status === "in_progress" ? "in progress" : task.status;
    return {
      title: `Task moved to ${label}`,
      body: task.title,
      route,
      tag: `task-${task.id}`,
      group: "tasks",
    };
  }

  // Someone taking a task off me is as worth knowing as being given one.
  if (wasAssignedToMe && !assignedToMe) {
    return {
      title: "Task reassigned",
      body: task.title,
      route,
      tag: `task-${task.id}`,
      group: "tasks",
    };
  }

  return null;
}

function describeTaskDeleted(
  event: Extract<NotifiableEvent, { type: "task:deleted" }>,
  ctx: NotifyContext,
): NotificationDescription | null {
  const { prefs, myUserId } = ctx;
  if (!prefs.tasks || myUserId == null) return null;
  if (Number(event.createdBy) !== myUserId) return null;
  if (Number(event.deletedByUserId) === myUserId) return null;
  if (ctx.isSelfEcho?.("task", Number(event.taskId))) return null;

  const deleter = firstName(ctx.nameFor?.(Number(event.deletedByUserId)));
  return {
    title: "Task deleted",
    body: `${deleter} deleted “${event.title}”`,
    route: `/tasks?workspaceId=${event.workspaceId}`,
    tag: `task-${event.taskId}`,
    group: "tasks",
    sound: "short",
    chimeWhileLooking: true,
  };
}

function describeCalendarEvent(
  event: Extract<NotifiableEvent, { type: "event:created" | "event:updated" }>,
  ctx: NotifyContext,
): NotificationDescription | null {
  const { prefs, myUserId } = ctx;
  if (!prefs.calendar || myUserId == null) return null;
  const calendarEvent = event.event;
  if (ctx.isSelfEcho?.("event", calendarEvent.id)) return null;

  const route = `/calendar?workspaceId=${calendarEvent.workspaceId}`;
  const invited = calendarEvent.attendees.some((a) => Number(a.userId) === myUserId);
  if (!invited) return null;
  if (Number(calendarEvent.createdBy) === myUserId) return null;

  const previous = ctx.previousEvent ?? null;
  const wasInvited = previous?.attendees.some((a) => Number(a.userId) === myUserId) ?? false;

  if (!wasInvited) {
    return {
      title: "New event invitation",
      body: `${calendarEvent.title} · ${whenLabel(calendarEvent.startsAt)}`,
      route,
      tag: `event-${calendarEvent.id}`,
      group: "calendar",
    };
  }

  // Only a change to when it happens is worth an interruption; a tweaked
  // description is not.
  if (previous && previous.startsAt !== calendarEvent.startsAt) {
    return {
      title: "Event moved",
      body: `${calendarEvent.title} · now ${whenLabel(calendarEvent.startsAt)}`,
      route,
      tag: `event-${calendarEvent.id}`,
      group: "calendar",
    };
  }

  return null;
}

/**
 * Returns what to show for an event, or null to stay silent.
 *
 * The final gate applies to every family: if the window is focused and already
 * showing the exact route the notification would open, saying it out loud is
 * noise. `urgent` (an incoming call) opts out.
 */
export function describeNotification(
  event: NotifiableEvent,
  ctx: NotifyContext,
): NotificationDescription | null {
  let description: NotificationDescription | null = null;
  switch (event.type) {
    case "message:created":
      description = describeMessage(event, ctx);
      break;
    case "call:started":
    case "call:updated":
    case "call:ended":
      description = describeCall(event, ctx);
      break;
    case "task:created":
    case "task:updated":
      description = describeTask(event, ctx);
      break;
    case "task:deleted":
      description = describeTaskDeleted(event, ctx);
      break;
    case "event:created":
    case "event:updated":
      description = describeCalendarEvent(event, ctx);
      break;
    default:
      description = null;
  }
  if (!description) return null;
  if (description.urgent) return description;
  const looking = ctx.hidden === false && isSameRoute(ctx.currentRoute, description.route);
  if (!looking) return description;
  // Assignment still rings if you're already on Tasks — otherwise a
  // reassignment to someone looking at the board is silent.
  if (description.chimeWhileLooking) return { ...description, skipToast: true };
  return null;
}
