/**
 * Comments REST client — thin, typed wrapper around the
 * `/documents/:id/comments` + `/comments/:id` endpoints shipped in slice 4
 * of Phase 3 (see `packages/cms-core/src/realtime/comments.ts`).
 *
 * The client lives in `cms-admin` so the side panel, the bubble-menu
 * "comment on selection" action, and any future MCP/AI hooks all share one
 * request path, error model, and DTO shape.
 *
 * Mirrors the server contract:
 *   POST   /documents/:documentId/comments     -> { body, anchor?, parentId? }
 *   GET    /documents/:documentId/comments?…   -> { data: CommentDTO[] }
 *   PATCH  /comments/:id                       -> { body }
 *   POST   /comments/:id/resolve               -> 200 with updated DTO
 *   POST   /comments/:id/reopen                -> 200 with updated DTO
 *   DELETE /comments/:id                       -> 200 with soft-deleted DTO
 */

import { cmsApi } from './api.js'

/**
 * Yjs relative-position anchor returned by the server. The encoding is
 * intentionally opaque on the wire (Base64 of `Y.encodeRelativePosition`);
 * `lib/comment-anchor.ts` handles the round-trip when slice 5 paints a
 * highlight in the editor.
 */
export interface CommentAnchor {
  from: string
  to: string
}

/**
 * Public DTO returned by every comments endpoint. Mirrors `CommentDTO` in
 * `@actuate-media/cms-core/realtime`; we duplicate the shape here so
 * `cms-admin` doesn't take a hard dependency on the server-side service
 * layer (which carries Prisma types we don't want pulled into the bundle).
 */
export interface Comment {
  id: string
  documentId: string
  parentId: string | null
  userId: string | null
  body: string
  anchor: CommentAnchor | null
  resolvedAt: string | null
  resolvedById: string | null
  deletedAt: string | null
  createdAt: string
  updatedAt: string
  isResolved: boolean
}

export type CommentOutcome<T> =
  | { ok: true; result: T }
  | { ok: false; error: string; status: number }

export interface CreateCommentInput {
  documentId: string
  body: string
  anchor?: CommentAnchor | null
  parentId?: string | null
}

export interface ListCommentsOptions {
  /** Default: false. Resolved threads are filtered out unless requested. */
  includeResolved?: boolean
  /** Default: false. Admin-only; the server rejects non-admin callers. */
  includeDeleted?: boolean
  /** Default: server-side cap (100). Hard upper bound is 200. */
  pageSize?: number
}

function encodeOptions(opts: ListCommentsOptions = {}): string {
  const params = new URLSearchParams()
  if (opts.includeResolved) params.set('includeResolved', 'true')
  if (opts.includeDeleted) params.set('includeDeleted', 'true')
  if (opts.pageSize !== undefined) params.set('pageSize', String(opts.pageSize))
  const qs = params.toString()
  return qs ? `?${qs}` : ''
}

function failure(error: string, status: number): { ok: false; error: string; status: number } {
  return { ok: false, error, status }
}

function envelope<T>(res: { data?: unknown; error?: string; status: number }): CommentOutcome<T> {
  if (res.error || res.data === undefined) {
    return failure(res.error ?? `Request failed (${res.status})`, res.status)
  }
  return { ok: true, result: res.data as T }
}

/**
 * Create a top-level comment or a reply. Pass `parentId` to thread the
 * comment under an existing one — the server enforces single-level
 * threading (replies cannot themselves be parents).
 */
export async function createComment(input: CreateCommentInput): Promise<CommentOutcome<Comment>> {
  if (typeof input.documentId !== 'string' || input.documentId.length === 0) {
    return failure('documentId is required', 0)
  }
  if (typeof input.body !== 'string' || input.body.trim().length === 0) {
    return failure('Comment cannot be empty', 0)
  }

  const body: Record<string, unknown> = { body: input.body.trim() }
  if (input.anchor) body.anchor = input.anchor
  if (input.parentId) body.parentId = input.parentId

  const res = await cmsApi<Comment>(`/documents/${encodeURIComponent(input.documentId)}/comments`, {
    method: 'POST',
    body: JSON.stringify(body),
  })
  return envelope<Comment>(res)
}

/**
 * List comments for a document. Active comments (not resolved, not deleted)
 * are returned by default; pass `includeResolved` to keep resolved threads
 * in the response. The server rejects `includeDeleted` for non-admins.
 */
export async function listComments(
  documentId: string,
  opts: ListCommentsOptions = {},
): Promise<CommentOutcome<Comment[]>> {
  if (typeof documentId !== 'string' || documentId.length === 0) {
    return failure('documentId is required', 0)
  }
  const path = `/documents/${encodeURIComponent(documentId)}/comments${encodeOptions(opts)}`
  const res = await cmsApi<Comment[]>(path, { method: 'GET' })
  return envelope<Comment[]>(res)
}

/** Edit a comment body. Only the author or an admin is permitted. */
export async function updateComment(id: string, body: string): Promise<CommentOutcome<Comment>> {
  if (!id) return failure('comment id is required', 0)
  if (typeof body !== 'string' || body.trim().length === 0) {
    return failure('Comment cannot be empty', 0)
  }
  const res = await cmsApi<Comment>(`/comments/${encodeURIComponent(id)}`, {
    method: 'PATCH',
    body: JSON.stringify({ body: body.trim() }),
  })
  return envelope<Comment>(res)
}

/** Resolve a thread. Write-role or the author may resolve. */
export async function resolveComment(id: string): Promise<CommentOutcome<Comment>> {
  if (!id) return failure('comment id is required', 0)
  const res = await cmsApi<Comment>(`/comments/${encodeURIComponent(id)}/resolve`, {
    method: 'POST',
    body: '{}',
  })
  return envelope<Comment>(res)
}

/** Re-open a resolved thread. Same permission as resolve. */
export async function reopenComment(id: string): Promise<CommentOutcome<Comment>> {
  if (!id) return failure('comment id is required', 0)
  const res = await cmsApi<Comment>(`/comments/${encodeURIComponent(id)}/reopen`, {
    method: 'POST',
    body: '{}',
  })
  return envelope<Comment>(res)
}

/**
 * Soft-delete a comment. The row is preserved so its replies don't lose
 * their thread root — the server simply stops returning it from the list
 * endpoint unless an admin asks for `includeDeleted`.
 */
export async function deleteComment(id: string): Promise<CommentOutcome<Comment>> {
  if (!id) return failure('comment id is required', 0)
  const res = await cmsApi<Comment>(`/comments/${encodeURIComponent(id)}`, {
    method: 'DELETE',
  })
  return envelope<Comment>(res)
}

/**
 * Sort comments into thread groups suitable for rendering. Roots are
 * returned in chronological order; replies are ordered the same way and
 * attached to their root. Replies whose root is missing (e.g. the root was
 * filtered out by `includeResolved=false`) are dropped — the UI can fetch
 * them explicitly if it needs to render orphaned replies.
 */
export interface CommentThread {
  root: Comment
  replies: Comment[]
}

export function groupCommentsIntoThreads(comments: readonly Comment[]): CommentThread[] {
  const roots = new Map<string, CommentThread>()
  const sorted = [...comments].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt))

  for (const c of sorted) {
    if (c.parentId === null) {
      roots.set(c.id, { root: c, replies: [] })
    }
  }
  for (const c of sorted) {
    if (c.parentId !== null) {
      const root = roots.get(c.parentId)
      if (root) root.replies.push(c)
    }
  }
  return Array.from(roots.values())
}
