import { createReadStream } from 'fs'
import type { SFTPWrapper, ClientChannel } from 'ssh2'
import type { SshConnection } from './ssh-connection'
import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from './relay-protocol'
import type { MultiplexerTransport } from './ssh-channel-multiplexer'

// ── SFTP upload helpers ───────────────────────────────────────────────

export async function uploadDirectory(
  sftp: SFTPWrapper,
  localDir: string,
  remoteDir: string
): Promise<void> {
  const { readdirSync, statSync } = await import('fs')
  const { join: pathJoin } = await import('path')

  const entries = readdirSync(localDir)
  for (const entry of entries) {
    const localPath = pathJoin(localDir, entry)
    const remotePath = `${remoteDir}/${entry}`
    const stat = statSync(localPath)

    if (stat.isDirectory()) {
      await mkdirSftp(sftp, remotePath)
      await uploadDirectory(sftp, localPath, remotePath)
    } else {
      await uploadFile(sftp, localPath, remotePath)
    }
  }
}

export function mkdirSftp(sftp: SFTPWrapper, path: string): Promise<void> {
  return new Promise((resolve, reject) => {
    sftp.mkdir(path, (err) => {
      // Ignore "already exists" errors (SFTP status code 4 = SSH_FX_FAILURE)
      if (err && (err as { code?: number }).code !== 4) {
        reject(err)
      } else {
        resolve()
      }
    })
  })
}

export function uploadFile(
  sftp: SFTPWrapper,
  localPath: string,
  remotePath: string
): Promise<void> {
  return new Promise((resolve, reject) => {
    const readStream = createReadStream(localPath)
    const writeStream = sftp.createWriteStream(remotePath)

    writeStream.on('close', resolve)
    writeStream.on('error', reject)
    readStream.on('error', reject)

    readStream.pipe(writeStream)
  })
}

// ── Sentinel detection ────────────────────────────────────────────────

export function waitForSentinel(channel: ClientChannel): Promise<MultiplexerTransport> {
  return new Promise<MultiplexerTransport>((resolve, reject) => {
    let sentinelReceived = false
    let stderrOutput = ''
    let bufferedStdout = Buffer.alloc(0)

    const timeout = setTimeout(() => {
      if (!sentinelReceived) {
        channel.close()
        reject(
          new Error(
            `Relay failed to start within ${RELAY_SENTINEL_TIMEOUT_MS / 1000}s.${stderrOutput ? ` stderr: ${stderrOutput.trim()}` : ''}`
          )
        )
      }
    }, RELAY_SENTINEL_TIMEOUT_MS)

    const MAX_BUFFER_CAP = 64 * 1024
    channel.stderr.on('data', (data: Buffer) => {
      stderrOutput += data.toString('utf-8')
      if (stderrOutput.length > MAX_BUFFER_CAP) {
        stderrOutput = stderrOutput.slice(-MAX_BUFFER_CAP)
      }
    })

    channel.on('close', () => {
      if (!sentinelReceived) {
        clearTimeout(timeout)
        reject(
          new Error(
            `Relay process exited before ready.${stderrOutput ? ` stderr: ${stderrOutput.trim()}` : ''}`
          )
        )
      }
    })

    const dataCallbacks: ((data: Buffer) => void)[] = []
    const closeCallbacks: (() => void)[] = []

    channel.on('data', (data: Buffer) => {
      if (sentinelReceived) {
        for (const cb of dataCallbacks) {
          cb(data)
        }
        return
      }

      bufferedStdout = Buffer.concat([bufferedStdout, data])
      const text = bufferedStdout.toString('utf-8')
      const sentinelIdx = text.indexOf(RELAY_SENTINEL)

      if (sentinelIdx !== -1) {
        sentinelReceived = true
        clearTimeout(timeout)

        const afterSentinel = bufferedStdout.subarray(
          Buffer.byteLength(text.substring(0, sentinelIdx + RELAY_SENTINEL.length), 'utf-8')
        )

        const transport: MultiplexerTransport = {
          write: (buf: Buffer) => channel.stdin.write(buf),
          onData: (cb) => {
            dataCallbacks.push(cb)
          },
          onClose: (cb) => {
            closeCallbacks.push(cb)
          }
        }

        channel.on('close', () => {
          for (const cb of closeCallbacks) {
            cb()
          }
        })

        resolve(transport)

        if (afterSentinel.length > 0) {
          for (const cb of dataCallbacks) {
            cb(afterSentinel)
          }
        }
      }
    })
  })
}

// ── Remote command execution ──────────────────────────────────────────

const EXEC_TIMEOUT_MS = 30_000

export async function execCommand(conn: SshConnection, command: string): Promise<string> {
  const channel = await conn.exec(command)
  return new Promise((resolve, reject) => {
    let stdout = ''
    let stderr = ''
    let settled = false

    const timeout = setTimeout(() => {
      if (!settled) {
        settled = true
        channel.close()
        reject(new Error(`Command "${command}" timed out after ${EXEC_TIMEOUT_MS / 1000}s`))
      }
    }, EXEC_TIMEOUT_MS)

    channel.on('data', (data: Buffer) => {
      stdout += data.toString('utf-8')
    })
    channel.stderr.on('data', (data: Buffer) => {
      stderr += data.toString('utf-8')
    })
    channel.on('close', (code: number) => {
      if (settled) {
        return
      }
      settled = true
      clearTimeout(timeout)
      if (code !== 0) {
        reject(new Error(`Command "${command}" failed (exit ${code}): ${stderr.trim()}`))
      } else {
        resolve(stdout)
      }
    })
  })
}

// ── Remote Node.js resolution ─────────────────────────────────────────

// Why: non-login SSH shells (the default for `exec`) don't source
// .bashrc/.zshrc, so node installed via nvm/fnm/Homebrew isn't in PATH.
// We try common locations and fall back to a login-shell `which`.
export async function resolveRemoteNodePath(conn: SshConnection): Promise<string> {
  // Why: non-login SSH exec channels don't source .bashrc/.zshrc, so node
  // installed via nvm/fnm/Homebrew may not be in PATH. We probe common
  // locations directly, then fall back to sourcing the profile explicitly.
  // The glob in $HOME/.nvm/... is expanded by the shell, not by `command -v`.
  const script = [
    'command -v node 2>/dev/null',
    'command -v /usr/local/bin/node 2>/dev/null',
    'command -v /opt/homebrew/bin/node 2>/dev/null',
    // Why: nvm installs into a versioned directory. `ls -1` sorts
    // alphabetically, which misorders versions (e.g. v9 > v18). Pipe
    // through `sort -V` (version sort) so we pick the highest version.
    'ls -1 $HOME/.nvm/versions/node/*/bin/node 2>/dev/null | sort -V | tail -1',
    'command -v $HOME/.local/bin/node 2>/dev/null',
    'command -v $HOME/.fnm/aliases/default/bin/node 2>/dev/null'
  ].join(' || ')

  try {
    const result = await execCommand(conn, script)
    const nodePath = result.trim().split('\n')[0]
    if (nodePath) {
      console.log(`[ssh-relay] Found node at: ${nodePath}`)
      return nodePath
    }
  } catch {
    // Fall through to login shell attempt
  }

  // Why: last resort — source the full login profile. This is separated into
  // its own exec because `bash -lc` can hang on remotes with interactive
  // shell configs (conda prompts, etc.). If this times out, the error message
  // from execCommand will tell us it was the login shell attempt.
  try {
    console.log('[ssh-relay] Trying login shell to find node...')
    const result = await execCommand(conn, "bash -lc 'command -v node' 2>/dev/null")
    const nodePath = result.trim().split('\n')[0]
    if (nodePath) {
      console.log(`[ssh-relay] Found node via login shell: ${nodePath}`)
      return nodePath
    }
  } catch {
    // Fall through
  }

  throw new Error(
    'Node.js not found on remote host. Orca relay requires Node.js 18+. ' +
      'Install Node.js on the remote and try again.'
  )
}
