import {
  d,
  reduce,
  createMachine,
  state,
  transition,
  interpret,
  action,
  invoke,
  type Service,
} from "robot3"
import { Baresip } from "./sip"
import log from "./utils/log"
import { sleep } from "bun"
import Buzz from "./buzz"
import { join } from "path"
import GPIO from "./pins"
import { Agent } from "./agent"
import { searchWeb } from "./agent/tools"
import { ring } from "./utils"
import { getSound, WaitingSounds } from "./utils/waiting-sounds"

type CancelableTask = () => void

type PhoneContext = {
  lastError?: string
  peer?: string
  numberDialed: number
  cancelRinger?: CancelableTask
  baresip: Baresip
  stopAgent?: CancelableTask
  ringer: GPIO.Output
  agentId: string
  agentKey: string
  dialFailureReason?: string
}

type PhoneService = Service<typeof phoneMachine>

const player = await Buzz.player()
let dialTonePlayback: Buzz.Playback | undefined

export const runPhone = async (agentId: string, agentKey: string) => {
  const gpio = new GPIO()
  using ringer = gpio.output(17, { resetOnClose: true })
  using hook = gpio.input(27, { pull: "up", debounce: 3 })
  using rotaryInUse = gpio.input(22, { pull: "up", debounce: 3 })
  using rotaryNumber = gpio.input(23, { pull: "up", debounce: 3 })

  await Buzz.setVolume(0.3)
  log(`📞 Phone is ${hook.value ? "off hook" : "on hook"}`)

  playStartRing(ringer)

  const phoneService = interpret(phoneMachine, () => {})
  listenForPhoneEvents(phoneService, hook, rotaryInUse, rotaryNumber)
  const baresip = await startBaresip(phoneService, hook, ringer)
  phoneService.send({ type: "config", baresip, agentId, agentKey, ringer })

  process.on("SIGINT", () => cleanup(baresip, ringer))
  process.on("SIGTERM", () => cleanup(baresip, ringer))

  // Keep process running
  await new Promise(() => {})
}

const listenForPhoneEvents = (
  phoneService: PhoneService,
  hook: GPIO.Input,
  rotaryInUse: GPIO.Input,
  rotaryNumber: GPIO.Input,
) => {
  hook.onChange((event) => {
    const type = event.value == 0 ? "hang-up" : "pick-up"
    log(`📞 Hook ${event.value} sending ${type}`)
    phoneService.send({ type })
  })

  rotaryInUse.onChange((event) => {
    if (event.value === 0) {
      phoneService.send({ type: "dial-start" } as any)
    } else {
      phoneService.send({ type: "dial-stop" })
    }
  })

  rotaryNumber.onChange((event) => {
    if (event.value === 1) {
      phoneService.send({ type: "digit_increment" })
    }
  })
}

const startBaresip = async (phoneService: PhoneService, hook: GPIO.Input, ringer: GPIO.Output) => {
  const baresipConfig = join(import.meta.dir, "..", "baresip")
  const baresip = new Baresip(["/usr/bin/baresip", "-v", "-f", baresipConfig])

  baresip.registrationSuccess.on(async () => {
    log("🐻 server connected")
    if (hook.value === 0) {
      phoneService.send({ type: "initialized" })
    } else {
      phoneService.send({ type: "pick-up" })
    }
  })

  baresip.callReceived.on(({ contact }) => {
    log(`🐻 incoming call from ${contact}`)
    phoneService.send({ type: "incoming-call", from: contact })
  })

  baresip.callEstablished.on(({ contact }) => {
    log(`🐻 call established with ${contact}`)
    phoneService.send({ type: "answered" } as any)
  })

  baresip.hungUp.on(() => {
    log("🐻 call hung up")
    phoneService.send({ type: "remote-hang-up" })
  })

  baresip.dialFailed.on(({ reason }) => {
    log.error("🐻 dial failed:", reason)
    phoneService.send({ type: "dial-failed", reason } as any)
  })

  baresip.connect().catch((error) => {
    log.error("🐻 connection error:", error)
    phoneService.send({ type: "error", message: error.message })
  })

  baresip.error.on(async ({ message, statusCode, reason }) => {
    const errorMessage = statusCode ? `Registration failed: ${statusCode} ${reason}` : message

    log.error("🐻 error:", errorMessage)
    // Don't send error to state machine - we're retrying, not giving up

    log("🔄 Retrying registration in 2 minutes...")
    await sleep(2 * 60 * 1000)
    baresip.restart()
  })

  return baresip
}

const cleanup = (baresip: Baresip, ringer: GPIO.Output) => {
  try {
    log("🛑 Shutting down, stopping agent process")
    playExitRing(ringer)
    baresip.kill()
  } catch (error) {
    log.error("Error during shutdown:", error)
  } finally {
    process.exit(0)
  }
}

const handleError = (ctx: PhoneContext, event: { type: "error"; message?: string }) => {
  ctx.lastError = event.message
  log.error(`Phone Error: ${event.message}`)
  return ctx
}

const config = (
  ctx: PhoneContext,
  event: { baresip: Baresip; agentId: string; agentKey: string; ringer: GPIO.Output },
) => {
  ctx.baresip = event.baresip
  ctx.agentId = event.agentId
  ctx.agentKey = event.agentKey
  ctx.ringer = event.ringer
  return ctx
}

const startAgent = async (service: Service<typeof phoneMachine>, ctx: PhoneContext, hasDialFailure = false) => {
  let streamPlayback = player.playStream()

  const agent = new Agent({
    agentId: ctx.agentId,
    apiKey: ctx.agentKey,
    tools: {
      search_web: (args: { query: string }) => searchWeb(args.query),
    },
  })

  handleAgentEvents(service, agent, streamPlayback)

  const stopListening = hasDialFailure
    ? await startListeningAfterDialFailure(agent, ctx.dialFailureReason)
    : startListening(service, agent)

  ctx.stopAgent = () => {
    stopListening()
    dialTonePlayback?.stop()
    streamPlayback.stop()
  }

  return ctx
}

function startListening(service: Service<typeof phoneMachine>, agent: Agent) {
  const abortAgent = new AbortController()

  new Promise<void>(async (resolve) => {
    const recorder = await Buzz.recorder()
    const listenPlayback = recorder.start()
    let backgroundNoisePlayback: Buzz.Playback | undefined
    let waitingForVoice = true
    const maxPreBufferChunks = 4 // Keep ~1 second of audio before speech detection

    let preConnectionBuffer: Uint8Array[] = []

    agent.events.on(async (event) => {
      if (event.type === "disconnected") abortAgent.abort()
    })

    for await (const chunk of listenPlayback.stream()) {
      if (abortAgent.signal.aborted) {
        agent.stop()
        listenPlayback.stop()
        backgroundNoisePlayback?.stop()

        resolve()
        break
      }

      if (waitingForVoice) {
        preConnectionBuffer.push(chunk)
        if (preConnectionBuffer.length > maxPreBufferChunks) {
          preConnectionBuffer.shift()
        }

        const rms = Buzz.calculateRMS(chunk)
        if (rms > 5000) {
          dialTonePlayback?.stop()
          service.send({ type: "start-agent" } as any)
          waitingForVoice = false

          backgroundNoisePlayback = await player.play(getSound("background"), { repeat: true })

          await agent.start()

          // Send pre-buffered audio
          for (const chunk of preConnectionBuffer) agent.sendAudio(chunk)
          preConnectionBuffer = []
        }
      } else {
        agent.sendAudio(chunk)
      }
    }
  })

  return () => abortAgent.abort()
}

async function startListeningAfterDialFailure(agent: Agent, dialFailureReason?: string) {
  const abortAgent = new AbortController()
  const recorder = await Buzz.recorder()
  const listenPlayback = recorder.start()

  const message = getFriendlyErrorMessage(dialFailureReason)
  agent.events.on((event) => {
    if (event.type === "connected") agent.sendMessage(`[SYSTEM: ${message}]`)
    if (event.type === "disconnected") abortAgent.abort()
  })

  const backgroundNoisePlayback = await player.play(getSound("background"), { repeat: true })
  await agent.start()

  streamAudioToAgent(agent, listenPlayback, backgroundNoisePlayback, abortAgent.signal)

  return () => abortAgent.abort()
}

async function streamAudioToAgent(
  agent: Agent,
  listenPlayback: Buzz.StreamingRecording,
  backgroundNoisePlayback: Buzz.Playback | undefined,
  signal: AbortSignal,
) {
  for await (const chunk of listenPlayback.stream()) {
    if (signal.aborted) {
      agent.stop()
      listenPlayback.stop()
      backgroundNoisePlayback?.stop()
      break
    }
    agent.sendAudio(chunk)
  }
}

const handleAgentEvents = (
  service: Service<typeof phoneMachine>,
  agent: Agent,
  streamPlayback: Buzz.StreamingPlayback,
) => {
  const waitingIndicator = new WaitingSounds(player)

  agent.events.on(async (event) => {
    switch (event.type) {
      case "connected":
        log("🤖 Connected to AI agent\n")
        break

      case "user_transcript":
        log(`🤖 You: ${event.transcript}`)
        break

      case "agent_response":
        log(`🤖 Agent: ${event.response}`)
        break

      case "audio":
        await waitingIndicator.stop()
        const audioBuffer = Buffer.from(event.audioBase64, "base64")
        streamPlayback.write(audioBuffer)
        break

      case "interruption":
        log("🤖 User interrupted")
        await waitingIndicator.stop()
        streamPlayback?.stop()
        streamPlayback = player.playStream() // Reset playback stream
        break

      case "tool_call":
        waitingIndicator.start(streamPlayback)
        log(`🤖 Tool call: ${event.name}(${JSON.stringify(event.args)})`)
        break

      case "tool_result":
        log(`🤖 Tool result: ${JSON.stringify(event.result)}`)
        break

      case "tool_error":
        console.error(`❌ Tool error: ${event.error}`)
        break

      case "disconnected":
        log(`🤖 👋 Conversation ended, returning to dialtone`)
        streamPlayback?.stop()
        service.send({ type: "remote-hang-up" })
        break

      case "error":
        log.error("🤖 Agent error:", event.error)
        break

      case "ping":
        break

      default:
        log.debug(`😵 Unknown agent event ${event.type}`)
        break
    }
  })
}

const stopAgent = (ctx: PhoneContext) => {
  ctx.stopAgent?.()
  ctx.stopAgent = undefined
  return ctx
}

const incomingCall = (ctx: PhoneContext, event: { type: "incoming-call"; from?: string }) => {
  ctx.peer = event.from
  return ctx
}

const hangUp = (ctx: PhoneContext) => {
  console.log(`📞 Hanging up call`)
  ctx.baresip.hangUp()
}

const answerCall = (ctx: PhoneContext) => {
  log(`📞 Answering call`)
  ctx.baresip.accept()
}

const storeDialFailure = (ctx: PhoneContext, event: { reason?: string }) => {
  ctx.dialFailureReason = event.reason
  return ctx
}

const clearDialFailure = (ctx: PhoneContext) => {
  ctx.dialFailureReason = undefined
  return ctx
}

function getFriendlyErrorMessage(rawReason?: string): string {
  if (rawReason?.includes("Not registered")) {
    return "The user's call failed. To fix it, they should contact Corey IRL."
  }

  return "The user's call failed. Twilio isn't working. To fix it, they should contact Corey IRL."
}

const makeCall = async (ctx: PhoneContext) => {
  log(`Dialing number: ${ctx.numberDialed}`)
  if (ctx.numberDialed === 1) {
    ctx.baresip.dial("+13476229543")
  } else if (ctx.numberDialed === 2) {
    ctx.baresip.dial("+18109643563")
  } else {
    log.error(`No contact for number dialed: ${ctx.numberDialed}`)
  }

  return ctx
}

const startRinger = async (ctx: PhoneContext) => {
  let abortController = new AbortController()
  const keepRinging = async () => {
    while (!abortController.signal.aborted) {
      await ring(ctx.ringer, 2000, abortController.signal)
      await sleep(4000)
    }
  }
  keepRinging().catch((error) => log.error("Ringer error:", error))

  ctx.cancelRinger = () => abortController.abort()

  return ctx
}

const stopRinger = (ctx: PhoneContext) => {
  ctx.cancelRinger?.()
  ctx.cancelRinger = undefined
  return ctx
}

async function startDialToneAndAgent(this: any, ctx: PhoneContext) {
  const hasDialFailure = !!ctx.dialFailureReason
  ctx = await startAgent(this, ctx, hasDialFailure)

  if (!hasDialFailure) {
    await dialTonePlayback?.stop()
    dialTonePlayback = await player.playTone([350, 440], Infinity)
  }

  return ctx
}

const stopDialTone = () => {
  dialTonePlayback?.stop()
}

const dialStart = (ctx: PhoneContext) => {
  ctx.numberDialed = 0
  return ctx
}

const digitIncrement = (ctx: PhoneContext) => {
  ctx.numberDialed += 1
  return ctx
}

const playStartRing = async (ringer: GPIO.Output) => {
  // Three quick beeps, getting faster = energetic/welcoming
  ringer.value = 1
  await Bun.sleep(80)
  ringer.value = 0
  await Bun.sleep(120)

  ringer.value = 1
  await Bun.sleep(80)
  ringer.value = 0
  await Bun.sleep(100)

  ringer.value = 1
  await Bun.sleep(80)
  ringer.value = 0
}

const playExitRing = async (ringer: GPIO.Output) => {
  ringer.value = 0 // Always try and turn it off!
}

const t = transition
const r = reduce
const a = action

const phoneMachine = createMachine(
  "booting",
  // prettier-ignore
  {
      booting: state(
        t("config", "initializing", r(config))
      ), 
      initializing: state(
        t("initialized", "idle"),
        t("pick-up", "ready"),
        t("error", "fault", r(handleError))),
      idle: state(
        t("incoming-call", "incoming", r(incomingCall)),
        t("pick-up", "ready")),
      incoming: invoke(startRinger,
        t("remote-hang-up", "idle", r(stopRinger)),
        t("pick-up", "connected", r(stopRinger), a(answerCall))),
      connected: state(
        t("remote-hang-up", "ready"),
        t("hang-up", "idle", a(hangUp))),
      ready: invoke(startDialToneAndAgent,
        t("dial-start", "dialing", a(stopDialTone), r(dialStart), r(clearDialFailure), a(stopAgent)),
        t("hang-up", "idle", a(stopDialTone), r(clearDialFailure), a(stopAgent)),
        t("start-agent", "connectToAgent", a(stopDialTone), r(clearDialFailure))),
      connectToAgent: state(
        t("hang-up", "idle", r(stopAgent), r(clearDialFailure)),
        t("remote-hang-up", "ready", r(stopAgent), r(clearDialFailure))),
      dialing: state(
        t("dial-stop", "outgoing"),
        t("digit_increment", "dialing", r(digitIncrement)),
        t("hang-up", "idle")),          
      outgoing: invoke(makeCall,
        t("answered", "connected"),
        t("dial-failed", "ready", r(storeDialFailure)),
        t("hang-up", "idle", a(hangUp))),
      aborted: state(
        t("hang-up", "idle")),
      fault: state(),
    },
)

d._onEnter = function (machine, to, state, prevState, event) {
  log(`📱 ${machine.current} -> ${to} (${(event as any).type})`)
}
