/**
 * Document tree tools — operate on the root Y.Doc's "doc-tree" Y.Map.
 */
import * as Y from 'yjs'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { AbracadabraMCPServer } from '../server.ts'
import type { TreeEntry, PageMeta } from '../converters/types.ts'
import { PAGE_TYPES, TYPE_ALIASES, resolvePageType } from '../converters/page-types.ts'
import type { SchemaBundleValidator } from '../schema/validator.ts'
import { makeEntryMap, patchEntry } from '@abraca/dabra'

/**
 * Normalize a document ID so the hub/root doc ID is treated as the tree root (null).
 * This lets callers pass the hub doc_id from list_spaces as parentId/rootId
 * and get the expected root-level results instead of an empty set.
 */
function normalizeRootId(id: string | null | undefined, server: AbracadabraMCPServer): string | null {
  if (id == null) return null
  return id === server.rootDocId ? null : id
}

/** Safely read a tree map value, converting Y.Map to plain object if needed. */
function toPlain(val: any): any {
  return val instanceof Y.Map ? val.toJSON() : val
}

function readEntries(treeMap: Y.Map<any>): TreeEntry[] {
  const entries: TreeEntry[] = []
  treeMap.forEach((raw: any, id: string) => {
    const value = toPlain(raw)
    if (typeof value !== 'object' || value === null) return // skip non-entry keys (e.g. ":updatedAt" timestamps)
    entries.push({
      id,
      label: value.label || 'Untitled',
      parentId: value.parentId ?? null,
      order: value.order ?? 0,
      type: value.type,
      meta: value.meta,
      createdAt: value.createdAt,
      updatedAt: value.updatedAt,
    })
  })
  return entries
}

function childrenOf(entries: TreeEntry[], parentId: string | null): TreeEntry[] {
  return entries
    .filter(e => e.parentId === parentId)
    .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
}

function descendantsOf(entries: TreeEntry[], id: string | null): TreeEntry[] {
  const result: TreeEntry[] = []
  const visited = new Set<string>()
  function collect(pid: string) {
    if (visited.has(pid)) return
    visited.add(pid)
    for (const child of childrenOf(entries, pid)) {
      result.push(child)
      collect(child.id)
    }
  }
  collect(id)
  return result
}

function buildTree(entries: TreeEntry[], rootId: string | null, maxDepth: number, currentDepth = 0, visited = new Set<string>()): any[] {
  if (maxDepth >= 0 && currentDepth >= maxDepth) return []
  const children = childrenOf(entries, rootId)
  return children.filter(e => !visited.has(e.id)).map(entry => {
    const next = new Set(visited)
    next.add(entry.id)
    return {
      id: entry.id,
      label: entry.label,
      type: entry.type,
      meta: entry.meta,
      order: entry.order,
      children: buildTree(entries, entry.id, maxDepth, currentDepth + 1, next),
    }
  })
}

export function registerTreeTools(
  mcp: McpServer,
  server: AbracadabraMCPServer,
  validator: SchemaBundleValidator | null = null,
) {
  mcp.tool(
    'list_documents',
    'List direct children of a document (defaults to root). Returns id, label, type, meta, order. NOTE: Only returns ONE level. Use find_document to search by name across the full tree, or get_document_tree to see the complete hierarchy.',
    { parentId: z.string().optional().describe('Parent document ID. Omit for root-level documents.') },
    async ({ parentId }) => {
      server.setAutoStatus('reading')
      server.setActiveToolCall({ name: 'list_documents' })
      const treeMap = server.getTreeMap()
      if (!treeMap) {
        return { content: [{ type: 'text', text: 'Not connected' }] }
      }

      const targetId = normalizeRootId(parentId, server)
      const entries = readEntries(treeMap)
      const children = childrenOf(entries, targetId)

      return {
        content: [{
          type: 'text',
          text: JSON.stringify(children, null, 2),
        }],
      }
    }
  )

  mcp.tool(
    'get_document_tree',
    'Get the full document tree as nested JSON. Call this FIRST when exploring a space or looking for documents — it shows the complete hierarchy including all nested documents. Use rootId to scope to a subtree.',
    {
      rootId: z.string().optional().describe('Root document ID to start from. Omit for the entire tree.'),
      depth: z.number().optional().describe('Maximum depth to traverse. Default 3. Use -1 for unlimited.'),
    },
    async ({ rootId, depth }) => {
      server.setAutoStatus('reading')
      server.setActiveToolCall({ name: 'get_document_tree' })
      const treeMap = server.getTreeMap()
      if (!treeMap) {
        return { content: [{ type: 'text', text: 'Not connected' }] }
      }

      const targetId = normalizeRootId(rootId, server)
      const maxDepth = depth ?? 3
      const entries = readEntries(treeMap)
      const tree = buildTree(entries, targetId, maxDepth)

      return {
        content: [{
          type: 'text',
          text: JSON.stringify(tree, null, 2),
        }],
      }
    }
  )

  mcp.tool(
    'find_document',
    'Search for documents by name across the entire tree. Returns matching documents with their full ancestor path. Use this when you\'re looking for a specific document and don\'t know where it is in the hierarchy.',
    {
      query: z.string().describe('Search query — matched case-insensitively against document labels (substring match).'),
      rootId: z.string().optional().describe('Restrict search to descendants of this document. Omit to search the entire tree.'),
    },
    async ({ query, rootId }) => {
      server.setAutoStatus('searching')
      server.setActiveToolCall({ name: 'find_document', target: query })
      const treeMap = server.getTreeMap()
      if (!treeMap) {
        return { content: [{ type: 'text', text: 'Not connected' }] }
      }

      const entries = readEntries(treeMap)
      const lowerQuery = query.toLowerCase()

      // If rootId specified, restrict to descendants only
      const normalizedRoot = normalizeRootId(rootId, server)
      const searchSet = normalizedRoot ? descendantsOf(entries, normalizedRoot) : entries

      // Find matches
      const matches = searchSet.filter(e =>
        e.label.toLowerCase().includes(lowerQuery)
      )

      // Build ancestor path for each match
      const byId = new Map(entries.map(e => [e.id, e]))
      const results = matches.map(entry => {
        const path: string[] = []
        let current = entry.parentId
        const visited = new Set<string>()
        while (current && !visited.has(current)) {
          visited.add(current)
          const parent = byId.get(current)
          if (!parent) break
          path.unshift(parent.label)
          current = parent.parentId
        }
        return {
          id: entry.id,
          label: entry.label,
          type: entry.type,
          meta: entry.meta,
          path,
        }
      })

      if (results.length === 0) {
        return {
          content: [{ type: 'text', text: `No documents found matching "${query}". Try get_document_tree to see the full hierarchy.` }],
        }
      }

      return {
        content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
      }
    }
  )

  mcp.tool(
    'query_documents',
    'Find documents by structural predicate across every space the user can read (POST /docs/query, v1). v1 filters on type (kanban, doc, calendar, etc.), parent doc, and label substring. Meta-field predicates (priority >= 3, dateStart in range) arrive in v2. Use this instead of find_document when you want to scope by page-type rather than label, or list every doc of a given kind across the server.',
    {
      type: z.string().optional().describe('Match documents.kind exactly. Examples: "kanban", "doc", "calendar", "table", "gallery". Omit to range across kinds.'),
      parentId: z.string().optional().describe('Narrow to direct children of this doc.'),
      labelContains: z.string().optional().describe('Case-insensitive substring match on the document label.'),
      limit: z.number().optional().describe('Maximum results returned. Default 50; hard cap 500 server-side.'),
    },
    async ({ type, parentId, labelContains, limit }) => {
      server.setAutoStatus('searching')
      server.setActiveToolCall({ name: 'query_documents', target: type ?? labelContains ?? parentId ?? 'query' })

      if (type == null && parentId == null && labelContains == null) {
        return {
          content: [{
            type: 'text',
            text: 'At least one of `type`, `parentId`, or `labelContains` is required — the server refuses to scan the full document set. (Use list_documents for root-level listing, or find_document for full-tree label search.)',
          }],
        }
      }

      try {
        const docs = await server.client.queryDocs({ type, parentId, labelContains, limit })
        if (docs.length === 0) {
          return {
            content: [{
              type: 'text',
              text: `No documents matched (type=${type ?? '*'}, parentId=${parentId ?? '*'}, labelContains=${labelContains ?? '*'}). Try widening the predicate.`,
            }],
          }
        }
        return {
          content: [{ type: 'text', text: JSON.stringify(docs, null, 2) }],
        }
      } catch (err) {
        return {
          content: [{
            type: 'text',
            text: `query_documents failed: ${err instanceof Error ? err.message : String(err)}`,
          }],
        }
      }
    }
  )

  mcp.tool(
    'create_document',
    'Create a new document in the tree. Returns the new document ID.',
    {
      parentId: z.string().optional().describe('Parent document ID. Omit for top-level pages. Use a document ID for nested/child pages.'),
      label: z.string().describe('Display name / title for the document.'),
      type: z.string().optional().describe('Page type — sets how this document renders. Core types (always available): "doc" (rich text), "kanban" (columns → cards), "table" (columns → rows, positional), "calendar" (events with datetimeStart/End), "timeline" (epics → tasks with dateStart/End + taskProgress), "checklist" (tasks with checked/priority, unlimited nesting), "outline" (nested items, unlimited depth), "gallery" (visual grid with covers/ratings), "map" (markers/lines with geoLat/geoLng), "graph" (force-directed knowledge graph), "dashboard" (positioned widgets with deskX/deskY/deskMode), "slides" (slides → sub-slides with transitions), "chart" (bar/stacked bar/line/donut/treemap from data points or aggregation), "sheets" (spreadsheet with formulas and formatting), "overview" (space home — activity and stats), "call" (video call room, no children). Plugin types (require plugin enabled on the server): "spatial" (3D scene with spShape/spX/spY/spZ + universal color, plugin: spatial), "media" (audio/video player with playlists, plugin: media), "coder" (collaborative multi-file coding env with fileType/entry, plugin: coder). Alias: "desktop" → "dashboard". Omit to inherit parent view. Only set on the parent page, NEVER on child items.'),
      meta: z.record(z.string(), z.unknown()).optional().describe('Initial metadata (PageMeta fields: color as hex string, icon as Lucide kebab-case name like "star"/"code-2"/"users" — never emoji, dateStart, dateEnd, priority 0-4, tags array, etc). Omit icon entirely to use page type default.'),
    },
    async ({ parentId, label, type, meta }) => {
      server.setAutoStatus('creating')
      server.setActiveToolCall({ name: 'create_document', target: label })

      const treeMap = server.getTreeMap()
      const rootDoc = server.rootDocument
      if (!treeMap || !rootDoc) {
        return { content: [{ type: 'text', text: 'Not connected' }] }
      }

      if (validator && type && meta) {
        const result = validator.validateMeta(type, meta)
        if (!result.ok) {
          const detail = result.errors.map((e) => `${e.path} ${e.message}`).join('; ')
          return {
            content: [{
              type: 'text',
              text: `Metadata validation failed for type "${type}": ${detail}`,
            }],
            isError: true,
          }
        }
      }

      const id = crypto.randomUUID()
      const normalizedParent = normalizeRootId(parentId, server)
      const now = Date.now()
      rootDoc.transact(() => {
        treeMap.set(
          id,
          makeEntryMap({
            label,
            parentId: normalizedParent,
            order: now,
            type,
            meta: meta as PageMeta,
            createdAt: now,
            updatedAt: now,
          }),
        )
      })

      server.setFocusedDoc(id)

      return {
        content: [{
          type: 'text',
          text: JSON.stringify({ id, label, parentId, type }, null, 2),
        }],
      }
    }
  )

  mcp.tool(
    'rename_document',
    'Rename a document (updates its display label everywhere).',
    {
      id: z.string().describe('Document ID to rename.'),
      label: z.string().describe('New display name.'),
    },
    async ({ id, label }) => {
      server.setAutoStatus('writing')
      server.setActiveToolCall({ name: 'rename_document', target: id })
      const treeMap = server.getTreeMap()
      if (!treeMap) {
        return { content: [{ type: 'text', text: 'Not connected' }] }
      }

      const raw = treeMap.get(id)
      if (!raw) {
        return { content: [{ type: 'text', text: `Document ${id} not found` }] }
      }

      patchEntry(treeMap, id, { label, updatedAt: Date.now() })
      return { content: [{ type: 'text', text: `Renamed to "${label}"` }] }
    }
  )

  mcp.tool(
    'move_document',
    'Move a document to a new parent and/or reorder it.',
    {
      id: z.string().describe('Document ID to move.'),
      newParentId: z.string().optional().describe('New parent document ID. Omit to move to top level.'),
      order: z.number().optional().describe('New sort order. Defaults to Date.now() (append to end).'),
    },
    async ({ id, newParentId, order }) => {
      server.setAutoStatus('writing')
      server.setActiveToolCall({ name: 'move_document', target: id })
      const treeMap = server.getTreeMap()
      if (!treeMap) {
        return { content: [{ type: 'text', text: 'Not connected' }] }
      }

      const raw = treeMap.get(id)
      if (!raw) {
        return { content: [{ type: 'text', text: `Document ${id} not found` }] }
      }

      patchEntry(treeMap, id, {
        parentId: normalizeRootId(newParentId, server),
        order: order ?? Date.now(),
        updatedAt: Date.now(),
      })
      return { content: [{ type: 'text', text: `Moved ${id} to parent ${newParentId}` }] }
    }
  )

  mcp.tool(
    'delete_document',
    'Soft-delete a document and its descendants (moves to trash).',
    {
      id: z.string().describe('Document ID to delete.'),
    },
    async ({ id }) => {
      server.setAutoStatus('writing')
      server.setActiveToolCall({ name: 'delete_document', target: id })
      const treeMap = server.getTreeMap()
      const trashMap = server.getTrashMap()
      const rootDoc = server.rootDocument
      if (!treeMap || !trashMap || !rootDoc) {
        return { content: [{ type: 'text', text: 'Not connected' }] }
      }

      const entries = readEntries(treeMap)
      const toDelete = [id, ...descendantsOf(entries, id).map(e => e.id)]

      const now = Date.now()
      rootDoc.transact(() => {
        for (const nid of toDelete) {
          const raw = treeMap.get(nid)
          if (!raw) continue
          const entry = toPlain(raw)
          trashMap.set(nid, {
            label: entry.label || 'Untitled',
            parentId: entry.parentId ?? null,
            order: entry.order ?? 0,
            type: entry.type,
            meta: entry.meta,
            deletedAt: now,
          })
          treeMap.delete(nid)
        }
      })

      return { content: [{ type: 'text', text: `Deleted ${toDelete.length} document(s)` }] }
    }
  )

  mcp.tool(
    'change_document_type',
    'Change the page type view of a document (data is preserved).',
    {
      id: z.string().describe('Document ID.'),
      type: z.string().describe('New page type. Core: "doc", "kanban", "table", "calendar", "timeline", "checklist", "outline", "gallery", "map", "graph", "dashboard", "slides", "chart", "sheets", "overview", "call". Plugin (require plugin enabled): "spatial", "media", "coder". Switching preserves the tree — children, labels, and meta are all retained; only the view changes.'),
    },
    async ({ id, type }) => {
      server.setAutoStatus('writing')
      server.setActiveToolCall({ name: 'change_document_type', target: id })
      const treeMap = server.getTreeMap()
      if (!treeMap) {
        return { content: [{ type: 'text', text: 'Not connected' }] }
      }

      const raw = treeMap.get(id)
      if (!raw) {
        return { content: [{ type: 'text', text: `Document ${id} not found` }] }
      }

      patchEntry(treeMap, id, { type, updatedAt: Date.now() })
      return { content: [{ type: 'text', text: `Changed type to "${type}"` }] }
    }
  )

  mcp.tool(
    'list_spaces',
    'List all Spaces available on the server. Spaces are top-level documents (direct children of the server root) tagged with kind="space". Returns id, label, public_access, and active flag. Use switch_space to navigate.',
    {},
    async () => {
      const spaces = server.spaces
      if (!spaces.length) {
        return { content: [{ type: 'text', text: 'No spaces available — create one to get started.' }] }
      }
      const active = server.rootDocId
      const annotated = spaces.map(s => ({ ...s, active: s.id === active }))
      return { content: [{ type: 'text', text: JSON.stringify(annotated, null, 2) }] }
    }
  )

  mcp.tool(
    'switch_space',
    'Switch the active Space. All subsequent tree operations will target the new Space. Use list_spaces to discover available ids.',
    { docId: z.string().describe('The id of the Space to switch to (from list_spaces).') },
    async ({ docId }) => {
      await server.switchSpace(docId)
      const space = server.spaces.find(s => s.id === docId)
      const name = space?.label ?? docId
      return { content: [{ type: 'text', text: `Switched to space "${name}" (${docId})` }] }
    }
  )

  mcp.tool(
    'duplicate_document',
    'Shallow-clone a document with " (copy)" suffix. Returns new document ID.',
    {
      id: z.string().describe('Document ID to duplicate.'),
    },
    async ({ id }) => {
      const treeMap = server.getTreeMap()
      if (!treeMap) return { content: [{ type: 'text', text: 'Not connected' }] }

      const raw = treeMap.get(id)
      if (!raw) return { content: [{ type: 'text', text: `Document ${id} not found` }] }

      const entry = toPlain(raw)
      const newId = crypto.randomUUID()
      treeMap.set(
        newId,
        makeEntryMap({
          ...entry,
          label: (entry.label || 'Untitled') + ' (copy)',
          order: Date.now(),
        }),
      )

      server.setFocusedDoc(newId)

      return {
        content: [{
          type: 'text',
          text: JSON.stringify({ id: newId, label: entry.label + ' (copy)' }, null, 2),
        }],
      }
    }
  )

  mcp.tool(
    'list_page_types',
    'Enumerate all known Abracadabra page types with their metadata schemas. Returns an array of { key, label, icon, description, core, plugin, supportsChildren, childLabel, grandchildLabel, defaultDepth, metaSchema, defaultMetaFields }. `metaSchema` describes fields that apply to DESCENDANTS (children, grandchildren, ...) of a page of this type. `defaultMetaFields` are view-config fields on the page doc itself. Plugin types (core: false) require the named plugin to be enabled on the server. Use this to discover what meta keys a given renderer supports instead of guessing.',
    {
      key: z.string().optional().describe('Filter to a single type by key (e.g. "kanban", "calendar"). Aliases are resolved (e.g. "desktop" → "dashboard"). Omit to list all types.'),
    },
    async ({ key }) => {
      if (key) {
        const resolved = resolvePageType(key)
        if (!resolved) {
          return { content: [{ type: 'text', text: `Unknown page type "${key}". Call list_page_types with no args to see all types.` }] }
        }
        return { content: [{ type: 'text', text: JSON.stringify(resolved, null, 2) }] }
      }
      const all = Object.values(PAGE_TYPES)
      return {
        content: [{
          type: 'text',
          text: JSON.stringify({ types: all, aliases: TYPE_ALIASES }, null, 2),
        }],
      }
    }
  )
}
