import * as fs from 'node:fs'
import * as path from 'node:path'

import { application } from '@application'
import { agentGlobalSkillService } from '@data/services/AgentGlobalSkillService'
import { loggerService } from '@logger'
import { directoryExists } from '@main/utils/file'
import { deleteDirectoryRecursive } from '@main/utils/fileOperations'
import { findAllSkillDirectories, findSkillMdPath, parseSkillMetadata } from '@main/utils/markdownParser'
import { executeCommand, findExecutableInEnv } from '@main/utils/process'
import type { InstalledSkill, ListSkillsQuery } from '@shared/data/api/schemas/skills'
import type {
  SkillFileNode,
  SkillInstallFromDirectoryOptions,
  SkillInstallFromZipOptions,
  SkillInstallOptions,
  SkillToggleOptions
} from '@shared/types/skill'
import { net } from 'electron'
import StreamZip from 'node-stream-zip'

import { SkillInstaller } from './SkillInstaller'

const logger = loggerService.withContext('SkillService')

// API base URLs for the 3 search sources
const CLAUDE_PLUGINS_API = 'https://api.claude-plugins.dev'

// ZIP extraction limits
const MAX_EXTRACTED_SIZE = 100 * 1024 * 1024 // 100MB
const MAX_FILES_COUNT = 1000
const MAX_FOLDER_NAME_LENGTH = 80

/**
 * Skill management service.
 *
 * Skills are stored in `{dataPath}/Skills/{folderName}/` (inert global library).
 * When enabled for a specific agent, a symlink is created at
 * `{agentWorkspace}/.claude/skills/{folderName}/` pointing to the library,
 * making the skill discoverable by Claude Code running against that workspace.
 *
 * Skill library metadata lives in `agent_global_skill`. Per-agent enablement
 * state lives in the `agent_skill` join table.
 */
export class SkillService {
  private readonly installer: SkillInstaller

  constructor() {
    this.installer = new SkillInstaller()
  }

  // ===========================================================================
  // Public API
  // ===========================================================================

  /**
   * List installed skills.
   *
   * When `agentId` is provided, each skill's `isEnabled` field reflects the
   * per-agent enablement state from `agent_skill`. Without `agentId`,
   * the field is forced to `false`.
   */
  async getById(id: string): Promise<InstalledSkill | null> {
    return agentGlobalSkillService.getById(id)
  }

  async list(query: ListSkillsQuery = {}): Promise<InstalledSkill[]> {
    return agentGlobalSkillService.list(query)
  }

  /**
   * Enable or disable a skill for a specific agent.
   *
   * Updates the `agent_skill` join row and creates / removes the
   * corresponding symlink under `{agentWorkspace}/.claude/skills/`.
   */
  async toggle(options: SkillToggleOptions): Promise<InstalledSkill | null> {
    const skill = await agentGlobalSkillService.getById(options.skillId)
    if (!skill) return null

    const workspaces = await this.getAgentSessionWorkspaces(options.agentId)

    await agentGlobalSkillService.upsertJoin(options.agentId, options.skillId, options.isEnabled)

    if (workspaces.length > 0) {
      // Track workspaces we already (un)linked so a mid-loop failure can reverse
      // them — otherwise the catch only reverts the DB row, leaving the symlinks
      // it already wrote to earlier workspaces orphaned and out of sync.
      const applied: string[] = []
      try {
        for (const workspace of workspaces) {
          if (options.isEnabled) {
            await this.linkSkill(skill.folderName, workspace)
          } else {
            await this.unlinkSkill(skill.folderName, workspace)
          }
          applied.push(workspace)
        }
      } catch (error) {
        // Best-effort reverse the filesystem ops we managed to apply before the
        // failure (linkSkill/unlinkSkill are idempotent), then revert the DB row.
        for (const workspace of applied) {
          try {
            if (options.isEnabled) {
              await this.unlinkSkill(skill.folderName, workspace)
            } else {
              await this.linkSkill(skill.folderName, workspace)
            }
          } catch (reverseError) {
            logger.error('Failed to reverse skill symlink during rollback', {
              agentId: options.agentId,
              skillId: options.skillId,
              workspace,
              error: reverseError instanceof Error ? reverseError.message : String(reverseError)
            })
          }
        }
        let rollbackError: unknown
        await agentGlobalSkillService.upsertJoin(options.agentId, options.skillId, !options.isEnabled).catch((e) => {
          rollbackError = e
          logger.error('Failed to roll back agent_skill after symlink error', {
            agentId: options.agentId,
            skillId: options.skillId,
            error: e instanceof Error ? e.message : String(e)
          })
        })
        logger.error('Failed to (un)link skill for agent', {
          agentId: options.agentId,
          skillId: options.skillId,
          isEnabled: options.isEnabled,
          error: error instanceof Error ? error.message : String(error)
        })
        if (rollbackError) {
          throw new AggregateError([error, rollbackError], 'Skill toggle and rollback both failed')
        }
        throw error
      }
    } else {
      logger.warn('Skipping skill symlink: agent has no resolvable workspace', {
        agentId: options.agentId,
        skillId: options.skillId
      })
    }

    return { ...skill, isEnabled: options.isEnabled }
  }

  /**
   * Seed skill enablement for a freshly created agent.
   *
   * Every skill marked `source = 'builtin'` is auto-enabled for the new agent.
   */
  async initSkillsForAgent(agentId: string, workspace: string | undefined): Promise<void> {
    const allSkills = await agentGlobalSkillService.listAll()
    const builtinSkills = allSkills.filter((s) => s.source === 'builtin')
    if (builtinSkills.length === 0) return

    for (const skill of builtinSkills) {
      await agentGlobalSkillService.upsertJoin(agentId, skill.id, true)
      if (workspace) {
        try {
          await this.linkSkill(skill.folderName, workspace)
        } catch (error) {
          logger.warn('Failed to link builtin skill for new agent', {
            agentId,
            skillId: skill.id,
            error: error instanceof Error ? error.message : String(error)
          })
        }
      }
    }
    logger.info('Seeded builtin skills for agent', { agentId, count: builtinSkills.length })
  }

  /**
   * Enable a skill across every existing agent and create symlinks in every
   * session workspace. Used when a new builtin skill is installed.
   */
  async enableForAllAgents(skillId: string, folderName: string): Promise<void> {
    const agentIds = await agentGlobalSkillService.upsertJoinForAllAgents(skillId, true)

    for (const agentId of agentIds) {
      const workspaces = await this.getAgentSessionWorkspaces(agentId)
      for (const workspace of workspaces) {
        try {
          await this.linkSkill(folderName, workspace)
        } catch (error) {
          logger.warn('Failed to link builtin skill for session workspace', {
            agentId,
            workspace,
            skillId,
            error: error instanceof Error ? error.message : String(error)
          })
        }
      }
    }
    logger.info('Enabled skill for all agents', { skillId, folderName, agentCount: agentIds.length })
  }

  /**
   * Ensure the workspace's `.claude/skills/` directory matches the
   * `agent_skill` DB state for the given agent.
   */
  async reconcileAgentSkills(agentId: string, workspace: string): Promise<void> {
    if (!workspace) return
    const agentSkillRows = await agentGlobalSkillService.listJoinByAgent(agentId)

    for (const row of agentSkillRows) {
      if (!row.isEnabled) continue
      const skill = await agentGlobalSkillService.getById(row.skillId)
      if (!skill) continue
      try {
        await this.linkSkill(skill.folderName, workspace)
      } catch (error) {
        logger.warn('Reconcile: failed to link skill', {
          agentId,
          skillId: row.skillId,
          error: error instanceof Error ? error.message : String(error)
        })
      }
    }
  }

  async readFile(skillId: string, filename: string): Promise<string | null> {
    const skill = await agentGlobalSkillService.getById(skillId)
    if (!skill) return null

    const skillRoot = this.getSkillStoragePath(skill.folderName)
    const filePath = path.resolve(skillRoot, filename)

    // Prevent path traversal
    if (!filePath.startsWith(skillRoot + path.sep) && filePath !== skillRoot) return null

    try {
      return await fs.promises.readFile(filePath, 'utf-8')
    } catch {
      return null
    }
  }

  async listFiles(skillId: string): Promise<SkillFileNode[]> {
    const skill = await agentGlobalSkillService.getById(skillId)
    if (!skill) return []

    const skillRoot = this.getSkillStoragePath(skill.folderName)
    try {
      return await this.buildFileTree(skillRoot, skillRoot)
    } catch {
      return []
    }
  }

  async uninstallByFolderName(folderName: string): Promise<void> {
    const skill = await agentGlobalSkillService.getByFolderName(folderName)
    if (!skill) {
      throw new Error(`Skill not found by folder name: ${folderName}`)
    }
    await this.uninstall(skill.id)
  }

  async getByFolderName(name: string): Promise<InstalledSkill | null> {
    const folderName = this.sanitizeFolderName(name)
    return agentGlobalSkillService.getByFolderName(folderName)
  }

  /**
   * Resolve the absolute path a skill with the given name would live at under
   * the global Skills storage root.
   */
  getSkillDirectory(name: string): string {
    return this.getSkillStoragePath(this.sanitizeFolderName(name))
  }

  async uninstall(skillId: string): Promise<void> {
    const skill = await agentGlobalSkillService.getById(skillId)
    if (!skill) {
      throw new Error(`Skill not found: ${skillId}`)
    }

    // Remove symlinks from every session workspace that had this skill enabled,
    // before we lose the join rows to the cascade delete below.
    const agentSkillRows = await agentGlobalSkillService.listJoinBySkill(skillId)
    for (const row of agentSkillRows) {
      if (!row.isEnabled) continue
      const workspaces = await this.getAgentSessionWorkspaces(row.agentId)
      for (const workspace of workspaces) {
        try {
          await this.unlinkSkill(skill.folderName, workspace)
        } catch (error) {
          logger.warn('Failed to unlink skill during uninstall', {
            skillId,
            agentId: row.agentId,
            workspace,
            error: error instanceof Error ? error.message : String(error)
          })
        }
      }
    }

    // Remove from global storage; FK cascade on skill_id deletes agent_skills rows.
    const skillPath = this.getSkillStoragePath(skill.folderName)
    await this.installer.uninstall(skillPath)
    await agentGlobalSkillService.deleteById(skillId)
    logger.info('Skill uninstalled', { skillId, folderName: skill.folderName })
  }

  /**
   * Install from a marketplace installSource handle.
   * Format: "claude-plugins:{owner}/{repo}/{skillName}" or "skills.sh:{owner}/{repo}" or "clawhub:{slug}"
   */
  async install(options: SkillInstallOptions): Promise<InstalledSkill> {
    const { installSource } = options
    const [source, ...rest] = installSource.split(':')
    const identifier = rest.join(':')

    switch (source) {
      case 'claude-plugins':
        return this.installFromClaudePlugins(identifier)
      case 'skills.sh':
        return this.installFromSkillsSh(identifier)
      case 'clawhub':
        return this.installFromClawhub(identifier)
      default:
        throw new Error(`Unknown install source: ${source}`)
    }
  }

  async installFromZip(options: SkillInstallFromZipOptions): Promise<InstalledSkill> {
    const { zipFilePath } = options
    logger.info('Installing skill from ZIP', { zipFilePath })

    await this.validateZipFile(zipFilePath)
    const tempDir = await this.createTempDir('zip-install')

    try {
      await this.extractZip(zipFilePath, tempDir)
      const skillDir = await this.locateSkillDir(tempDir)
      return await this.installSkillDir(skillDir, 'zip', null)
    } finally {
      await this.safeRemoveDirectory(tempDir)
    }
  }

  async installFromDirectory(options: SkillInstallFromDirectoryOptions): Promise<InstalledSkill> {
    const { directoryPath } = options
    logger.info('Installing skill from directory', { directoryPath })

    if (!(await directoryExists(directoryPath))) {
      throw new Error(`Directory not found: ${directoryPath}`)
    }

    return this.installSkillDir(directoryPath, 'local', null)
  }

  /**
   * List local skills from an agent workdir's .claude/skills/ directory.
   */
  async listLocal(workdir: string): Promise<Array<{ name: string; description?: string; filename: string }>> {
    const results: Array<{ name: string; description?: string; filename: string }> = []
    const skillsDir = path.join(workdir, '.claude', 'skills')

    try {
      const entries = await fs.promises.readdir(skillsDir, { withFileTypes: true })
      for (const entry of entries) {
        if (!(await this.isLocalSkillDirectoryEntry(skillsDir, entry))) continue
        try {
          const skillPath = path.join(skillsDir, entry.name)
          const metadata = await parseSkillMetadata(skillPath, entry.name, 'skills')
          results.push({ name: metadata.name, description: metadata.description, filename: entry.name })
        } catch (error) {
          if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
          logger.warn('Failed to parse skill metadata; skipping', {
            skillsDir,
            entry: entry.name,
            error: error instanceof Error ? error.message : String(error)
          })
        }
      }
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code === 'ENOENT') return results
      logger.warn('Failed to enumerate skills directory', {
        skillsDir,
        error: error instanceof Error ? error.message : String(error)
      })
    }

    return results
  }

  /**
   * `listLocal` is only for user/project-owned workspace skills that already
   * live under `.claude/skills/`. Those entries can be real directories or
   * user-created symlinks to directories.
   *
   * Cherry-managed skills also appear under `.claude/skills/` as symlinks when
   * enabled for Claude SDK discovery, but their source of truth is
   * `agent_global_skill` and they are rendered by `list({ agentId })`. Keep
   * them out of this local-only list.
   */
  private async isLocalSkillDirectoryEntry(skillsDir: string, entry: fs.Dirent): Promise<boolean> {
    if (entry.isDirectory()) return true
    if (!entry.isSymbolicLink()) return false

    const entryPath = path.join(skillsDir, entry.name)
    try {
      const stats = await fs.promises.stat(entryPath)
      if (!stats.isDirectory()) return false
      if (await this.isManagedSkillSymlinkTarget(entryPath)) return false
      return true
    } catch (error) {
      logger.warn('Failed to resolve local skill symlink; skipping', {
        skillsDir,
        entry: entry.name,
        error: error instanceof Error ? error.message : String(error)
      })
      return false
    }
  }

  /**
   * `linkSkill()` creates workspace symlinks that point back into the app-owned
   * global skill storage. Those are managed DB-backed skills, not independent
   * local workspace skills.
   */
  private async isManagedSkillSymlinkTarget(entryPath: string): Promise<boolean> {
    try {
      const [entryRealPath, skillsRootRealPath] = await Promise.all([
        fs.promises.realpath(entryPath),
        fs.promises.realpath(application.getPath('feature.agents.skills'))
      ])
      return entryRealPath === skillsRootRealPath || entryRealPath.startsWith(skillsRootRealPath + path.sep)
    } catch {
      return false
    }
  }

  // ===========================================================================
  // Symlink management
  // ===========================================================================

  /**
   * Create a symlink from `{workspace}/.claude/skills/{folderName}` →
   * global skills storage (`{dataPath}/Skills/{folderName}`).
   */
  async linkSkill(folderName: string, workspace: string): Promise<void> {
    const target = this.getSkillStoragePath(folderName)
    const linkPath = this.getSkillLinkPath(folderName, workspace)

    try {
      await fs.promises.mkdir(path.dirname(linkPath), { recursive: true })

      try {
        const stat = await fs.promises.lstat(linkPath)
        if (stat.isSymbolicLink()) {
          await fs.promises.rm(linkPath)
        } else if (stat.isDirectory()) {
          throw new Error(`Cannot link skill '${folderName}': target path already exists and is not a symlink`)
        }
      } catch (error) {
        if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
          throw error
        }
        // Does not exist, fine
      }

      await fs.promises.symlink(target, linkPath, 'junction')
      logger.info('Skill linked', { folderName, target, linkPath })
    } catch (error) {
      logger.error('Failed to link skill', {
        folderName,
        linkPath,
        error: error instanceof Error ? error.message : String(error)
      })
      throw error
    }
  }

  /**
   * Remove the symlink at `{workspace}/.claude/skills/{folderName}`.
   */
  async unlinkSkill(folderName: string, workspace: string): Promise<void> {
    const linkPath = this.getSkillLinkPath(folderName, workspace)

    try {
      const stat = await fs.promises.lstat(linkPath)
      if (stat.isSymbolicLink()) {
        await fs.promises.unlink(linkPath)
        logger.info('Skill unlinked', { folderName, linkPath })
      }
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
        logger.error('Failed to unlink skill', {
          folderName,
          linkPath,
          error: error instanceof Error ? error.message : String(error)
        })
        throw error
      }
      // Link doesn't exist, nothing to do
    }
  }

  // ===========================================================================
  // Source-specific install flows
  // ===========================================================================

  private async installFromClaudePlugins(identifier: string): Promise<InstalledSkill> {
    const parts = identifier.split('/')
    if (parts.length < 3) {
      throw new Error(`Invalid claude-plugins identifier: ${identifier}`)
    }

    const [owner, repo, ...rest] = parts
    const directoryPath = rest.join('/')
    const repoUrl = `https://github.com/${owner}/${repo}`
    const sourceUrl = `${repoUrl}/tree/main/${directoryPath}`
    const tempDir = await this.createTempDir('claude-plugins')

    try {
      await this.cloneRepository(repoUrl, tempDir)
      const skillName = parts[parts.length - 1]
      const skillDir = await this.resolveSkillDirectory(tempDir, skillName, directoryPath)
      const installed = await this.installSkillDir(skillDir, 'marketplace', sourceUrl)

      this.reportInstall(owner, repo, skillName).catch((err) => {
        logger.warn('Failed to report install', { error: err instanceof Error ? err.message : String(err) })
      })

      return installed
    } finally {
      await this.safeRemoveDirectory(tempDir)
    }
  }

  private async installFromSkillsSh(identifier: string): Promise<InstalledSkill> {
    const parts = identifier.split('/')
    if (parts.length < 2) {
      throw new Error(`Invalid skills.sh identifier: ${identifier}`)
    }
    logger.info('Installing from skills.sh', { identifier })

    const owner = parts[0]
    const repo = parts[1]
    const skillName = parts.length > 2 ? parts.slice(2).join('/') : null
    const repoUrl = `https://github.com/${owner}/${repo}`
    const tempDir = await this.createTempDir('skills-sh')

    try {
      await this.cloneRepository(repoUrl, tempDir)
      const skillDir = await this.resolveSkillDirectory(tempDir, skillName, null)
      return await this.installSkillDir(skillDir, 'marketplace', repoUrl)
    } finally {
      await this.safeRemoveDirectory(tempDir)
    }
  }

  private async installFromClawhub(slug: string): Promise<InstalledSkill> {
    const detailUrl = `https://api.clawhub.ai/api/v1/skills/${slug}`
    const detailResp = await net.fetch(detailUrl, {
      headers: { 'User-Agent': 'CherryStudio' }
    })

    if (!detailResp.ok) {
      throw new Error(`clawhub detail failed: HTTP ${detailResp.status}`)
    }

    const downloadUrl = `https://api.clawhub.ai/api/v1/skills/${slug}/download`
    const downloadResp = await net.fetch(downloadUrl, {
      headers: { 'User-Agent': 'CherryStudio' }
    })

    if (!downloadResp.ok) {
      throw new Error(`clawhub download failed: HTTP ${downloadResp.status}`)
    }

    const tempDir = await this.createTempDir('clawhub')
    const zipPath = path.join(tempDir, 'skill.zip')

    try {
      const buffer = Buffer.from(await downloadResp.arrayBuffer())
      await fs.promises.writeFile(zipPath, buffer)
      const extractDir = path.join(tempDir, 'extracted')
      await fs.promises.mkdir(extractDir, { recursive: true })
      await this.extractZip(zipPath, extractDir)
      const skillDir = await this.locateSkillDir(extractDir)
      return await this.installSkillDir(skillDir, 'marketplace', `https://clawhub.ai/skills/${slug}`)
    } finally {
      await this.safeRemoveDirectory(tempDir)
    }
  }

  // ===========================================================================
  // Core install logic
  // ===========================================================================

  private async installSkillDir(skillDir: string, source: string, sourceUrl: string | null): Promise<InstalledSkill> {
    const metadata = await parseSkillMetadata(skillDir, path.basename(skillDir), 'skills')

    const skillsRoot = path.resolve(application.getPath('feature.agents.skills'))
    const isInPlace = path.resolve(path.dirname(skillDir)) === skillsRoot
    const folderName = isInPlace ? path.basename(skillDir) : this.sanitizeFolderName(metadata.filename)

    const existing = await agentGlobalSkillService.getByFolderName(folderName)

    const contentHash = await this.installer.computeContentHash(skillDir)
    const destPath = this.getSkillStoragePath(folderName)

    await fs.promises.mkdir(path.dirname(destPath), { recursive: true })
    await this.installer.install(skillDir, destPath)

    const tags = metadata.tags ?? []

    if (existing) {
      // Update metadata in-place to preserve the skill ID and its agent_skills rows.
      await agentGlobalSkillService.update(existing.id, {
        name: metadata.name,
        description: metadata.description ?? null,
        author: metadata.author ?? null,
        tags,
        contentHash
      })
      const updated = (await agentGlobalSkillService.getById(existing.id))!
      logger.info('Skill updated', { id: existing.id, name: metadata.name, folderName, source })
      return updated
    }

    const isBuiltin = source === 'builtin'

    let inserted: InstalledSkill | undefined
    try {
      const insertedRow = await agentGlobalSkillService.insert({
        name: metadata.name,
        description: metadata.description ?? null,
        folderName,
        source,
        sourceUrl,
        namespace: null,
        author: metadata.author ?? null,
        tags,
        contentHash,
        isEnabled: false
      })
      inserted = (await agentGlobalSkillService.getById(insertedRow.id)) ?? undefined
    } catch (error) {
      try {
        await this.installer.uninstall(destPath)
      } catch (cleanupError) {
        logger.error('Failed to clean up skill files after DB insert failure', {
          folderName,
          destPath,
          error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
        })
      }
      throw error
    }
    if (!inserted) {
      await this.installer.uninstall(destPath)
      throw new Error(`Failed to insert skill: ${metadata.name}`)
    }

    if (isBuiltin) {
      await this.enableForAllAgents(inserted.id, folderName)
    }

    logger.info('Skill installed', { id: inserted.id, name: metadata.name, folderName, source })
    return inserted
  }

  // ===========================================================================
  // Git operations
  // ===========================================================================

  private async cloneRepository(repoUrl: string, destDir: string): Promise<void> {
    const gitCommand = (await findExecutableInEnv('git')) ?? 'git'

    const branch = await this.resolveDefaultBranch(gitCommand, repoUrl)
    if (branch) {
      await executeCommand(gitCommand, ['clone', '--depth', '1', '--branch', branch, '--', repoUrl, destDir])
      return
    }

    try {
      await executeCommand(gitCommand, ['clone', '--depth', '1', '--', repoUrl, destDir])
    } catch {
      await executeCommand(gitCommand, ['clone', '--depth', '1', '--branch', 'master', '--', repoUrl, destDir])
    }
  }

  private async resolveDefaultBranch(command: string, repoUrl: string): Promise<string | null> {
    try {
      const output = await executeCommand(command, ['ls-remote', '--symref', '--', repoUrl, 'HEAD'], { capture: true })
      const match = output.match(/ref: refs\/heads\/([^\s]+)/)
      return match?.[1] ?? null
    } catch {
      return null
    }
  }

  // ===========================================================================
  // ZIP operations
  // ===========================================================================

  private async validateZipFile(zipFilePath: string): Promise<void> {
    const stats = await fs.promises.stat(zipFilePath)
    if (!stats.isFile()) {
      throw new Error(`Not a file: ${zipFilePath}`)
    }
    if (!zipFilePath.toLowerCase().endsWith('.zip')) {
      throw new Error(`Not a ZIP file: ${zipFilePath}`)
    }
  }

  private async extractZip(zipFilePath: string, destDir: string): Promise<void> {
    const zip = new StreamZip.async({ file: zipFilePath })

    try {
      const entries = await zip.entries()
      let totalSize = 0
      let fileCount = 0

      for (const entry of Object.values(entries)) {
        totalSize += entry.size
        fileCount++

        if (totalSize > MAX_EXTRACTED_SIZE) {
          throw new Error(`ZIP too large: ${totalSize} bytes exceeds ${MAX_EXTRACTED_SIZE}`)
        }
        if (fileCount > MAX_FILES_COUNT) {
          throw new Error(`ZIP has too many files: ${fileCount} exceeds ${MAX_FILES_COUNT}`)
        }
      }

      await zip.extract(null, destDir)
    } finally {
      await zip.close()
    }
  }

  // ===========================================================================
  // Directory resolution
  // ===========================================================================

  private async locateSkillDir(extractedDir: string): Promise<string> {
    return this.resolveSkillDirectory(extractedDir, null, null)
  }

  private async resolveSkillDirectory(
    repoDir: string,
    skillName: string | null,
    directoryPath: string | null
  ): Promise<string> {
    if (directoryPath) {
      const resolved = path.resolve(repoDir, directoryPath)
      const skillMdPath = await findSkillMdPath(resolved)
      if (skillMdPath) return resolved

      logger.debug('SKILL.md not found at directoryPath, falling through to search', { directoryPath })
    }

    const candidates = await findAllSkillDirectories(repoDir, repoDir, 8)

    if (skillName) {
      const matched = candidates.find((c) => path.basename(c.folderPath) === skillName)
      if (matched) return matched.folderPath
    }

    if (candidates.length === 1) {
      return candidates[0].folderPath
    }

    if (candidates.length > 1 && skillName) {
      const lowerName = skillName.toLowerCase()
      const fuzzy = candidates.find((c) => {
        const base = path.basename(c.folderPath).toLowerCase()
        return base.includes(lowerName) || lowerName.includes(base)
      })
      if (fuzzy) return fuzzy.folderPath
    }

    if (candidates.length > 0) {
      logger.warn('resolveSkillDirectory: fallback to first candidate', {
        directoryPath,
        skillName,
        candidateCount: candidates.length,
        selected: candidates[0].folderPath
      })
      return candidates[0].folderPath
    }

    const rootSkill = await findSkillMdPath(repoDir)
    if (rootSkill) return repoDir

    throw new Error(`No skill directory found in ${repoDir}`)
  }

  // ===========================================================================
  // Path helpers
  // ===========================================================================

  private getSkillStoragePath(folderName: string): string {
    return path.join(application.getPath('feature.agents.skills'), folderName)
  }

  private getSkillLinkPath(folderName: string, workspace: string): string {
    return path.join(workspace, '.claude', 'skills', folderName)
  }

  /** Subset of `agentGlobalSkillService.listAgentSessionWorkspacePaths` that exist on disk. */
  private async getAgentSessionWorkspaces(agentId: string): Promise<string[]> {
    const paths = await agentGlobalSkillService.listAgentSessionWorkspacePaths(agentId)
    const reachable: string[] = []
    for (const p of paths) {
      if (await directoryExists(p)) reachable.push(p)
    }
    return reachable
  }

  private sanitizeFolderName(folderName: string): string {
    let sanitized = folderName.replace(/[/\\]/g, '_')
    sanitized = sanitized.replace(new RegExp(String.fromCharCode(0), 'g'), '')
    sanitized = sanitized.replace(/[^a-zA-Z0-9_-]/g, '_')

    if (sanitized.length > MAX_FOLDER_NAME_LENGTH) {
      sanitized = sanitized.slice(0, MAX_FOLDER_NAME_LENGTH)
    }

    return sanitized
  }

  private async createTempDir(prefix: string): Promise<string> {
    const tempDir = path.join(application.getPath('feature.agents.skills.install.temp'), `${prefix}-${Date.now()}`)
    await fs.promises.mkdir(tempDir, { recursive: true })
    return tempDir
  }

  private async safeRemoveDirectory(dirPath: string): Promise<void> {
    try {
      await deleteDirectoryRecursive(dirPath)
    } catch (error) {
      logger.warn('Failed to clean up temp directory', {
        dirPath,
        error: error instanceof Error ? error.message : String(error)
      })
    }
  }

  private async buildFileTree(dir: string, root: string): Promise<SkillFileNode[]> {
    const entries = await fs.promises.readdir(dir, { withFileTypes: true })
    const nodes: SkillFileNode[] = []

    const sorted = entries
      .filter((e) => !e.name.startsWith('.') && e.name !== 'node_modules')
      .sort((a, b) => {
        if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1
        return a.name.localeCompare(b.name)
      })

    for (const entry of sorted) {
      const fullPath = path.join(dir, entry.name)
      const relativePath = path.relative(root, fullPath)

      if (entry.isDirectory()) {
        const children = await this.buildFileTree(fullPath, root)
        nodes.push({ name: entry.name, path: relativePath, type: 'directory', children })
      } else {
        nodes.push({ name: entry.name, path: relativePath, type: 'file' })
      }
    }

    return nodes
  }

  /**
   * Register or refresh a built-in skill's DB row after its files have been
   * copied to the global skills directory. Called by `installBuiltinSkills`.
   *
   * - If the row exists and files weren't updated, no-ops.
   * - If files were updated, refreshes the metadata row in-place.
   * - If the row is missing (first install), inserts it and fans it out to
   *   every existing agent via `enableForAllAgents`.
   */
  async syncBuiltinSkill(folderName: string, destPath: string, filesUpdated: boolean): Promise<void> {
    const existing = await agentGlobalSkillService.getByFolderName(folderName)
    if (existing && !filesUpdated) return

    const metadata = await parseSkillMetadata(destPath, folderName, 'skills')
    const contentHash = await this.installer.computeContentHash(destPath)
    const tags = metadata.tags ?? []

    if (existing) {
      await agentGlobalSkillService.update(existing.id, {
        name: metadata.name,
        description: metadata.description ?? null,
        author: metadata.author ?? null,
        tags,
        contentHash
      })
    } else {
      const inserted = await agentGlobalSkillService.insert({
        name: metadata.name,
        description: metadata.description ?? null,
        folderName,
        source: 'builtin',
        sourceUrl: null,
        namespace: null,
        author: metadata.author ?? null,
        tags,
        contentHash,
        isEnabled: false
      })
      await this.enableForAllAgents(inserted.id, folderName)
    }

    logger.info('Built-in skill synced to DB', { folderName, firstInstall: !existing })
  }

  private async reportInstall(owner: string, repo: string, skillName: string): Promise<void> {
    const url = `${CLAUDE_PLUGINS_API}/api/skills/${owner}/${repo}/${skillName}/install`
    await net.fetch(url, { method: 'POST' })
  }
}

export const skillService = new SkillService()
