const fs = require('node:fs/promises')
const path = require('node:path')
const { spawn } = require('node:child_process')

const hyperDeskRoot = path.resolve(__dirname, '..')
const hyperDeskProtectedPatterns = [
  /^index\.html$/i,
  /^package\.json$/i,
  /^package-lock\.json$/i,
  /^gleam\.toml$/i,
  /^manifest\.toml$/i,
  /^README\.md$/i,
  /^electron\/.*/i,
  /^src\/.*/i,
  /^renderer\/.*/i,
  /^test\/.*/i,
  /^build\/.*/i,
  /^dist\/.*/i,
  /^assets\/.*/i,
  /^\.git\/.*/i,
  /^\.pi\/.*/i,
  /^\.zenbin\/.*/i,
  /^node_modules\/.*/i,
]

function isHyperDeskAppFile(resolvedPath) {
  const relative = path.relative(hyperDeskRoot, resolvedPath)
  // If it's outside the HyperDesk root, not our concern
  if (relative.startsWith('..')) return false
  // Allow writes to projects/ subdirectory
  if (relative.startsWith('projects' + path.sep) || relative === 'projects') return false
  // Allow writes to .pi/hyper-desk/ (profile files)
  if (relative.startsWith('.pi' + path.sep + 'hyper-desk' + path.sep)) return false
  // Check against protected patterns
  const posixRelative = relative.split(path.sep).join('/')
  return hyperDeskProtectedPatterns.some(pattern => pattern.test(posixRelative))
}

class PiAdapter {
  constructor(sendToUi, chromeTools = undefined, options = {}) {
    this.sendToUi = sendToUi
    this.chromeTools = chromeTools
    this.externalPackagesEnabled = Boolean(options.externalPackagesEnabled)
    this.sessions = new Map()
    this.threadHadDelta = new Map()
    this.pendingApprovals = new Map()
    this.autoApproveBrowserActionsByThread = new Set()
    this.autoApproveLocalEffectsByThread = new Set()
    this.sdkPromise = undefined
    this.typeboxPromise = undefined
  }

  setExternalPackagesEnabled(enabled) {
    const next = Boolean(enabled)
    if (this.externalPackagesEnabled === next) return
    this.externalPackagesEnabled = next
    for (const session of this.sessions.values()) {
      try {
        Promise.resolve(session.abort?.()).catch(() => {})
      } catch (_error) {
        // Existing sessions are best-effort aborted; new sessions use the new resource policy.
      }
    }
    this.sessions.clear()
    this.threadHadDelta.clear()
  }

  async loadSdk() {
    if (!this.sdkPromise) {
      this.sdkPromise = import('@earendil-works/pi-coding-agent')
    }
    return this.sdkPromise
  }

  async loadTypebox() {
    if (!this.typeboxPromise) {
      this.typeboxPromise = import('typebox')
    }
    return this.typeboxPromise
  }

  createStandaloneResourceSettings(settingsManager) {
    const sanitize = settings => ({
      ...(settings || {}),
      packages: [],
      extensions: [],
      skills: [],
      prompts: [],
      themes: []
    })

    return {
      reload: async () => {
        await settingsManager.reload?.()
      },
      getGlobalSettings: () => sanitize(settingsManager.getGlobalSettings?.()),
      getProjectSettings: () => sanitize(settingsManager.getProjectSettings?.()),
      getNpmCommand: () => settingsManager.getNpmCommand?.()
    }
  }

  async getSession(threadId, cwd = process.cwd(), preferredModelName = '') {
    if (this.sessions.has(threadId)) return this.sessions.get(threadId)
    const sessionCwd = path.resolve(cwd)
    await this.linkBuiltinSkills(sessionCwd)

    const {
      AuthStorage,
      createAgentSession,
      DefaultResourceLoader,
      defineTool,
      getAgentDir,
      ModelRegistry,
      SessionManager,
      SettingsManager
    } = await this.loadSdk()

    const agentDir = getAgentDir()
    const authStorage = AuthStorage.create(path.join(agentDir, 'auth.json'))
    const modelRegistry = ModelRegistry.create(authStorage, path.join(agentDir, 'models.json'))
    const settingsManager = SettingsManager.create(sessionCwd, agentDir)
    settingsManager.applyOverrides({ compaction: { enabled: false } })

    const available = await modelRegistry.getAvailable()
    const model = this.chooseModel(available, settingsManager, preferredModelName)
    if (!model) {
      throw new Error(`No Pi model available from ${agentDir}. Run terminal Pi login or configure credentials, then refresh.`)
    }

    this.sendToUi('pi:models', {
      provider: model.provider || 'unknown',
      summary: `Using model: ${this.formatModel(model)}\nExternal Pi packages: ${this.externalPackagesEnabled ? 'enabled' : 'disabled'}`
    })

    const customTools = [
      ...(await this.createLocalEffectTools(defineTool, sessionCwd, threadId)),
      ...(await this.createChromeTools(defineTool, threadId))
    ]
    const customToolNames = customTools.map(tool => tool.name)
    const extensionToolNames = this.externalPackagesEnabled ? this.extensionToolNames() : []
    const profile = await this.loadProfileFiles(sessionCwd, agentDir)
    const builtinSkills = await this.loadBuiltinSkills()
    const resourceLoader = new DefaultResourceLoader({
      cwd: sessionCwd,
      agentDir,
      settingsManager: this.externalPackagesEnabled ? settingsManager : this.createStandaloneResourceSettings(settingsManager),
      noExtensions: !this.externalPackagesEnabled,
      noSkills: !this.externalPackagesEnabled,
      noPromptTemplates: !this.externalPackagesEnabled,
      noThemes: !this.externalPackagesEnabled,
      extensionFactories: [this.createWorkbenchApprovalExtension()],
      appendSystemPromptOverride: base => this.appendProfileSystemPrompt(base, profile, builtinSkills),
      agentsFilesOverride: base => this.appendProfileContextFiles(base, profile)
    })
    await resourceLoader.reload()

    const { session } = await createAgentSession({
      cwd: sessionCwd,
      agentDir,
      authStorage,
      modelRegistry,
      settingsManager,
      resourceLoader,
      sessionManager: SessionManager.inMemory(sessionCwd),
      model,
      tools: ['read', 'grep', 'find', 'ls', ...customToolNames, ...extensionToolNames],
      customTools,
      thinkingLevel: 'off'
    })

    session.subscribe(event => this.handleEvent(threadId, event))
    this.sessions.set(threadId, session)
    this.sendContextStats(threadId, session)
    return session
  }

  async loadProfileFiles(cwd, agentDir) {
    const locations = [
      { scope: 'global', dir: path.join(agentDir, 'hyper-desk') },
      { scope: 'project', dir: path.join(cwd, '.pi', 'hyper-desk') }
    ]
    const files = []

    for (const location of locations) {
      for (const name of ['identity', 'user', 'soul']) {
        const filePath = path.join(location.dir, `${name}.md`)
        try {
          const content = await fs.readFile(filePath, 'utf8')
          if (content.trim()) files.push({ ...location, name, path: filePath, content })
        } catch (error) {
          if (error?.code !== 'ENOENT') throw error
        }
      }
    }

    return files
  }

  appendProfileSystemPrompt(base, profile, builtinSkills = []) {
    const sections = [...base]
    for (const file of profile.filter(file => file.name === 'identity' || file.name === 'soul')) {
      sections.push(`# HyperDesk ${file.scope} ${file.name}\n\n${file.content}`)
    }
    if (builtinSkills.length > 0) {
      sections.push(`# HyperDesk Default Skills\n\nThe following capabilities are available by default. Apply them when relevant without waiting for explicit permission.\n`)
      for (const skill of builtinSkills) {
        sections.push(`## ${skill.name}\n\n${skill.content}`)
      }
    }
    return sections
  }

  async loadBuiltinSkills() {
    const skillsDir = path.join(__dirname, '..', 'skills')
    const skills = []
    try {
      const entries = await fs.readdir(skillsDir, { withFileTypes: true })
      for (const entry of entries) {
        if (!entry.isDirectory()) continue
        const skillFile = path.join(skillsDir, entry.name, 'SKILL.md')
        try {
          const content = await fs.readFile(skillFile, 'utf8')
          if (content.trim()) {
            skills.push({ name: entry.name, content: content.trim() })
          }
        } catch (error) {
          if (error?.code !== 'ENOENT') throw error
        }
      }
    } catch (error) {
      if (error?.code !== 'ENOENT') throw error
    }
    return skills
  }

  async linkBuiltinSkills(sessionCwd) {
    const appSkills = path.join(__dirname, '..', 'skills')
    const sessionSkills = path.join(sessionCwd, 'skills')
    try {
      const existing = await fs.lstat(sessionSkills).catch(() => null)
      if (!existing) {
        await fs.symlink(appSkills, sessionSkills)
      }
    } catch (error) {
      // Symlink failures are non-fatal; skills still work via system prompt
    }
  }

  appendProfileContextFiles(base, profile) {
    const userFiles = profile
      .filter(file => file.name === 'user')
      .map(file => ({ path: file.path, content: `# HyperDesk ${file.scope} user\n\n${file.content}` }))

    return { agentsFiles: [...base.agentsFiles, ...userFiles] }
  }

  async createLocalEffectTools(defineTool, cwd, threadId = 'global') {
    const { Type } = await this.loadTypebox()
    const textResult = text => ({ content: [{ type: 'text', text }], details: {} })

    return [
      defineTool({
        name: 'fs_write_file',
        label: 'Write File',
        description: 'Write a file under the current project directory. Requires user approval. Writing to the HyperDesk application source files is blocked.',
        parameters: Type.Object({
          path: Type.String({ description: 'Project-relative path to write.' }),
          content: Type.String({ description: 'Full file content to write.' }),
          reason: Type.Optional(Type.String({ description: 'Why this write is needed.' }))
        }),
        execute: async (toolCallId, params) => {
          const filePath = String(params?.path || '')
          const approved = await this.requestApproval(toolCallId, 'fs_write_file', `Write ${filePath}${params?.reason ? ` — ${params.reason}` : ''}`, threadId)
          if (!approved) return { content: [{ type: 'text', text: 'User rejected fs_write_file.' }], details: {}, isError: true }
          const absolutePath = await this.resolveProjectPath(filePath, cwd)
          if (isHyperDeskAppFile(absolutePath)) {
            return { content: [{ type: 'text', text: `Blocked: ${filePath} is a protected HyperDesk application file. Use the projects/ subdirectory or a different working directory.` }], details: {}, isError: true }
          }
          await fs.mkdir(path.dirname(absolutePath), { recursive: true })
          await fs.writeFile(absolutePath, String(params?.content || ''), 'utf8')
          return textResult(`Wrote ${filePath}`)
        }
      }),
      defineTool({
        name: 'bash_run',
        label: 'Run Shell Command',
        description: 'Run a shell command in the current project directory. Requires user approval. Commands that write to HyperDesk application source files are blocked.',
        parameters: Type.Object({
          command: Type.String({ description: 'Shell command to run.' }),
          timeoutMs: Type.Optional(Type.Number({ description: 'Timeout in milliseconds. Default 30000, max 120000.' })),
          reason: Type.Optional(Type.String({ description: 'Why this command is needed.' }))
        }),
        execute: async (toolCallId, params) => {
          const command = String(params?.command || '')
          const approved = await this.requestApproval(toolCallId, 'bash_run', `Run: ${command}${params?.reason ? ` — ${params.reason}` : ''}`, threadId)
          if (!approved) return { content: [{ type: 'text', text: 'User rejected bash_run.' }], details: {}, isError: true }
          if (this.commandTargetsHyperDeskFile(command, cwd)) {
            return { content: [{ type: 'text', text: 'Blocked: this command targets a protected HyperDesk application file. Use the projects/ subdirectory or a different working directory.' }], details: {}, isError: true }
          }
          return textResult(await this.runShellCommand(command, params?.timeoutMs, cwd))
        }
      })
    ]
  }

  async createChromeTools(defineTool, threadId = 'global') {
    const { Type } = await this.loadTypebox()
    const textResult = text => ({ content: [{ type: 'text', text }], details: {} })
    const jsonResult = value => textResult(JSON.stringify(value, null, 2))

    return [
      defineTool({
        name: 'chrome_launch',
        label: 'Launch Chrome',
        description: 'Launch or connect to a visible Chrome window with DevTools enabled.',
        parameters: Type.Object({
          url: Type.Optional(Type.String({ description: 'Optional URL to open.' }))
        }),
        execute: async (_toolCallId, params) => textResult(await this.safeChromeCall('launch', params?.url))
      }),
      defineTool({
        name: 'chrome_list_tabs',
        label: 'List Chrome Tabs',
        description: 'List visible Chrome tabs available over DevTools. Read-only.',
        parameters: Type.Object({}),
        execute: async () => jsonResult(await this.safeChromeCall('listTabs'))
      }),
      defineTool({
        name: 'chrome_new_tab',
        label: 'New Chrome Tab',
        description: 'Open a new visible Chrome tab. Requires approval.',
        parameters: Type.Object({
          url: Type.Optional(Type.String({ description: 'Optional URL for the new tab. Defaults to about:blank.' })),
          reason: Type.Optional(Type.String({ description: 'Why a new tab is needed.' }))
        }),
        execute: async (toolCallId, params) => {
          const approved = await this.requestApproval(toolCallId, 'chrome_new_tab', `Open a new Chrome tab${params?.url ? ` at ${params.url}` : ''}${params?.reason ? ` — ${params.reason}` : ''}`, threadId)
          if (!approved) return { content: [{ type: 'text', text: 'User rejected chrome_new_tab.' }], details: {}, isError: true }
          return textResult(await this.safeChromeCall('newTab', params?.url || 'about:blank'))
        }
      }),
      defineTool({
        name: 'chrome_navigate',
        label: 'Navigate Chrome',
        description: 'Navigate a visible Chrome tab to a URL. Requires approval.',
        parameters: Type.Object({
          url: Type.String({ description: 'URL to navigate to.' }),
          tabId: Type.Optional(Type.String({ description: 'Optional Chrome tab id from chrome_list_tabs.' })),
          reason: Type.Optional(Type.String({ description: 'Why navigation is needed.' }))
        }),
        execute: async (toolCallId, params) => {
          const approved = await this.requestApproval(toolCallId, 'chrome_navigate', `Navigate Chrome to ${params?.url}${params?.reason ? ` — ${params.reason}` : ''}`, threadId)
          if (!approved) return { content: [{ type: 'text', text: 'User rejected chrome_navigate.' }], details: {}, isError: true }
          return textResult(await this.safeChromeCall('navigate', params?.url, params?.tabId))
        }
      }),
      defineTool({
        name: 'chrome_snapshot_text',
        label: 'Chrome Text Snapshot',
        description: 'Get visible text from a Chrome tab. Read-only.',
        parameters: Type.Object({
          maxChars: Type.Optional(Type.Number({ description: 'Maximum characters to return. Default 12000.' })),
          tabId: Type.Optional(Type.String({ description: 'Optional Chrome tab id from chrome_list_tabs.' }))
        }),
        execute: async (_toolCallId, params) => textResult(await this.safeChromeCall('snapshotText', params?.maxChars, params?.tabId))
      }),
      defineTool({
        name: 'chrome_snapshot_elements',
        label: 'Chrome Interactive Elements',
        description: 'List visible interactive elements in a Chrome tab. Read-only.',
        parameters: Type.Object({
          maxElements: Type.Optional(Type.Number({ description: 'Maximum elements to return. Default 100.' })),
          tabId: Type.Optional(Type.String({ description: 'Optional Chrome tab id from chrome_list_tabs.' }))
        }),
        execute: async (_toolCallId, params) => jsonResult(await this.safeChromeCall('snapshotElements', params?.maxElements, params?.tabId))
      }),
      defineTool({
        name: 'chrome_scroll',
        label: 'Scroll Chrome',
        description: 'Scroll a visible Chrome tab. Requires approval unless browser actions have been approved for this session.',
        parameters: Type.Object({
          y: Type.Number({ description: 'Vertical scroll amount in pixels. Positive scrolls down, negative scrolls up.' }),
          x: Type.Optional(Type.Number({ description: 'Horizontal scroll amount in pixels. Default 0.' })),
          tabId: Type.Optional(Type.String({ description: 'Optional Chrome tab id from chrome_list_tabs.' })),
          reason: Type.Optional(Type.String({ description: 'Why scrolling is needed.' }))
        }),
        execute: async (toolCallId, params) => {
          const approved = await this.requestApproval(toolCallId, 'chrome_scroll', `Scroll Chrome by ${params?.y}px${params?.reason ? ` — ${params.reason}` : ''}`, threadId)
          if (!approved) return { content: [{ type: 'text', text: 'User rejected chrome_scroll.' }], details: {}, isError: true }
          return textResult(await this.safeChromeCall('scroll', params?.x || 0, params?.y, params?.tabId))
        }
      }),
      defineTool({
        name: 'chrome_click',
        label: 'Click Chrome Element',
        description: 'Click a Chrome element by index from chrome_snapshot_elements. Requires approval.',
        parameters: Type.Object({
          index: Type.Number({ description: 'Element index from chrome_snapshot_elements.' }),
          tabId: Type.Optional(Type.String({ description: 'Optional Chrome tab id from chrome_list_tabs.' })),
          reason: Type.Optional(Type.String({ description: 'Why this click is needed.' }))
        }),
        execute: async (toolCallId, params) => {
          const approved = await this.requestApproval(toolCallId, 'chrome_click', `Click Chrome element #${params?.index}${params?.reason ? ` — ${params.reason}` : ''}`, threadId)
          if (!approved) return { content: [{ type: 'text', text: 'User rejected chrome_click.' }], details: {}, isError: true }
          return textResult(await this.safeChromeCall('click', params?.index, params?.tabId))
        }
      }),
      defineTool({
        name: 'chrome_fill',
        label: 'Fill Chrome Field',
        description: 'Fill a Chrome input/textarea/contenteditable element by index. Requires approval.',
        parameters: Type.Object({
          index: Type.Number({ description: 'Element index from chrome_snapshot_elements.' }),
          value: Type.String({ description: 'Text to enter.' }),
          tabId: Type.Optional(Type.String({ description: 'Optional Chrome tab id from chrome_list_tabs.' })),
          reason: Type.Optional(Type.String({ description: 'Why this fill is needed.' }))
        }),
        execute: async (toolCallId, params) => {
          const preview = String(params?.value || '').slice(0, 160)
          const approved = await this.requestApproval(toolCallId, 'chrome_fill', `Fill Chrome element #${params?.index} with: ${preview}${params?.reason ? ` — ${params.reason}` : ''}`, threadId)
          if (!approved) return { content: [{ type: 'text', text: 'User rejected chrome_fill.' }], details: {}, isError: true }
          return textResult(await this.safeChromeCall('fill', params?.index, params?.value, params?.tabId))
        }
      }),
      defineTool({
        name: 'chrome_press_key',
        label: 'Press Chrome Key',
        description: 'Press a keyboard key in a visible Chrome tab. Requires approval.',
        parameters: Type.Object({
          key: Type.String({ description: 'Key to press, e.g. Enter, Tab, Escape.' }),
          tabId: Type.Optional(Type.String({ description: 'Optional Chrome tab id from chrome_list_tabs.' })),
          reason: Type.Optional(Type.String({ description: 'Why this key press is needed.' }))
        }),
        execute: async (toolCallId, params) => {
          const approved = await this.requestApproval(toolCallId, 'chrome_press_key', `Press ${params?.key} in Chrome${params?.reason ? ` — ${params.reason}` : ''}`, threadId)
          if (!approved) return { content: [{ type: 'text', text: 'User rejected chrome_press_key.' }], details: {}, isError: true }
          return textResult(await this.safeChromeCall('pressKey', params?.key, params?.tabId))
        }
      })
    ]
  }

  requestApproval(id, toolName, summary, threadId = 'global') {
    const scope = String(threadId || 'global')
    if (this.autoApproveBrowserActionsByThread.has(scope) && this.isBrowserActionTool(toolName)) {
      return Promise.resolve(true)
    }
    if (this.autoApproveLocalEffectsByThread.has(scope) && this.isLocalEffectTool(toolName)) {
      return Promise.resolve(true)
    }

    return new Promise(resolve => {
      this.pendingApprovals.set(id, { resolve, threadId: scope })
      this.sendToUi('approval:required', { id, toolName, summary, threadId: scope })
    })
  }

  isBrowserActionTool(toolName) {
    return String(toolName || '').startsWith('chrome_')
  }

  isLocalEffectTool(toolName) {
    return ['fs_write_file', 'bash_run', 'subagent', 'zenbin_identity'].includes(String(toolName || ''))
  }

  resolveApproval(id, approved) {
    const entry = this.pendingApprovals.get(id)
    if (!entry) return
    this.pendingApprovals.delete(id)
    entry.resolve(Boolean(approved))
  }

  approveAllBrowserActions(id) {
    const entry = this.pendingApprovals.get(id)
    this.autoApproveBrowserActionsByThread.add(String(entry?.threadId || 'global'))
    if (id) this.resolveApproval(id, true)
  }

  approveAllLocalEffects(id) {
    const entry = this.pendingApprovals.get(id)
    this.autoApproveLocalEffectsByThread.add(String(entry?.threadId || 'global'))
    if (id) this.resolveApproval(id, true)
  }

  async resolveProjectPath(projectPath, cwd = process.cwd()) {
    const root = path.resolve(cwd)
    const resolved = path.resolve(root, projectPath)
    if (resolved !== root && !resolved.startsWith(root + path.sep)) {
      throw new Error(`Path escapes project directory: ${projectPath}`)
    }

    const realRoot = await fs.realpath(root)
    const realParent = await this.realExistingPath(path.dirname(resolved))
    if (realParent !== realRoot && !realParent.startsWith(realRoot + path.sep)) {
      throw new Error(`Path escapes project directory through symlink: ${projectPath}`)
    }

    try {
      const realTarget = await fs.realpath(resolved)
      if (realTarget !== realRoot && !realTarget.startsWith(realRoot + path.sep)) {
        throw new Error(`Path escapes project directory through symlink: ${projectPath}`)
      }
    } catch (error) {
      if (error?.code !== 'ENOENT') throw error
    }

    return resolved
  }

  async realExistingPath(targetPath) {
    let current = path.resolve(targetPath)
    while (true) {
      try {
        return await fs.realpath(current)
      } catch (error) {
        if (error?.code !== 'ENOENT') throw error
        const parent = path.dirname(current)
        if (parent === current) throw error
        current = parent
      }
    }
  }

  commandTargetsHyperDeskFile(command, cwd = process.cwd()) {
    const resolvedCwd = path.resolve(cwd)
    const mutatingCommandPattern = /(^|[;&|\s])(rm|mv|cp|sed|perl|python|python3|node|tee|touch|truncate|install)\b/
    const hasMutation = mutatingCommandPattern.test(command) || /(^|[^>])>(?!>)|>>/.test(command)
    if (!hasMutation) return false

    const tokenPattern = /"([^"]+)"|'([^']+)'|([^\s;&|<>]+)/g
    let match
    while ((match = tokenPattern.exec(command)) !== null) {
      const token = match[1] || match[2] || match[3] || ''
      if (!this.shellTokenLooksLikePath(token)) continue
      try {
        const resolved = path.isAbsolute(token) ? path.resolve(token) : path.resolve(resolvedCwd, token)
        if (isHyperDeskAppFile(resolved)) return true
      } catch (_error) {
        // ignore unresolvable paths
      }
    }
    return false
  }

  shellTokenLooksLikePath(token) {
    if (!token || token.startsWith('-')) return false
    if (path.isAbsolute(token)) return true
    if (token.startsWith('./') || token.startsWith('../')) return true
    if (token.includes('/')) return true
    return /\.(json|md|toml|cjs|js|mjs|gleam|html|css|png|ico|icns)$/i.test(token)
  }

  runShellCommand(command, timeoutMs = 30000, cwd = process.cwd()) {
    const timeout = Math.max(1000, Math.min(Number(timeoutMs) || 30000, 120000))

    return new Promise(resolve => {
      const child = spawn(command, {
        cwd: path.resolve(cwd),
        shell: true,
        env: process.env
      })

      let stdout = ''
      let stderr = ''
      const timer = setTimeout(() => {
        child.kill('SIGTERM')
      }, timeout)

      child.stdout.on('data', chunk => { stdout += chunk.toString() })
      child.stderr.on('data', chunk => { stderr += chunk.toString() })
      child.on('error', error => {
        clearTimeout(timer)
        resolve(`Command failed to start: ${error.message}`)
      })
      child.on('close', code => {
        clearTimeout(timer)
        const output = [`exit code: ${code}`, stdout && `stdout:\n${stdout}`, stderr && `stderr:\n${stderr}`]
          .filter(Boolean)
          .join('\n\n')
        resolve(output.slice(-30000))
      })
    })
  }

  async safeChromeCall(name, ...args) {
    const fn = this.chromeTools?.[name]
    if (!fn) throw new Error(`Chrome tool not available: ${name}`)
    return await fn.apply(this.chromeTools, args)
  }

  async fetchUrlText(rawUrl, maxChars = 20000) {
    const url = String(rawUrl || '').trim()
    if (!url.startsWith('http://') && !url.startsWith('https://')) {
      throw new Error('web_fetch_text only supports http(s) URLs')
    }

    const limit = Number.isFinite(Number(maxChars)) ? Math.max(1, Math.min(Number(maxChars), 50000)) : 20000
    const response = await fetch(url, {
      redirect: 'follow',
      headers: {
        'user-agent': 'hyper-desk/0.1 (+https://pi.dev)'
      }
    })

    if (!response.ok) {
      throw new Error(`Fetch failed ${response.status} ${response.statusText}`)
    }

    const contentType = response.headers.get('content-type') || ''
    const body = await response.text()
    const text = contentType.includes('text/html') ? this.htmlToText(body) : body
    return text.slice(0, limit)
  }

  htmlToText(html) {
    return String(html)
      .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
      .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
      .replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, ' ')
      .replace(/<\/(p|div|section|article|header|footer|main|li|h[1-6]|tr)>/gi, '\n')
      .replace(/<br\s*\/?>/gi, '\n')
      .replace(/<[^>]+>/g, ' ')
      .replace(/&nbsp;/g, ' ')
      .replace(/&amp;/g, '&')
      .replace(/&lt;/g, '<')
      .replace(/&gt;/g, '>')
      .replace(/&quot;/g, '"')
      .replace(/&#39;/g, "'")
      .replace(/[ \t]+/g, ' ')
      .replace(/\n\s*\n\s*\n+/g, '\n\n')
      .trim()
  }

  createWorkbenchApprovalExtension() {
    return pi => {
      pi.on('tool_call', async event => {
        if (!this.requiresExtensionApproval(event?.toolName)) return undefined
        const approved = await this.requestApproval(
          event?.id || `${event?.toolName}-${Date.now()}`,
          event?.toolName,
          this.extensionApprovalSummary(event?.toolName, event?.input)
        )
        if (approved) return undefined
        return { block: true, reason: `User rejected ${event?.toolName}.` }
      })
    }
  }

  requiresExtensionApproval(toolName) {
    return ['subagent', 'zenbin_identity'].includes(String(toolName || ''))
  }

  extensionApprovalSummary(toolName, input) {
    const preview = input ? JSON.stringify(input).slice(0, 1000) : ''
    return `${toolName}${preview ? `: ${preview}` : ''}`
  }

  extensionToolNames() {
    return ['subagent', 'zenbin_identity']
  }

  chooseModel(models, settingsManager = undefined, preferredModelName = '') {
    const preferred = String(preferredModelName || '')
    if (preferred && preferred !== 'No model yet') {
      const configured = models.find(model => this.formatModel(model) === preferred)
      if (configured) return configured
    }

    const defaultProvider = settingsManager?.getDefaultProvider?.()
    const defaultModel = settingsManager?.getDefaultModel?.()
    if (defaultProvider && defaultModel) {
      const configured = models.find(model => model.provider === defaultProvider && (model.id || model.modelId || model.name) === defaultModel)
      if (configured) return configured
    }

    return models.find(model => this.formatModel(model) === 'openai-codex/gpt-5.5')
      || models.find(model => this.formatModel(model).includes('openai-codex/gpt-5.5'))
      || models.find(model => this.formatModel(model).startsWith('openai-codex/'))
      || models.find(model => this.formatModel(model).includes('gpt-5.5'))
      || models.find(model => this.formatModel(model).includes('sonnet') && !this.isDeprecatedModel(model))
      || models.find(model => !this.isDeprecatedModel(model))
      || models[0]
  }

  isDeprecatedModel(model) {
    return this.formatModel(model).includes('claude-3-5-haiku-20241022')
  }

  formatModel(model) {
    const provider = model?.provider || 'unknown'
    const id = model?.id || model?.modelId || model?.name || 'unknown'
    return `${provider}/${id}`
  }

  formatUserError(error) {
    const message = error?.message || String(error)
    if (/spawn npm ENOENT/.test(message)) {
      return `${message}\n\nHyperDesk could not find npm from the app environment. Install Node/npm or launch HyperDesk after opening a terminal, then try again.`
    }
    return message
  }

  async openLogin(provider = 'any') {
    const providerHint = provider && provider !== 'any' ? ` for ${provider}` : ''
    const command = `echo 'Starting Pi login${providerHint}. Follow the prompts, then return to HyperDesk and refresh models.'; pi /login`

    try {
      if (process.platform === 'darwin') {
        spawn('osascript', [
          '-e',
          `tell application "Terminal" to do script ${JSON.stringify(command)}`,
          '-e',
          'tell application "Terminal" to activate'
        ], { detached: true, stdio: 'ignore' }).unref()
      } else {
        spawn('sh', ['-lc', command], { detached: true, stdio: 'ignore' }).unref()
      }

      this.sendToUi('pi:models', {
        provider,
        summary: `Opened Pi login${providerHint}. Complete login in the terminal, then click Configure Pi again or refresh models.`
      })
    } catch (error) {
      this.sendToUi('pi:models', {
        provider,
        summary: `Could not open Pi login: ${error?.message || String(error)}\nRun \`pi /login\` manually, then refresh models.`
      })
    }
  }

  async listModels(provider = 'any') {
    try {
      const { AuthStorage, getAgentDir, ModelRegistry } = await this.loadSdk()
      const agentDir = getAgentDir()
      const authStorage = AuthStorage.create(path.join(agentDir, 'auth.json'))
      const modelRegistry = ModelRegistry.create(authStorage, path.join(agentDir, 'models.json'))
      const available = await modelRegistry.getAvailable()
      const normalizedProvider = String(provider || 'any').toLowerCase()
      const filtered = normalizedProvider === 'any'
        ? available
        : available.filter(model => String(model.provider || '').toLowerCase() === normalizedProvider)

      const summary = filtered.length === 0
        ? `No available ${provider} models found in ${agentDir}. If terminal Pi is logged in, check that provider/model is enabled there, then refresh models.`
        : `Found ${filtered.length} available model${filtered.length === 1 ? '' : 's'}:\n` + filtered
          .slice(0, 12)
          .map(model => `• ${this.formatModel(model)}`)
          .join('\n')

      this.sendToUi('pi:models', { provider, summary })
    } catch (error) {
      this.sendToUi('pi:models', {
        provider,
        summary: `Could not load Pi models: ${error?.message || String(error)}`
      })
    }
  }

  async sendMessage(threadId, cwd, message, preferredModelName = '') {
    try {
      const session = await this.getSession(threadId, cwd, preferredModelName)
      this.threadHadDelta.set(threadId, false)

      if (session.isStreaming) {
        await session.followUp(message)
      } else {
        await session.prompt(message)
      }
    } catch (error) {
      this.sendToUi('pi:error', { threadId, message: this.formatUserError(error) })
    }
  }

  async abort(threadId) {
    try {
      const session = this.sessions.get(threadId)
      if (session) await session.abort()
      this.sendToUi('pi:error', { threadId, message: 'Stopped by user.' })
    } catch (error) {
      this.sendToUi('pi:error', { threadId, message: error?.message || String(error) })
    }
  }

  handleEvent(threadId, event) {
    if (event.type === 'message_update') {
      const update = event.assistantMessageEvent
      if (update?.type === 'text_delta' && update.delta) {
        this.threadHadDelta.set(threadId, true)
        this.sendToUi('pi:delta', { threadId, text: update.delta })
      }
    }

    if (event.type === 'tool_execution_start') {
      this.sendToUi('pi:tool-event', {
        threadId,
        kind: 'start',
        text: `${event.toolName}`
      })
    }

    if (event.type === 'tool_execution_end') {
      this.sendToUi('pi:tool-event', {
        threadId,
        kind: event.isError ? 'error' : 'end',
        text: `${event.toolName}`
      })
    }

    if (event.type === 'message_end') {
      const role = event.message?.role
      const stopReason = event.message?.stopReason

      if ((!role || role === 'assistant') && stopReason !== 'toolUse') {
        if (!this.threadHadDelta.get(threadId)) {
          const text = this.extractAssistantText(event.message)
          if (text) {
            this.threadHadDelta.set(threadId, true)
            this.sendToUi('pi:delta', { threadId, text })
          }
        }
      }

      this.sendContextStats(threadId)
    }

    if (event.type === 'agent_end') {
      this.sendContextStats(threadId)
      this.sendToUi('pi:done', { threadId })
    }
  }

  sendContextStats(threadId, suppliedSession = undefined) {
    try {
      const session = suppliedSession || this.sessions.get(threadId)
      if (!session) return

      const totals = this.calculateTokenTotals(session)
      const contextUsage = session.getContextUsage?.()
      const model = session.state?.model
      const contextMax = Number(contextUsage?.contextWindow ?? model?.contextWindow ?? 0) || 0
      const contextUsed = Number(contextUsage?.tokens ?? totals.inputTokens + totals.outputTokens) || 0
      const rawPercent = contextUsage?.percent ?? (contextMax > 0 ? (contextUsed / contextMax) * 100 : 0)
      const contextPercent = Math.max(0, Math.min(100, Math.round(Number(rawPercent) || 0)))

      this.sendToUi('pi:context-stats', {
        threadId,
        modelName: model ? this.formatModel(model) : 'No model yet',
        inputTokens: totals.inputTokens,
        outputTokens: totals.outputTokens,
        contextUsed,
        contextMax,
        contextPercent
      })
    } catch (_error) {
      // Token stats are auxiliary UI; never interrupt the agent because they failed.
    }
  }

  calculateTokenTotals(session) {
    let inputTokens = 0
    let outputTokens = 0

    for (const entry of session.sessionManager?.getEntries?.() || []) {
      if (entry?.type !== 'message' || entry?.message?.role !== 'assistant') continue
      const usage = entry.message.usage || {}
      inputTokens += Number(usage.input || 0)
      outputTokens += Number(usage.output || 0)
    }

    return { inputTokens, outputTokens }
  }

  extractAssistantText(message) {
    if (!message) return ''
    if (message.errorMessage) return message.errorMessage
    if (!Array.isArray(message.content)) return ''

    return message.content
      .filter(block => block?.type === 'text' && typeof block.text === 'string')
      .map(block => block.text)
      .join('')
  }
}

module.exports = { PiAdapter }
