import { parseCommandDispatch, parseSlashCommand } from '@hermes/shared/slash'

import type { GatewayClient } from '../gatewayClient.js'
import type { SlashExecResponse } from '../gatewayTypes.js'
import { rpcErrorMessage } from '../lib/rpc.js'
import { launchWidget } from '../sdk/host.js'
import { getWidgetApp } from '../sdk/registry.js'

import type { SlashHandler, SlashHandlerContext, SlashSubmission } from './interfaces.js'
import { scoreSlashMenuItem } from './slash/fuzzyScore.js'
import { findSlashCommand } from './slash/registry.js'
import type { SlashRunCtx } from './slash/types.js'
import { captureDestination, isCurrentDestination } from './submissionDestination.js'
import { getUiState } from './uiStore.js'
import {
  describeSlashExecError,
  isUnsupportedCommand,
  RPC_UNKNOWN_METHOD,
  shouldFallbackToDispatch
} from './userMessages.js'

/** Shared metrics count each user-typed command once, from the client: the gateway no longer
 *  counts slash.exec, so locally handled commands (/resume, /skin, overlays) land too.
 *  Fire-and-forget; the backend canonicalizes the raw name. */
export function reportSlashCommand(gw: GatewayClient, name: string, sid: null | string | undefined): void {
  if (name) {
    gw.request('shared_metrics.slash_command', { command: name, ...(sid ? { session_id: sid } : {}) }).catch(
      () => undefined
    )
  }
}

// Gateways that answered `command.observe` with unknown_method (older than this TUI): not asked again.
const observeUnsupported = new WeakSet<object>()

/** `SlashRunCtx.ran`: the shared gateway fires `pre_command` for the commands it runs (`slash.exec`,
 *  `command.dispatch`). A recognized command Ink runs itself is reported the way the classic CLI
 *  reports its own (`command.observe`, plain data, not awaited) once the handler says it ran: a read
 *  when it answers, a gateway mutation once accepted. A refusal never calls it, and neither does a
 *  forward, so no command fires twice. At most once per typed command; the gateway refuses a name
 *  it does not recognize. */
function observer(gw: GatewayClient, name: string, arg: string, sid: null | string): () => void {
  let sent = false

  return () => {
    if (sent || !gw?.isCanonical || !sid || observeUnsupported.has(gw)) {
      return
    }

    sent = true
    gw.request('command.observe', { arg, name, session_id: sid }).catch((e: unknown) => {
      if ((e as { code?: number })?.code === RPC_UNKNOWN_METHOD || /unknown_method/.test(rpcErrorMessage(e))) {
        observeUnsupported.add(gw)
      }
    })
  }
}

/** `typed` is false for programmatic calls (a picker re-issuing `/model <x>`) and for the
 *  backend's alias re-dispatch; prefix/alias expansion keeps it, so a typed `/hea` counts once
 *  as the /heartbeat it resolved to. */
export function createSlashHandler(ctx: SlashHandlerContext): SlashHandler {
  const { gw } = ctx.gateway
  const { catalog } = ctx.local
  const { page, send, sys } = ctx.transcript

  const handler = (cmd: string, submission?: SlashSubmission, typed = true): boolean => {
    const flight = ++ctx.slashFlightRef.current
    const ui = getUiState()
    const sid = ui.sid
    const destination = captureDestination()
    const parsed = parseSlashCommand(cmd)
    const argTail = parsed.arg ? ` ${parsed.arg}` : ''

    const countTyped = () => {
      if (typed) {
        reportSlashCommand(gw, parsed.name, sid)
      }
    }

    const stale = () => flight !== ctx.slashFlightRef.current || !isCurrentDestination(destination)

    const guarded =
      <T>(fn: (r: T) => void) =>
      (r: null | T): void => {
        if (!stale() && r) {
          fn(r)
        }
      }

    const guardedErr = (e: unknown) => {
      if (!stale()) {
        sys(isUnsupportedCommand(e) ? describeSlashExecError(parsed.name, e) : `error: ${rpcErrorMessage(e)}`)
      }
    }

    const runCtx: SlashRunCtx = { ...ctx, flight, guarded, guardedErr, sid, stale, ui }

    const found = findSlashCommand(parsed.name)

    if (found) {
      countTyped()
      // A programmatic call (a picker's re-issue, an alias re-dispatch) is not the user's command.
      found.run(parsed.arg, typed ? { ...runCtx, ran: observer(gw, parsed.name, parsed.arg, sid) } : runCtx, cmd)

      return true
    }

    // Registry-first fallback: widget apps registered AFTER the static
    // command table was built (user widgets from $HERMES_HOME/tui-widgets,
    // /widgets-reload) dispatch straight off the live registry.
    if (getWidgetApp(parsed.name)) {
      countTyped()
      const err = launchWidget(parsed.name, parsed.arg)

      if (err) {
        sys(err)
      }

      return true
    }

    if (catalog?.canon) {
      const needle = `/${parsed.name}`.toLowerCase()
      const exact = Object.entries(catalog.canon).find(([alias]) => alias.toLowerCase() === needle)?.[1]

      if (exact) {
        if (exact.toLowerCase() !== needle) {
          return handler(`${exact}${argTail}`, submission, typed)
        }
      } else {
        // Tiered name scoring (ported from grok-cli's slash menu): prefix
        // matches rank above substring matches, so `/hea` still resolves to
        // /heartbeat while `/beat` now finds it too instead of dead-ending.
        // Only the best tier survives — a substring hit never widens an
        // unambiguous prefix hit into an "ambiguous command" complaint.
        // Description tiers (score >= 3) are a completion-menu concern and
        // never auto-execute a command here.
        const scored = Object.entries(catalog.canon)
          .map(([alias, canon]) => ({ canon, score: scoreSlashMenuItem({ id: alias.slice(1) }, needle.slice(1)) }))
          .filter(entry => entry.score < 3)

        const best = Math.min(...scored.map(entry => entry.score))
        const matches = [...new Set(scored.filter(entry => entry.score === best).map(entry => entry.canon))]

        if (matches.length === 1 && matches[0]!.toLowerCase() !== needle) {
          return handler(`${matches[0]}${argTail}`, submission, typed)
        }

        if (matches.length > 1) {
          sys(`ambiguous command: ${matches.slice(0, 6).join(', ')}${matches.length > 6 ? ', …' : ''}`)

          return true
        }
      }
    }

    // Main's sidecar slash output: a long reply opens the pager, a short one is a system line.
    const showOutput = (text: string) => {
      const long = text.length > 180 || text.split('\n').filter(Boolean).length > 2

      long ? page(text, parsed.name[0]!.toUpperCase() + parsed.name.slice(1)) : sys(text)
    }

    const handleDispatch = (raw: unknown, fromExec = false): void => {
      const d = parseCommandDispatch(raw)

      if (!d) {
        return sys('error: invalid response: command.dispatch')
      }

      if (d.type === 'exec' || d.type === 'plugin') {
        // The shared owner answers its slash.exec reads as `{type: 'exec'}`; they page like
        // main's bare `{output}` did. command.dispatch output keeps its system line.
        return fromExec && d.output ? showOutput(d.output) : sys(d.output || '(no output)')
      }

      if (d.type === 'alias') {
        return void handler(`/${d.target}${argTail}`, submission, false)
      }

      // A skill/bundle dispatch's `message` is the expanded skill body —
      // model-facing scaffolding. `display` is the invocation the gateway
      // projected; the transcript shows that instead. An ordinary send has no
      // projection and goes through unchanged. No client-side fallback here:
      // the TUI spawns its gateway from this same checkout, so the two can't
      // version-skew (unlike the desktop, which can meet an older backend).
      const sendDispatch = (display: string | undefined, message: string) => {
        const shown = display?.trim()

        if (submission) {
          return send(message, true, shown || undefined, submission.expand, { attachments: submission.attachments })
        }

        return shown ? send(message, true, shown) : send(message)
      }

      if (d.type === 'skill') {
        return d.message?.trim()
          ? sendDispatch(d.display, d.message)
          : sys(`/${parsed.name}: skill payload missing message`)
      }

      if (d.type === 'send') {
        if (d.notice?.trim()) {
          sys(d.notice)
        }

        return d.message?.trim() ? sendDispatch(d.display, d.message) : sys(`/${parsed.name}: empty message`)
      }

      if (d.type === 'prefill') {
        // /undo returns prefill: drop the backed-up message text into
        // the composer so the user can edit and resubmit, instead of
        // submitting it immediately like 'send'.
        if (d.notice?.trim()) {
          sys(d.notice)
        }

        if (d.message) {
          ctx.composer.setInput(d.message)
        }
      }
    }

    countTyped()
    gw.request<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: sid })
      .then(r => {
        if (stale()) {
          return
        }

        if (parseCommandDispatch(r)) {
          return handleDispatch(r, true)
        }

        const body = r?.output || `/${parsed.name}: no output`

        showOutput(r?.warning ? `warning: ${r.warning}\n${body}` : body)
      })
      .catch((execErr: unknown) => {
        if (stale()) {
          return
        }

        // Only "slash.exec does not own this command" refusals (4011/4018) may
        // fall through to command.dispatch. A helper timeout/crash (5030) must
        // be shown as itself — the fallback's "not a quick/plugin/bundle/skill
        // command" refusal used to bury the real cause and imply the command
        // did not exist.
        if (isUnsupportedCommand(execErr)) {
          return sys(describeSlashExecError(parsed.name, execErr))
        }

        if (!shouldFallbackToDispatch(execErr)) {
          sys(`error: ${describeSlashExecError(parsed.name, execErr)}`)

          return
        }

        gw.request('command.dispatch', { arg: parsed.arg, name: parsed.name, session_id: sid })
          .then((raw: unknown) => {
            if (stale()) {
              return
            }

            handleDispatch(raw)
          })
          .catch(guardedErr)
      })

    return true
  }

  return handler
}
