import { spawn, type ChildProcess } from 'child_process'
import type { WebContents } from 'electron'

const EXTRA_PATH_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/opt/local/bin']
const ENV_WITH_DDEV_PATH = {
  ...process.env,
  PATH: [...EXTRA_PATH_DIRS, process.env.PATH].join(':')
}

const running = new Map<string, ChildProcess>()
const cancelledIds = new Set<string>()

// ddev colorizes its output unconditionally, regardless of TTY-ness, so a
// GUI panel rendering raw stdout/stderr ends up showing literal escape
// codes instead of color. Strip them before they ever reach the renderer.
const ANSI_PATTERN = new RegExp(
  // eslint-disable-next-line no-control-regex -- intentional: matches ESC/BEL bytes in ddev's colorized output
  '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))',
  'g'
)

function stripAnsi(text: string): string {
  return text.replace(ANSI_PATTERN, '')
}

export class CommandFailedError extends Error {
  constructor(
    message: string,
    public readonly exitCode: number | null
  ) {
    super(message)
    this.name = 'CommandFailedError'
  }
}

export class CommandCancelledError extends Error {
  constructor() {
    super('Command was cancelled')
    this.name = 'CommandCancelledError'
  }
}

// Spawns `ddev <args>` and streams stdout/stderr to the renderer as
// terminal:data events keyed by operationId, so a terminal panel can show
// live progress instead of waiting for the whole command to finish.
export function runStreamed(
  operationId: string,
  args: string[],
  sender: WebContents,
  options: { cwd?: string } = {}
): Promise<void> {
  return new Promise((resolve, reject) => {
    // stdin must be closed, not just unused — an open-but-silent pipe
    // leaves ddev blocked forever on any prompt it tries to read (e.g. the
    // first-run telemetry opt-in), since a GUI-spawned child has no
    // terminal to answer it. A closed stdin gets an immediate EOF instead,
    // which ddev treats as "use the default" rather than hanging.
    const child = spawn('ddev', args, {
      env: ENV_WITH_DDEV_PATH,
      cwd: options.cwd,
      stdio: ['ignore', 'pipe', 'pipe']
    })
    running.set(operationId, child)

    const tail: string[] = []
    const trackTail = (chunk: string): void => {
      tail.push(chunk)
      if (tail.length > 20) tail.shift()
    }

    child.stdout.on('data', (data: Buffer) => {
      const chunk = stripAnsi(data.toString())
      trackTail(chunk)
      if (!sender.isDestroyed()) {
        sender.send('terminal:data', { operationId, stream: 'stdout', chunk })
      }
    })

    child.stderr.on('data', (data: Buffer) => {
      const chunk = stripAnsi(data.toString())
      trackTail(chunk)
      if (!sender.isDestroyed()) {
        sender.send('terminal:data', { operationId, stream: 'stderr', chunk })
      }
    })

    child.on('error', (err) => {
      running.delete(operationId)
      cancelledIds.delete(operationId)
      if (!sender.isDestroyed()) {
        sender.send('terminal:exit', { operationId, exitCode: null, cancelled: false })
      }
      reject(new CommandFailedError(err.message, null))
    })

    child.on('close', (code) => {
      running.delete(operationId)
      const wasCancelled = cancelledIds.delete(operationId)
      if (!sender.isDestroyed()) {
        sender.send('terminal:exit', { operationId, exitCode: code, cancelled: wasCancelled })
      }
      if (wasCancelled) {
        reject(new CommandCancelledError())
      } else if (code === 0) {
        resolve()
      } else {
        reject(
          new CommandFailedError(tail.join('').trim() || `ddev exited with code ${code}`, code)
        )
      }
    })
  })
}

export function cancelCommand(operationId: string): boolean {
  const child = running.get(operationId)
  if (!child) return false
  cancelledIds.add(operationId)
  child.kill()
  return true
}

// Log tailing (`ddev logs -f`) runs indefinitely rather than completing, so
// it doesn't fit runStreamed's resolve/reject-on-exit model or the terminal
// panel's operation semantics (no "success"/"failure" toast makes sense for
// an open-ended stream). It shares the same running/cancel bookkeeping —
// stopping a log stream reuses cancelCommand — but pushes chunks over a
// dedicated logs:data channel instead of terminal:data.
export function startLogStream(operationId: string, args: string[], sender: WebContents): void {
  const child = spawn('ddev', args, { env: ENV_WITH_DDEV_PATH, stdio: ['ignore', 'pipe', 'pipe'] })
  running.set(operationId, child)

  const forward = (stream: 'stdout' | 'stderr') => (data: Buffer) => {
    if (!sender.isDestroyed()) {
      sender.send('logs:data', { operationId, stream, chunk: stripAnsi(data.toString()) })
    }
  }
  child.stdout.on('data', forward('stdout'))
  child.stderr.on('data', forward('stderr'))

  const finish = (): void => {
    running.delete(operationId)
    cancelledIds.delete(operationId)
    if (!sender.isDestroyed()) {
      sender.send('logs:exit', { operationId })
    }
  }
  child.on('close', finish)
  child.on('error', finish)
}

export function killAllRunningCommands(): void {
  for (const child of running.values()) {
    child.kill()
  }
  running.clear()
}
