import { ForbiddenException, BadRequestException, NotFoundException } from "@nestjs/common";
import { and, desc, eq, gte, isNull, lte, sql } from "drizzle-orm";
import type Redis from "ioredis";
import type { Task, TaskStatus } from "@slackwsh/contracts";
import { schema, withTenant, withUser } from "@slackwsh/data";
import { can } from "@slackwsh/core";
import {
  asEntityId,
  requireChannelMembership,
  requireWorkspaceMembership,
  type EntityIdInput,
} from "./channels";
import { activeWorkspaceMemberIds, fanoutToMembers } from "./member-fanout";

export interface CreateTaskInput {
  title: string;
  description?: string;
  assigneeUserId?: EntityIdInput | null;
  dueAt?: string | null;
  channelId?: EntityIdInput | null;
  status?: TaskStatus;
}

export interface UpdateTaskInput {
  title?: string;
  description?: string | null;
  dueAt?: string | null;
  channelId?: EntityIdInput | null;
}

export interface CreateTaskFromMessageInput {
  title?: string;
  description?: string;
  assigneeUserId?: EntityIdInput | null;
  dueAt?: string | null;
}

type TaskRow = typeof schema.tasks.$inferSelect;

async function requireTaskInWorkspace(
  tx: any,
  taskId: EntityIdInput,
  workspaceId: number,
  opts: { allowDeleted?: boolean } = {},
): Promise<TaskRow> {
  const id = asEntityId(taskId);
  const [task] = await tx.select().from(schema.tasks).where(eq(schema.tasks.id, id)).limit(1);
  if (!task || task.workspaceId !== workspaceId) throw new NotFoundException("task not found");
  if (!opts.allowDeleted && task.deletedAt != null) throw new NotFoundException("task not found");
  return task;
}

/** The channel a task optionally references must belong to the same
 * workspace — the same fetch-and-check every caller-supplied-id-pair action
 * in this codebase needs (RLS alone only scopes by workspace_id). */
async function requireChannelInWorkspace(tx: any, channelId: EntityIdInput, workspaceId: number) {
  const chId = asEntityId(channelId);
  const [channel] = await tx.select().from(schema.channels).where(eq(schema.channels.id, chId)).limit(1);
  if (!channel || channel.workspaceId !== workspaceId) throw new NotFoundException("channel not found");
  return channel;
}

export async function createTask(
  redis: Redis,
  workspaceId: EntityIdInput,
  actorUserId: EntityIdInput,
  input: CreateTaskInput,
): Promise<Task> {
  const wsId = asEntityId(workspaceId);
  const userId = asEntityId(actorUserId);
  const assigneeId = input.assigneeUserId != null ? asEntityId(input.assigneeUserId) : null;
  const channelId = input.channelId != null ? asEntityId(input.channelId) : null;

  const { task, memberIds } = await withTenant({ workspaceId: wsId, userId }, async (tx) => {
    const membership = await requireWorkspaceMembership(tx, wsId, userId);
    if (!can({ userId, role: membership.role }, "task:create")) {
      throw new ForbiddenException("insufficient role to create tasks");
    }

    if (assigneeId != null) await requireWorkspaceMembership(tx, wsId, assigneeId);
    if (channelId != null) await requireChannelInWorkspace(tx, channelId, wsId);

    const [inserted] = await tx
      .insert(schema.tasks)
      .values({
        workspaceId: wsId,
        title: input.title,
        description: input.description ?? null,
        status: input.status ?? "todo",
        assigneeUserId: assigneeId,
        dueAt: input.dueAt ? new Date(input.dueAt) : null,
        channelId,
        createdBy: userId,
      })
      .returning();
    if (!inserted) throw new Error("task insert returned no row");

    const memberIds = await activeWorkspaceMemberIds(tx, wsId);
    return { task: inserted as TaskRow, memberIds };
  });

  const wire = toWireTask(task);
  await notifyAssignee(assigneeId, userId, task);
  await fanoutToMembers(redis, memberIds, { type: "task:created", payload: { task: wire } });

  return wire;
}

export async function listTasks(
  workspaceId: EntityIdInput,
  actorUserId: EntityIdInput,
  filters: {
    status?: TaskStatus;
    assigneeUserId?: EntityIdInput;
    channelId?: EntityIdInput;
    mine?: boolean;
    deleted?: boolean;
    dueBefore?: string;
    dueAfter?: string;
    limit?: number;
  } = {},
): Promise<Task[]> {
  const wsId = asEntityId(workspaceId);
  const userId = asEntityId(actorUserId);
  return withTenant({ workspaceId: wsId, userId }, async (tx) => {
    await requireWorkspaceMembership(tx, wsId, userId);

    const conditions = [eq(schema.tasks.workspaceId, wsId)];
    if (filters.deleted) conditions.push(sql`${schema.tasks.deletedAt} is not null`);
    else conditions.push(isNull(schema.tasks.deletedAt));
    // Status tabs only apply to active tasks; the Deleted list shows every soft-deleted row.
    if (filters.status && !filters.deleted) conditions.push(eq(schema.tasks.status, filters.status));
    const assigneeFilter = filters.mine ? userId : filters.assigneeUserId != null ? asEntityId(filters.assigneeUserId) : null;
    if (assigneeFilter != null) conditions.push(eq(schema.tasks.assigneeUserId, assigneeFilter));
    if (filters.channelId != null) conditions.push(eq(schema.tasks.channelId, asEntityId(filters.channelId)));
    if (filters.dueBefore) conditions.push(lte(schema.tasks.dueAt, new Date(filters.dueBefore)));
    if (filters.dueAfter) conditions.push(gte(schema.tasks.dueAt, new Date(filters.dueAfter)));

    const rows = await tx
      .select()
      .from(schema.tasks)
      .where(and(...conditions))
      .orderBy(desc(filters.deleted ? schema.tasks.deletedAt : schema.tasks.updatedAt))
      .limit(filters.limit ?? 100);

    return rows.map(toWireTask);
  });
}

export async function getTask(workspaceId: EntityIdInput, actorUserId: EntityIdInput, taskId: EntityIdInput): Promise<Task> {
  const wsId = asEntityId(workspaceId);
  const userId = asEntityId(actorUserId);
  return withTenant({ workspaceId: wsId, userId }, async (tx) => {
    await requireWorkspaceMembership(tx, wsId, userId);
    const task = await requireTaskInWorkspace(tx, taskId, wsId);
    return toWireTask(task);
  });
}

export async function updateTask(
  redis: Redis,
  workspaceId: EntityIdInput,
  actorUserId: EntityIdInput,
  taskId: EntityIdInput,
  input: UpdateTaskInput,
): Promise<Task> {
  const wsId = asEntityId(workspaceId);
  const userId = asEntityId(actorUserId);

  const { task, memberIds } = await withTenant({ workspaceId: wsId, userId }, async (tx) => {
    const membership = await requireWorkspaceMembership(tx, wsId, userId);
    const existing = await requireTaskInWorkspace(tx, taskId, wsId);
    const isOwn = existing.createdBy === userId;
    if (!can({ userId, role: membership.role }, "task:update", { isOwnResource: isOwn })) {
      throw new ForbiddenException("insufficient permission to edit this task");
    }
    if (input.channelId !== undefined && input.channelId != null) {
      await requireChannelInWorkspace(tx, input.channelId, wsId);
    }

    const patch: Record<string, unknown> = { updatedAt: new Date() };
    if (input.title !== undefined) patch.title = input.title;
    if (input.description !== undefined) patch.description = input.description;
    if (input.dueAt !== undefined) patch.dueAt = input.dueAt ? new Date(input.dueAt) : null;
    if (input.channelId !== undefined) {
      const nextChannelId = input.channelId != null ? asEntityId(input.channelId) : null;
      patch.channelId = nextChannelId;
      // The schema's channelId/sourceMessageId pair invariant: a source
      // message always lives in the task's channel. Re-pointing the channel
      // would strand the old message reference, so drop it.
      if (existing.sourceMessageId != null && nextChannelId !== existing.channelId) patch.sourceMessageId = null;
    }

    const [updated] = await tx.update(schema.tasks).set(patch).where(eq(schema.tasks.id, existing.id)).returning();
    if (!updated) throw new Error("task update returned no row");

    const memberIds = await activeWorkspaceMemberIds(tx, wsId);
    return { task: updated as TaskRow, memberIds };
  });

  const wire = toWireTask(task);
  await fanoutToMembers(redis, memberIds, { type: "task:updated", payload: { task: wire } });
  return wire;
}

export async function assignTask(
  redis: Redis,
  workspaceId: EntityIdInput,
  actorUserId: EntityIdInput,
  taskId: EntityIdInput,
  assigneeUserId: EntityIdInput | null,
): Promise<Task> {
  const wsId = asEntityId(workspaceId);
  const userId = asEntityId(actorUserId);
  const assigneeId = assigneeUserId != null ? asEntityId(assigneeUserId) : null;

  const { task, memberIds, assigneeChanged } = await withTenant({ workspaceId: wsId, userId }, async (tx) => {
    const membership = await requireWorkspaceMembership(tx, wsId, userId);
    if (!can({ userId, role: membership.role }, "task:assign")) {
      throw new ForbiddenException("insufficient role to assign tasks");
    }
    const existing = await requireTaskInWorkspace(tx, taskId, wsId);
    if (assigneeId != null) await requireWorkspaceMembership(tx, wsId, assigneeId);

    const [updated] = await tx
      .update(schema.tasks)
      .set({ assigneeUserId: assigneeId, updatedAt: new Date() })
      .where(eq(schema.tasks.id, existing.id))
      .returning();
    if (!updated) throw new Error("task update returned no row");

    const memberIds = await activeWorkspaceMemberIds(tx, wsId);
    return {
      task: updated as TaskRow,
      memberIds,
      assigneeChanged: existing.assigneeUserId !== assigneeId,
    };
  });

  const wire = toWireTask(task);
  if (assigneeChanged) await notifyAssignee(assigneeId, userId, task);
  await fanoutToMembers(redis, memberIds, { type: "task:updated", payload: { task: wire } });
  return wire;
}

export async function updateTaskStatus(
  redis: Redis,
  workspaceId: EntityIdInput,
  actorUserId: EntityIdInput,
  taskId: EntityIdInput,
  status: TaskStatus,
): Promise<Task> {
  const wsId = asEntityId(workspaceId);
  const userId = asEntityId(actorUserId);

  const { task, memberIds } = await withTenant({ workspaceId: wsId, userId }, async (tx) => {
    const membership = await requireWorkspaceMembership(tx, wsId, userId);
    const existing = await requireTaskInWorkspace(tx, taskId, wsId);
    const isOwn = existing.createdBy === userId || existing.assigneeUserId === userId;
    if (!can({ userId, role: membership.role }, "task:update_status", { isOwnResource: isOwn })) {
      throw new ForbiddenException("insufficient permission to change this task's status");
    }

    const [updated] = await tx
      .update(schema.tasks)
      .set({
        status,
        completedAt: status === "done" ? new Date() : null,
        updatedAt: new Date(),
      })
      .where(eq(schema.tasks.id, existing.id))
      .returning();
    if (!updated) throw new Error("task update returned no row");

    const memberIds = await activeWorkspaceMemberIds(tx, wsId);
    return { task: updated as TaskRow, memberIds };
  });

  const wire = toWireTask(task);
  await fanoutToMembers(redis, memberIds, { type: "task:updated", payload: { task: wire } });
  return wire;
}

export async function deleteTask(
  redis: Redis,
  workspaceId: EntityIdInput,
  actorUserId: EntityIdInput,
  taskId: EntityIdInput,
): Promise<void> {
  const wsId = asEntityId(workspaceId);
  const userId = asEntityId(actorUserId);

  const { taskIdNum, memberIds, title, createdBy, deletedByName } = await withTenant({ workspaceId: wsId, userId }, async (tx) => {
    const membership = await requireWorkspaceMembership(tx, wsId, userId);
    const existing = await requireTaskInWorkspace(tx, taskId, wsId);
    const isOwn = existing.createdBy === userId;
    if (!can({ userId, role: membership.role }, "task:delete", { isOwnResource: isOwn })) {
      throw new ForbiddenException("insufficient permission to delete this task");
    }

    const [actor] = await tx
      .select({ name: schema.users.name })
      .from(schema.users)
      .where(eq(schema.users.id, userId))
      .limit(1);

    await tx
      .update(schema.tasks)
      .set({
        deletedAt: new Date(),
        deletedBy: userId,
        updatedAt: new Date(),
      })
      .where(eq(schema.tasks.id, existing.id));
    const memberIds = await activeWorkspaceMemberIds(tx, wsId);
    return {
      taskIdNum: existing.id,
      memberIds,
      title: existing.title,
      createdBy: existing.createdBy,
      deletedByName: actor?.name ?? "Someone",
    };
  });

  await notifyCreatorOfDelete(createdBy, userId, { id: taskIdNum, title, workspaceId: wsId, deletedByName });
  await fanoutToMembers(redis, memberIds, {
    type: "task:deleted",
    payload: {
      workspaceId: wsId,
      taskId: taskIdNum,
      title,
      createdBy,
      deletedByUserId: userId,
    },
  });
}

/** Bring a soft-deleted task back into the active lists. */
export async function restoreTask(
  redis: Redis,
  workspaceId: EntityIdInput,
  actorUserId: EntityIdInput,
  taskId: EntityIdInput,
): Promise<Task> {
  const wsId = asEntityId(workspaceId);
  const userId = asEntityId(actorUserId);

  const { task, memberIds } = await withTenant({ workspaceId: wsId, userId }, async (tx) => {
    const membership = await requireWorkspaceMembership(tx, wsId, userId);
    const existing = await requireTaskInWorkspace(tx, taskId, wsId, { allowDeleted: true });
    if (existing.deletedAt == null) throw new BadRequestException("task is not deleted");
    const isOwn = existing.createdBy === userId;
    if (!can({ userId, role: membership.role }, "task:delete", { isOwnResource: isOwn })) {
      throw new ForbiddenException("insufficient permission to restore this task");
    }

    const [updated] = await tx
      .update(schema.tasks)
      .set({ deletedAt: null, deletedBy: null, updatedAt: new Date() })
      .where(eq(schema.tasks.id, existing.id))
      .returning();
    if (!updated) throw new Error("task restore returned no row");
    const memberIds = await activeWorkspaceMemberIds(tx, wsId);
    return { task: updated as TaskRow, memberIds };
  });

  const wire = toWireTask(task);
  await fanoutToMembers(redis, memberIds, { type: "task:created", payload: { task: wire } });
  return wire;
}

/** The one place a task action checks *channel* membership rather than just
 * workspace membership: the actor must be able to read the specific message
 * they're building a task from. Standalone task creation and the general
 * list intentionally have no such gate (tasks are workspace-wide). */
export async function createTaskFromMessage(
  redis: Redis,
  workspaceId: EntityIdInput,
  actorUserId: EntityIdInput,
  channelId: EntityIdInput,
  messageId: EntityIdInput,
  input: CreateTaskFromMessageInput,
): Promise<Task> {
  const wsId = asEntityId(workspaceId);
  const userId = asEntityId(actorUserId);
  const chId = asEntityId(channelId);
  const msgId = asEntityId(messageId);
  const assigneeId = input.assigneeUserId != null ? asEntityId(input.assigneeUserId) : null;

  const { task, memberIds } = await withTenant({ workspaceId: wsId, userId }, async (tx) => {
    const membership = await requireWorkspaceMembership(tx, wsId, userId);
    if (!can({ userId, role: membership.role }, "task:create")) {
      throw new ForbiddenException("insufficient role to create tasks");
    }
    await requireChannelMembership(tx, chId, userId);

    const [message] = await tx.select().from(schema.messages).where(eq(schema.messages.id, msgId)).limit(1);
    if (!message || message.workspaceId !== wsId || message.channelId !== chId) {
      throw new NotFoundException("message not found in this channel");
    }
    if (assigneeId != null) await requireWorkspaceMembership(tx, wsId, assigneeId);

    const sourceText = message.text || "(attachment)";
    const [inserted] = await tx
      .insert(schema.tasks)
      .values({
        workspaceId: wsId,
        title: input.title ?? sourceText.slice(0, 80),
        description: input.description ?? sourceText,
        status: "todo",
        assigneeUserId: assigneeId,
        dueAt: input.dueAt ? new Date(input.dueAt) : null,
        channelId: chId,
        sourceMessageId: msgId,
        createdBy: userId,
      })
      .returning();
    if (!inserted) throw new Error("task insert returned no row");

    const memberIds = await activeWorkspaceMemberIds(tx, wsId);
    return { task: inserted as TaskRow, memberIds };
  });

  const wire = toWireTask(task);
  await notifyAssignee(assigneeId, userId, task);
  await fanoutToMembers(redis, memberIds, { type: "task:created", payload: { task: wire } });

  return wire;
}

/** Skips notifying yourself, mirroring sendMessage's peer-fanout guard. Runs
 * in its own withUser transaction — the same direct-insert pattern
 * apps/api/src/workspaces/workspaces.service.ts uses for "workspace_invite",
 * since apps/worker's notification consumer is still just a logging stub. */
async function notifyAssignee(assigneeId: number | null, actorUserId: number, task: TaskRow) {
  if (assigneeId == null || assigneeId === actorUserId) return;
  await withUser(assigneeId, (tx) =>
    tx.insert(schema.notifications).values({
      userId: assigneeId,
      type: "task_assigned",
      title: "New task assigned to you",
      body: task.title,
      payload: { taskId: task.id, workspaceId: task.workspaceId, assignedByUserId: actorUserId },
    }),
  );
}

async function notifyCreatorOfDelete(
  creatorId: number,
  actorUserId: number,
  task: { id: number; title: string; workspaceId: number; deletedByName: string },
) {
  if (creatorId === actorUserId) return;
  const who = task.deletedByName.split(" ")[0] || "Someone";
  await withUser(creatorId, (tx) =>
    tx.insert(schema.notifications).values({
      userId: creatorId,
      type: "task_deleted",
      title: "Task deleted",
      body: `${who} deleted “${task.title}”`,
      payload: { taskId: task.id, workspaceId: task.workspaceId, deletedByUserId: actorUserId },
    }),
  );
}

function unreadAssignedScope(userId: number, workspaceId: number) {
  return and(
    eq(schema.notifications.userId, userId),
    eq(schema.notifications.type, "task_assigned"),
    isNull(schema.notifications.readAt),
    sql`${schema.notifications.payload}->>'workspaceId' = ${String(workspaceId)}`,
  );
}

/** How many assignment notifications the actor has never opened — the Tasks badge. */
export async function unreadAssignedTaskCount(
  workspaceId: EntityIdInput,
  actorUserId: EntityIdInput,
): Promise<number> {
  const wsId = asEntityId(workspaceId);
  const userId = asEntityId(actorUserId);
  await withTenant({ workspaceId: wsId, userId }, async (tx) => {
    await requireWorkspaceMembership(tx, wsId, userId);
  });
  return withUser(userId, async (tx) => {
    const rows = await tx
      .select({ id: schema.notifications.id })
      .from(schema.notifications)
      .where(unreadAssignedScope(userId, wsId));
    return rows.length;
  });
}

/** Opening the Tasks page is the acknowledgement — the badge clears here
 * rather than per-row, matching how missed calls behave. */
export async function markAssignedTasksSeen(
  workspaceId: EntityIdInput,
  actorUserId: EntityIdInput,
): Promise<{ seen: number }> {
  const wsId = asEntityId(workspaceId);
  const userId = asEntityId(actorUserId);
  await withTenant({ workspaceId: wsId, userId }, async (tx) => {
    await requireWorkspaceMembership(tx, wsId, userId);
  });
  return withUser(userId, async (tx) => {
    const updated = await tx
      .update(schema.notifications)
      .set({ readAt: new Date() })
      .where(unreadAssignedScope(userId, wsId))
      .returning({ id: schema.notifications.id });
    return { seen: updated.length };
  });
}

// The `status` column is a Postgres enum (task_status), so Drizzle's row
// type is already narrow here — unlike messages' `type`/`blocks` (plain
// text columns wider than the wire union), no cast is needed for status.
function toWireTask(row: TaskRow): Task {
  return {
    id: row.id,
    workspaceId: row.workspaceId,
    title: row.title,
    description: row.description,
    status: row.status,
    assigneeUserId: row.assigneeUserId,
    dueAt: row.dueAt?.toISOString() ?? null,
    createdBy: row.createdBy,
    channelId: row.channelId,
    sourceMessageId: row.sourceMessageId,
    completedAt: row.completedAt?.toISOString() ?? null,
    deletedAt: row.deletedAt?.toISOString() ?? null,
    deletedBy: row.deletedBy,
    createdAt: row.createdAt.toISOString(),
    updatedAt: row.updatedAt.toISOString(),
  };
}
