/**
 * Task-dependency query helpers — task-dependencies.
 *
 * All helpers are tenant-scoped (tenant_id in every WHERE).
 * Routes MUST NOT use raw Drizzle — they call these helpers.
 *
 * Exports:
 *  - getDependencies          — { blocking, blocked_by } for a task
 *  - getProjectEdges          — all edges for a project (cycle-check / critical-path)
 *  - insertDependency         — create one edge
 *  - deleteDependency         — remove one edge by id
 *  - assertSameProject        — throw CrossProjectDependencyError on mismatch
 *  - wouldCreateCycle         — DFS cycle check before insert
 *  - computeCriticalPath      — topological longest-path
 *  - getCachedCriticalPath    — KV-backed critical-path result
 *  - getProjectRevision       — read KV revision counter
 *  - bumpProjectRevision      — increment KV revision counter
 *  - getUnsatisfiedBlockers   — blockers whose status is not terminal
 *  - DuplicateDependencyError — raised on unique constraint violation
 *  - CrossProjectDependencyError — raised on cross-project edge attempt
 */
import { and, eq, inArray } from 'drizzle-orm'
import type { Db } from '../client'
import { taskDependencies } from '../schema/task-dependencies'
import { tasks, taskLabels, taskStatuses } from '../schema/tasks'
import type {
  TaskDependency,
  DependenciesResponse,
  DependencyEntry,
  CriticalPathResult,
} from '@zync/types'
import type { TaskObject } from '@zync/types'
import type { Env } from '@zync/types'

// ── Domain errors ─────────────────────────────────────────────────────────────

export class DuplicateDependencyError extends Error {
  constructor() {
    super('Dependency edge already exists')
    this.name = 'DuplicateDependencyError'
  }
}

export class CrossProjectDependencyError extends Error {
  constructor() {
    super('Both tasks must belong to the same project')
    this.name = 'CrossProjectDependencyError'
  }
}

// ── Local serializer (mirrors queries/tasks.ts#serializeTask) ─────────────────

function serializeTaskRow(
  row: typeof tasks.$inferSelect,
  labels: string[],
): TaskObject {
  return {
    id: row.id,
    tenant_id: row.tenantId,
    project_id: row.projectId ?? null,
    status_id: row.statusId,
    title: row.title,
    description: row.description ?? null,
    priority: row.priority as TaskObject['priority'],
    assignee_id: row.assigneeId ?? null,
    reporter_id: row.reporterId,
    due_date: row.dueDate ?? null,
    estimated_hours:
      row.estimatedHours !== null && row.estimatedHours !== undefined
        ? parseFloat(String(row.estimatedHours))
        : null,
    source: row.source as TaskObject['source'],
    external_id: row.externalId ?? null,
    position: parseFloat(String(row.position)),
    labels,
    created_at: row.createdAt.toISOString(),
    updated_at: row.updatedAt.toISOString(),
  }
}

async function getLabelsForTasks(
  db: Db,
  taskIds: string[],
): Promise<Record<string, string[]>> {
  if (taskIds.length === 0) return {}
  const rows = await db
    .select({ taskId: taskLabels.taskId, label: taskLabels.label })
    .from(taskLabels)
    .where(inArray(taskLabels.taskId, taskIds))
  const map: Record<string, string[]> = {}
  for (const row of rows) {
    if (!map[row.taskId]) map[row.taskId] = []
    map[row.taskId]!.push(row.label)
  }
  return map
}

// ── Serialize dependency row ──────────────────────────────────────────────────

function serializeDep(row: typeof taskDependencies.$inferSelect): TaskDependency {
  return {
    id: row.id,
    blocking_task_id: row.blockingTaskId,
    blocked_task_id: row.blockedTaskId,
    created_by: row.createdBy,
    created_at: row.createdAt.toISOString(),
  }
}

// ── getDependencies ───────────────────────────────────────────────────────────

/**
 * Returns the two dependency groups for a task:
 *  - blocking:  tasks that THIS task blocks (edges where blocking_task_id = taskId)
 *  - blocked_by: tasks that block THIS task (edges where blocked_task_id = taskId)
 */
export async function getDependencies(
  db: Db,
  tenantId: string,
  taskId: string,
): Promise<DependenciesResponse> {
  // edges where this task is the blocker → it blocks those tasks
  const blockingEdges = await db
    .select({
      id: taskDependencies.id,
      blockedTaskId: taskDependencies.blockedTaskId,
    })
    .from(taskDependencies)
    .where(
      and(
        eq(taskDependencies.tenantId, tenantId),
        eq(taskDependencies.blockingTaskId, taskId),
      ),
    )

  // edges where this task is the blocked one → it is blocked by those tasks
  const blockedByEdges = await db
    .select({
      id: taskDependencies.id,
      blockingTaskId: taskDependencies.blockingTaskId,
    })
    .from(taskDependencies)
    .where(
      and(
        eq(taskDependencies.tenantId, tenantId),
        eq(taskDependencies.blockedTaskId, taskId),
      ),
    )

  const downstreamPairs = blockingEdges.map((e) => ({ depId: e.id, taskId: e.blockedTaskId }))
  const upstreamPairs = blockedByEdges.map((e) => ({ depId: e.id, taskId: e.blockingTaskId }))

  const allIds = [
    ...new Set([
      ...downstreamPairs.map((p) => p.taskId),
      ...upstreamPairs.map((p) => p.taskId),
    ]),
  ]
  if (allIds.length === 0) return { blocking: [], blocked_by: [] }

  const taskRows = await db
    .select()
    .from(tasks)
    .where(
      and(
        eq(tasks.tenantId, tenantId),
        inArray(tasks.id, allIds),
      ),
    )

  const labelsMap = await getLabelsForTasks(db, allIds)
  const taskMap: Record<string, TaskObject> = {}
  for (const row of taskRows) {
    taskMap[row.id] = serializeTaskRow(row, labelsMap[row.id] ?? [])
  }

  const blocking: DependencyEntry[] = downstreamPairs.flatMap((p) =>
    taskMap[p.taskId] ? [{ dependency_id: p.depId, task: taskMap[p.taskId]! }] : [],
  )
  const blocked_by: DependencyEntry[] = upstreamPairs.flatMap((p) =>
    taskMap[p.taskId] ? [{ dependency_id: p.depId, task: taskMap[p.taskId]! }] : [],
  )

  return { blocking, blocked_by }
}

// ── getProjectEdges ───────────────────────────────────────────────────────────

/**
 * All dependency edges for tasks within a project.
 * Joins through tasks to filter by project_id.
 */
export async function getProjectEdges(
  db: Db,
  tenantId: string,
  projectId: string,
): Promise<TaskDependency[]> {
  // Load all task ids in this project first (tenant-scoped)
  const projectTasks = await db
    .select({ id: tasks.id })
    .from(tasks)
    .where(
      and(
        eq(tasks.tenantId, tenantId),
        eq(tasks.projectId, projectId),
      ),
    )

  if (projectTasks.length === 0) return []

  const taskIds = projectTasks.map((t) => t.id)

  const edges = await db
    .select()
    .from(taskDependencies)
    .where(
      and(
        eq(taskDependencies.tenantId, tenantId),
        inArray(taskDependencies.blockingTaskId, taskIds),
      ),
    )

  return edges.map(serializeDep)
}

export async function getProjectIdsForTasks(
  db: Db,
  tenantId: string,
  taskIds: string[],
): Promise<string[]> {
  if (taskIds.length === 0) return []

  const rows = await db
    .select({ projectId: tasks.projectId })
    .from(tasks)
    .where(
      and(
        eq(tasks.tenantId, tenantId),
        inArray(tasks.id, taskIds),
      ),
    )

  return [
    ...new Set(
      rows
        .map((row) => row.projectId)
        .filter((projectId): projectId is string => projectId !== null),
    ),
  ]
}

// ── InsertDependencyInput ─────────────────────────────────────────────────────

export interface InsertDependencyInput {
  blockingTaskId: string
  blockedTaskId: string
  createdBy: string
}

// ── insertDependency ──────────────────────────────────────────────────────────

export async function insertDependency(
  db: Db,
  tenantId: string,
  input: InsertDependencyInput,
): Promise<TaskDependency> {
  try {
    const [row] = await db
      .insert(taskDependencies)
      .values({
        tenantId,
        blockingTaskId: input.blockingTaskId,
        blockedTaskId: input.blockedTaskId,
        createdBy: input.createdBy,
      })
      .returning()

    if (!row) throw new Error('Insert returned no row')
    return serializeDep(row)
  } catch (err: unknown) {
    // Translate Postgres unique-violation (code 23505) to a domain error
    if (
      err instanceof Error &&
      (err.message.includes('23505') ||
        err.message.toLowerCase().includes('unique'))
    ) {
      throw new DuplicateDependencyError()
    }
    throw err
  }
}

// ── deleteDependency ──────────────────────────────────────────────────────────

export async function deleteDependency(
  db: Db,
  tenantId: string,
  dependencyId: string,
): Promise<void> {
  await db
    .delete(taskDependencies)
    .where(
      and(
        eq(taskDependencies.tenantId, tenantId),
        eq(taskDependencies.id, dependencyId),
      ),
    )
}

// ── assertSameProject ─────────────────────────────────────────────────────────

export async function assertSameProject(
  db: Db,
  tenantId: string,
  taskAId: string,
  taskBId: string,
): Promise<{ projectId: string }> {
  const rows = await db
    .select({ id: tasks.id, projectId: tasks.projectId })
    .from(tasks)
    .where(
      and(
        eq(tasks.tenantId, tenantId),
        inArray(tasks.id, [taskAId, taskBId]),
      ),
    )

  const a = rows.find((r) => r.id === taskAId)
  const b = rows.find((r) => r.id === taskBId)

  if (!a || !b) {
    throw new CrossProjectDependencyError()
  }

  if (a.projectId !== b.projectId || a.projectId === null) {
    throw new CrossProjectDependencyError()
  }

  return { projectId: a.projectId }
}

// ── wouldCreateCycle ──────────────────────────────────────────────────────────

/**
 * Returns true if adding the edge (blockingTaskId → blockedTaskId)
 * would introduce a cycle in the project's dependency graph.
 *
 * Algorithm: iterative DFS from blockedTaskId following blocker→blocked edges.
 * If blockingTaskId is reachable, the proposed edge closes a cycle.
 * O(V + E).
 */
export async function wouldCreateCycle(
  db: Db,
  tenantId: string,
  args: { projectId: string; blockingTaskId: string; blockedTaskId: string },
): Promise<boolean> {
  const edges = await getProjectEdges(db, tenantId, args.projectId)

  // Build adjacency: blockingTaskId → [blockedTaskId, ...]
  const adj: Record<string, string[]> = {}
  for (const edge of edges) {
    if (!adj[edge.blocking_task_id]) adj[edge.blocking_task_id] = []
    adj[edge.blocking_task_id]!.push(edge.blocked_task_id)
  }

  // Add the candidate edge to the graph
  if (!adj[args.blockingTaskId]) adj[args.blockingTaskId] = []
  adj[args.blockingTaskId]!.push(args.blockedTaskId)

  // Iterative DFS from args.blockedTaskId; look for args.blockingTaskId
  const visited = new Set<string>()
  const stack: string[] = [args.blockedTaskId]

  while (stack.length > 0) {
    const current = stack.pop()!
    if (current === args.blockingTaskId) return true
    if (visited.has(current)) continue
    visited.add(current)

    const neighbors = adj[current] ?? []
    for (const neighbor of neighbors) {
      if (!visited.has(neighbor)) {
        stack.push(neighbor)
      }
    }
  }

  return false
}

// ── computeCriticalPath ───────────────────────────────────────────────────────

/**
 * Computes the critical path (longest weighted path) in the project's DAG.
 * Weight = task.estimated_hours (default 1 when null).
 *
 * Returns { critical_task_ids, critical_dependency_ids }.
 * Throws if a cycle is detected (should not happen given wouldCreateCycle guards).
 */
export async function computeCriticalPath(
  db: Db,
  tenantId: string,
  projectId: string,
): Promise<CriticalPathResult> {
  const [projectTasks, edges] = await Promise.all([
    db
      .select({ id: tasks.id, estimatedHours: tasks.estimatedHours })
      .from(tasks)
      .where(
        and(
          eq(tasks.tenantId, tenantId),
          eq(tasks.projectId, projectId),
        ),
      ),
    getProjectEdges(db, tenantId, projectId),
  ])

  if (projectTasks.length === 0) {
    return { critical_task_ids: [], critical_dependency_ids: [] }
  }

  // Build node weight map
  const weight: Record<string, number> = {}
  for (const t of projectTasks) {
    weight[t.id] = t.estimatedHours !== null && t.estimatedHours !== undefined
      ? parseFloat(String(t.estimatedHours))
      : 1
  }

  // Build adjacency (blocker → blocked) and reverse adjacency (blocked → [blockers])
  const outEdges: Record<string, Array<{ to: string; edgeId: string }>> = {}
  const inDegree: Record<string, number> = {}

  for (const t of projectTasks) {
    outEdges[t.id] = []
    inDegree[t.id] = 0
  }

  for (const edge of edges) {
    const src = edge.blocking_task_id
    const dst = edge.blocked_task_id
    if (!outEdges[src]) outEdges[src] = []
    outEdges[src]!.push({ to: dst, edgeId: edge.id })
    inDegree[dst] = (inDegree[dst] ?? 0) + 1
  }

  // Kahn's topological sort
  const queue: string[] = []
  for (const id of Object.keys(inDegree)) {
    if ((inDegree[id] ?? 0) === 0) queue.push(id)
  }

  const topoOrder: string[] = []
  const tempDegree = { ...inDegree }

  while (queue.length > 0) {
    const node = queue.shift()!
    topoOrder.push(node)
    for (const { to } of outEdges[node] ?? []) {
      tempDegree[to] = (tempDegree[to] ?? 0) - 1
      if (tempDegree[to] === 0) queue.push(to)
    }
  }

  if (topoOrder.length !== projectTasks.length) {
    throw new Error('Cycle detected in project dependency graph during critical-path computation')
  }

  // Longest-path DP (forward pass)
  // dist[id] = max accumulated weight ending at id (including id's own weight)
  const dist: Record<string, number> = {}
  const prevNode: Record<string, string | null> = {}
  const prevEdge: Record<string, string | null> = {}

  for (const id of topoOrder) {
    dist[id] = weight[id] ?? 1
    prevNode[id] = null
    prevEdge[id] = null
  }

  for (const node of topoOrder) {
    for (const { to, edgeId } of outEdges[node] ?? []) {
      const candidate = (dist[node] ?? 0) + (weight[to] ?? 1)
      if (candidate > (dist[to] ?? 0)) {
        dist[to] = candidate
        prevNode[to] = node
        prevEdge[to] = edgeId
      }
    }
  }

  // Find the node with the max distance (end of critical path)
  let maxDist = 0
  let endNode: string | null = null
  for (const [id, d] of Object.entries(dist)) {
    if (d > maxDist) {
      maxDist = d
      endNode = id
    }
  }

  // Back-trace the critical path
  const critical_task_ids: string[] = []
  const critical_dependency_ids: string[] = []

  let cur: string | null = endNode
  while (cur !== null) {
    critical_task_ids.unshift(cur)
    const eId = prevEdge[cur]
    if (eId) critical_dependency_ids.unshift(eId)
    cur = prevNode[cur] ?? null
  }

  return { critical_task_ids, critical_dependency_ids }
}

// ── KV revision helpers ───────────────────────────────────────────────────────

function revKey(tenantId: string, projectId: string): string {
  return `critpath:rev:${tenantId}:${projectId}`
}

function cacheKey(tenantId: string, projectId: string, rev: number): string {
  return `critpath:${tenantId}:${projectId}:${rev}`
}

export async function getProjectRevision(
  env: Env,
  tenantId: string,
  projectId: string,
): Promise<number> {
  const raw = await env.KV.get(revKey(tenantId, projectId))
  return raw !== null ? parseInt(raw, 10) : 0
}

export async function bumpProjectRevision(
  env: Env,
  tenantId: string,
  projectId: string,
): Promise<void> {
  const current = await getProjectRevision(env, tenantId, projectId)
  await env.KV.put(revKey(tenantId, projectId), String(current + 1))
}

// ── getCachedCriticalPath ─────────────────────────────────────────────────────

/**
 * Returns the critical path from KV cache, computing and storing on miss.
 * Cache invalidation is implicit: bumped revision changes the cache key.
 */
export async function getCachedCriticalPath(
  env: Env,
  db: Db,
  tenantId: string,
  projectId: string,
): Promise<CriticalPathResult> {
  const rev = await getProjectRevision(env, tenantId, projectId)
  const key = cacheKey(tenantId, projectId, rev)

  const cached = await env.KV.get(key)
  if (cached !== null) {
    return JSON.parse(cached) as CriticalPathResult
  }

  const result = await computeCriticalPath(db, tenantId, projectId)
  // Cache for 1 hour; invalidation is by revision key bump, not TTL
  await env.KV.put(key, JSON.stringify(result), { expirationTtl: 3600 })
  return result
}

// ── getUnsatisfiedBlockers ────────────────────────────────────────────────────

/**
 * Returns the tasks that block taskId whose status is NOT terminal.
 * Returns [] when the task has no blockers or all blockers are in terminal statuses.
 */
export async function getUnsatisfiedBlockers(
  db: Db,
  tenantId: string,
  taskId: string,
): Promise<TaskObject[]> {
  // Find edges where blocked_task_id = taskId
  const edges = await db
    .select({ blockingTaskId: taskDependencies.blockingTaskId })
    .from(taskDependencies)
    .where(
      and(
        eq(taskDependencies.tenantId, tenantId),
        eq(taskDependencies.blockedTaskId, taskId),
      ),
    )

  if (edges.length === 0) return []

  const blockerIds = edges.map((e) => e.blockingTaskId)

  // Load blocker tasks with their statuses
  const blockerRows = await db
    .select({
      task: tasks,
      isTerminal: taskStatuses.isTerminal,
    })
    .from(tasks)
    .innerJoin(taskStatuses, eq(tasks.statusId, taskStatuses.id))
    .where(
      and(
        eq(tasks.tenantId, tenantId),
        inArray(tasks.id, blockerIds),
      ),
    )

  // Keep only non-terminal blockers
  const unsatisfied = blockerRows.filter((r) => !r.isTerminal)
  if (unsatisfied.length === 0) return []

  const unsatisfiedIds = unsatisfied.map((r) => r.task.id)
  const labelsMap = await getLabelsForTasks(db, unsatisfiedIds)

  return unsatisfied.map((r) => serializeTaskRow(r.task, labelsMap[r.task.id] ?? []))
}
