<script setup lang="ts">
/**
 * Props-driven file tree composition. Renders a recursive list of files
 * and folders sourced by a `onLoadTree` callback. The composition makes
 * no HTTP calls itself — the consumer provides a Promise-returning
 * loader for each node.
 *
 *   <FileTree
 *     :on-load-tree="(path) => fetch(`/api/tree?path=${path}`).then(r => r.json())"
 *     v-model:selected-path="selected"
 *     v-model:expanded-paths="expanded"
 *   />
 */
import { computed, h, onMounted, ref, watch, type PropType } from "vue"
import {
  ChevronDown,
  ChevronRight,
  File as FileIcon,
  Folder,
  FolderOpen,
} from "@lucide/vue"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"

export interface TreeNode {
  /** Full path from the root, e.g. `src/components/Button.vue`. */
  path: string
  /** Display name (typically the trailing path segment). */
  name: string
  kind: "file" | "directory"
  /** Pre-loaded children. When omitted for a directory, the loader is
   *  invoked the first time the node is expanded. */
  children?: TreeNode[]
}

export interface FileTreeProps {
  /** Root path. Passed to `onLoadTree` on mount. */
  path?: string
  /** REQUIRED loader for children at a given path. */
  onLoadTree: (path: string) => Promise<TreeNode[]>
  /** Current selected file path (v-modelable as `selectedPath`). */
  selectedPath?: string
  /** Called when the user clicks a file. */
  onSelectPath?: (path: string) => void
  /** Controlled expanded directory paths (v-modelable as `expandedPaths`). */
  expandedPaths?: string[]
  /** Fired when the set of expanded paths changes. */
  onExpandedChange?: (paths: string[]) => void
  label?: string
  name?: string | null
  classList?: string[] | string
}

const props = withDefaults(defineProps<FileTreeProps>(), {
  path: "",
  label: "File Tree",
})

const emit = defineEmits<{
  "update:selectedPath": [path: string]
  "update:expandedPaths": [paths: string[]]
}>()

interface InternalNode {
  path: string
  name: string
  kind: "file" | "directory"
  loading: boolean
  loaded: boolean
  error: string | null
  childNodes: InternalNode[]
}

function toInternal(node: TreeNode): InternalNode {
  return {
    path: node.path,
    name: node.name,
    kind: node.kind,
    loading: false,
    loaded: Array.isArray(node.children),
    error: null,
    childNodes: Array.isArray(node.children)
      ? node.children.map(toInternal)
      : [],
  }
}

const rootNodes = ref<InternalNode[]>([])
const rootLoading = ref<boolean>(false)
const rootError = ref<string | null>(null)

const localExpanded = ref<Set<string>>(
  new Set<string>(props.expandedPaths ?? []),
)

const expandedPathsList = computed<string[]>(() =>
  Array.from(localExpanded.value),
)

watch(
  () => props.expandedPaths,
  (next) => {
    if (next === undefined) {
      return
    }
    localExpanded.value = new Set(next)
  },
)

function notifyExpanded(): void {
  const list = expandedPathsList.value
  emit("update:expandedPaths", list)
  if (props.onExpandedChange) {
    props.onExpandedChange(list)
  }
}

async function loadRoot(): Promise<void> {
  rootLoading.value = true
  rootError.value = null
  try {
    const result = await props.onLoadTree(props.path ?? "")
    rootNodes.value = Array.isArray(result) ? result.map(toInternal) : []
  } catch (err) {
    rootError.value = err instanceof Error ? err.message : String(err)
    rootNodes.value = []
  } finally {
    rootLoading.value = false
  }
}

async function loadChildren(node: InternalNode): Promise<void> {
  if (node.kind !== "directory") {
    return
  }
  if (node.loading || node.loaded) {
    return
  }
  node.loading = true
  node.error = null
  try {
    const result = await props.onLoadTree(node.path)
    node.childNodes = Array.isArray(result) ? result.map(toInternal) : []
    node.loaded = true
  } catch (err) {
    node.error = err instanceof Error ? err.message : String(err)
    node.childNodes = []
  } finally {
    node.loading = false
  }
}

function isExpanded(path: string): boolean {
  return localExpanded.value.has(path)
}

async function toggleDirectory(node: InternalNode): Promise<void> {
  const next = new Set(localExpanded.value)
  if (next.has(node.path)) {
    next.delete(node.path)
  } else {
    next.add(node.path)
    if (!node.loaded) {
      await loadChildren(node)
    }
  }
  localExpanded.value = next
  notifyExpanded()
}

function selectFile(node: InternalNode): void {
  if (node.kind !== "file") {
    return
  }
  emit("update:selectedPath", node.path)
  if (props.onSelectPath) {
    props.onSelectPath(node.path)
  }
}

onMounted(() => {
  void loadRoot()
})

watch(
  () => props.path,
  () => {
    void loadRoot()
  },
)

const FileTreeNode = {
  name: "FileTreeNode",
  props: {
    node: {
      type: Object as PropType<InternalNode>,
      required: true as const,
    },
    depth: { type: Number, required: true as const },
  },
  setup(nodeProps: { node: InternalNode; depth: number }) {
    return () => {
      const node = nodeProps.node
      const expanded = isExpanded(node.path)
      const indent = { paddingLeft: `${nodeProps.depth * 16 + 8}px` }
      const isFile = node.kind === "file"
      const selected = isFile && props.selectedPath === node.path
      const rowClass = [
        "flex items-center gap-1.5 rounded-sm py-1 pr-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground",
        selected ? "bg-accent text-accent-foreground font-medium" : "",
      ].join(" ")

      const row = h(
        "div",
        {
          class: rowClass,
          style: indent,
          role: "treeitem",
          "data-testid": `file-tree-row-${node.path}`,
          "data-kind": node.kind,
          "aria-expanded": isFile ? undefined : String(expanded),
          "aria-selected": isFile ? String(selected) : undefined,
          onClick: (e: Event) => {
            e.stopPropagation()
            if (isFile) {
              selectFile(node)
            } else {
              void toggleDirectory(node)
            }
          },
        },
        [
          isFile
            ? h("span", { class: "inline-block w-4 shrink-0" })
            : h(expanded ? ChevronDown : ChevronRight, {
                class: "size-4 shrink-0 text-muted-foreground",
                "data-testid": expanded
                  ? `file-tree-chevron-down-${node.path}`
                  : `file-tree-chevron-right-${node.path}`,
              }),
          isFile
            ? h(FileIcon, {
                class: "size-4 shrink-0 text-muted-foreground",
              })
            : h(expanded ? FolderOpen : Folder, {
                class: "size-4 shrink-0 text-muted-foreground",
              }),
          h("span", { class: "truncate" }, node.name),
        ],
      )

      const children: ReturnType<typeof h>[] = []
      if (!isFile && expanded) {
        const childIndent = {
          paddingLeft: `${(nodeProps.depth + 1) * 16 + 8}px`,
        }
        if (node.loading) {
          children.push(
            h(
              "li",
              {
                class: "py-1",
                style: childIndent,
                "data-testid": `file-tree-child-loading-${node.path}`,
              },
              [h(Skeleton, { class: "h-4 w-1/2" })],
            ),
          )
        } else if (node.error) {
          children.push(
            h(
              "li",
              {
                class: "py-1 text-sm text-destructive",
                style: childIndent,
                role: "alert",
                "data-testid": `file-tree-child-error-${node.path}`,
              },
              node.error,
            ),
          )
        } else if (node.childNodes.length === 0) {
          children.push(
            h(
              "li",
              {
                class: "py-1 text-sm italic text-muted-foreground",
                style: childIndent,
                "data-testid": `file-tree-child-empty-${node.path}`,
              },
              "(empty)",
            ),
          )
        } else {
          for (const child of node.childNodes) {
            children.push(
              h(FileTreeNode, {
                key: child.path,
                node: child,
                depth: nodeProps.depth + 1,
              }),
            )
          }
        }
      }

      return h(
        "li",
        { class: "flex list-none flex-col" },
        [row, ...children],
      )
    }
  },
}

defineExpose({ reload: loadRoot })
</script>

<template>
  <Card
    :class="cn(props.classList)"
    :data-element-name="props.name ?? undefined"
    data-testid="file-tree"
  >
    <CardHeader>
      <CardTitle class="flex items-center gap-2 text-base">
        <Folder class="size-5 text-muted-foreground" />
        <span>{{ props.label }}</span>
      </CardTitle>
    </CardHeader>
    <CardContent class="px-2 pb-4">
      <div
        v-if="rootLoading"
        data-testid="file-tree-loading"
        class="flex flex-col gap-1.5 px-2"
      >
        <Skeleton class="h-5 w-3/4" />
        <Skeleton class="h-5 w-2/3" />
        <Skeleton class="h-5 w-1/2" />
      </div>
      <p
        v-else-if="rootError"
        class="px-2 text-sm text-destructive"
        data-testid="file-tree-error"
        role="alert"
      >
        {{ rootError }}
      </p>
      <p
        v-else-if="rootNodes.length === 0"
        class="px-2 text-sm text-muted-foreground"
        data-testid="file-tree-empty"
      >
        No files.
      </p>
      <ul
        v-else
        class="flex flex-col"
        role="tree"
        data-testid="file-tree-list"
      >
        <FileTreeNode
          v-for="node in rootNodes"
          :key="node.path"
          :node="node"
          :depth="0"
        />
      </ul>
    </CardContent>
  </Card>
</template>
