/* eslint-disable import/order */
import './importsWorkaround'
import './styles.css'
import './globals'
import 'iconify-icon'
import './chat'
import './inventory'

import './menus/components/button'
import './menus/components/edit_box'
import './menus/components/slider'
import './menus/components/hotbar'
import './menus/components/health_bar'
import './menus/components/food_bar'
import './menus/components/breath_bar'
import './menus/components/debug_overlay'
import './menus/components/playerlist_overlay'
import './menus/components/bossbars_overlay'
import './menus/hud'
import './menus/play_screen'
import './menus/pause_screen'
import './menus/loading_or_error_screen'
import './menus/keybinds_screen'
import './menus/options_screen'
import './menus/advanced_options_screen'
import { notification } from './menus/notification'
import './menus/title_screen'
import { initWithRenderer, statsEnd, statsStart } from './rightTopStats'

import { options, watchValue } from './optionsStorage'
import './reactUi.jsx'
import { contro } from './controls'
import './dragndrop'
import './browserfs'
import './eruda'
import { watchOptionsAfterViewerInit } from './watchOptions'
import downloadAndOpenFile from './downloadAndOpenFile'

import net from 'net'
import mineflayer from 'mineflayer'
import { WorldDataEmitter, Viewer } from 'prismarine-viewer/viewer'
import pathfinder from 'mineflayer-pathfinder'
import { Vec3 } from 'vec3'

import blockInteraction from './blockInteraction'

import * as THREE from 'three'
import { versionsByMinecraftVersion } from 'minecraft-data'

import { initVR } from './vr'
import {
  activeModalStack,
  showModal,
  hideCurrentModal,
  activeModalStacks,
  insertActiveModalStack,
  isGameActive,
  miscUiState,
  gameAdditionalState
} from './globalState'

import {
  pointerLock,
  goFullscreen, isCypress,
  toMajorVersion,
  setLoadingScreenStatus,
  setRenderDistance
} from './utils'

import {
  removePanorama,
  addPanoramaCubeMap,
  initPanoramaOptions
} from './panorama'

import { startLocalServer, unsupportedLocalServerFeatures } from './createLocalServer'
import serverOptions from './defaultLocalServerOptions'
import dayCycle from './dayCycle'

import { subscribeKey } from 'valtio/utils'
import _ from 'lodash-es'

import { genTexturePackTextures, watchTexturepackInViewer } from './texturePack'
import { connectToPeer } from './localServerMultiplayer'
import CustomChannelClient from './customClient'
import debug from 'debug'
import { loadScript } from 'prismarine-viewer/viewer/lib/utils'
import { registerServiceWorker } from './serviceWorker'

window.debug = debug
window.THREE = THREE

// ACTUAL CODE

void registerServiceWorker()

// Create three.js context, add to page
const renderer = new THREE.WebGLRenderer({
  powerPreference: options.highPerformanceGpu ? 'high-performance' : 'default',
})
initWithRenderer(renderer.domElement)
window.renderer = renderer
renderer.setPixelRatio(window.devicePixelRatio || 1) // todo this value is too high on ios, need to check, probably we should use avg, also need to make it configurable
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.domElement.id = 'viewer-canvas'
document.body.appendChild(renderer.domElement)

// Create viewer
const viewer: import('prismarine-viewer/viewer/lib/viewer').Viewer = new Viewer(renderer, options.numWorkers)
window.viewer = viewer
viewer.entities.entitiesOptions = {
  fontFamily: 'mojangles'
}
watchOptionsAfterViewerInit()
initPanoramaOptions(viewer)
watchTexturepackInViewer(viewer)

let renderInterval: number
watchValue(options, (o) => {
  renderInterval = o.frameLimit && 1000 / o.frameLimit
})

let postRenderFrameFn = () => { }
let delta = 0
let lastTime = performance.now()
const renderFrame = (time: DOMHighResTimeStamp) => {
  if (window.stopLoop) return
  window.requestAnimationFrame(renderFrame)
  if (window.stopRender) return
  if (renderInterval) {
    delta += time - lastTime
    lastTime = time
    if (delta > renderInterval) {
      delta %= renderInterval
      // continue rendering
    } else {
      return
    }
  }
  statsStart()
  viewer.update()
  renderer.render(viewer.scene, viewer.camera)
  postRenderFrameFn()
  statsEnd()
}
renderFrame(performance.now())

window.addEventListener('resize', () => {
  viewer.camera.aspect = window.innerWidth / window.innerHeight
  viewer.camera.updateProjectionMatrix()
  renderer.setSize(window.innerWidth, window.innerHeight)
})

const loadingScreen = document.getElementById('loading-error-screen')

const hud = document.getElementById('hud')
const pauseMenu = document.getElementById('pause-screen')

let mouseMovePostHandle = (e) => { }
let lastMouseMove: number
let debugMenu
const updateCursor = () => {
  blockInteraction.update()
  debugMenu ??= hud.shadowRoot.querySelector('#debug-overlay')
  debugMenu.cursorBlock = blockInteraction.cursorBlock
}
function onCameraMove (e) {
  if (e.type !== 'touchmove' && !pointerLock.hasPointerLock) return
  e.stopPropagation?.()
  const now = performance.now()
  // todo: limit camera movement for now to avoid unexpected jumps
  if (now - lastMouseMove < 4) return
  lastMouseMove = now
  let { mouseSensX, mouseSensY } = options
  if (mouseSensY === true) mouseSensY = mouseSensX
  mouseMovePostHandle({
    x: e.movementX * mouseSensX * 0.0001,
    y: e.movementY * mouseSensY * 0.0001
  })
  updateCursor()
}
window.addEventListener('mousemove', onCameraMove, { capture: true })


function hideCurrentScreens () {
  activeModalStacks['main-menu'] = [...activeModalStack]
  insertActiveModalStack('', [])
}

async function main () {
  const menu = document.getElementById('play-screen')
  menu.addEventListener('connect', e => {
    const options = e.detail
    connect(options)
  })
  const connectSingleplayer = (serverOverrides = {}) => {
    void connect({ singleplayer: true, username: options.localUsername, password: '', serverOverrides })
  }
  document.querySelector('#title-screen').addEventListener('singleplayer', (e) => {
    //@ts-expect-error
    connectSingleplayer(e.detail)
  })
  const qs = new URLSearchParams(window.location.search)
  if (qs.get('singleplayer') === '1') {
    // todo
    setTimeout(() => {
      connectSingleplayer()
    })
  }
}

let listeners = []
// only for dom listeners (no removeAllListeners)
// todo refactor them out of connect fn instead
const registerListener: import('./utilsTs').RegisterListener = (target, event, callback) => {
  target.addEventListener(event, callback)
  listeners.push({ target, event, callback })
}
const removeAllListeners = () => {
  for (const { target, event, callback } of listeners) {
    target.removeEventListener(event, callback)
  }
  listeners = []
}

const cleanConnectIp = (host: string | undefined, defaultPort: string | undefined) => {
  const hostPort = host && /:\d+$/.exec(host)
  if (hostPort) {
    return {
      host: host.slice(0, -hostPort[0].length),
      port: hostPort[0].slice(1)
    }
  } else {
    return { host, port: defaultPort }
  }
}

async function connect (connectOptions: {
  server?: string; singleplayer?: any; username?: string; password?: any; proxy?: any; botVersion?: any; serverOverrides?; peerId?: string
}) {
  document.getElementById('play-screen').style = 'display: none;'
  removePanorama()

  const singeplayer = connectOptions.singleplayer
  const p2pMultiplayer = !!connectOptions.peerId
  miscUiState.singleplayer = singeplayer
  miscUiState.flyingSquid = singeplayer || p2pMultiplayer
  const { renderDistance, maxMultiplayerRenderDistance } = options
  const server = cleanConnectIp(connectOptions.server, '25565')
  const proxy = cleanConnectIp(connectOptions.proxy, undefined)
  const { username, password } = connectOptions

  console.log(`connecting to ${server.host}:${server.port} with ${username}`)

  hideCurrentScreens()
  setLoadingScreenStatus('Logging in')

  let ended = false
  let bot: typeof __type_bot
  const destroyAll = () => {
    if (ended) return
    ended = true
    viewer.resetAll()
    window.localServer = undefined

    postRenderFrameFn = () => { }
    if (bot) {
      bot.end()
      // ensure mineflayer plugins receive this even for cleanup
      bot.emit('end', '')
      bot.removeAllListeners()
      bot._client.removeAllListeners()
      bot._client = undefined
      window.bot = bot = undefined
    }
    removeAllListeners()
  }
  const handleError = (err) => {
    errorAbortController.abort()
    console.log('Encountered error!', err)

    // #region rejoin key
    const controller = new AbortController()
    window.addEventListener('keyup', (e) => {
      if (e.code !== 'KeyR') return
      controller.abort()
      void connect(connectOptions)
      loadingScreen.hasError = false
    }, { signal: controller.signal })
    // #endregion

    setLoadingScreenStatus(`Error encountered. Error message: ${err}`, true)
    destroyAll()
    if (isCypress()) throw err
  }

  const errorAbortController = new AbortController()
  window.addEventListener('unhandledrejection', (e) => {
    if (e.reason.name === 'ServerPluginLoadFailure') {
      if (confirm(`Failed to load server plugin ${e.reason.pluginName} (invoking ${e.reason.pluginMethod}). Continue?`)) {
        return
      }
    }
    handleError(e.reason)
  }, {
    signal: errorAbortController.signal
  })
  window.addEventListener('error', (e) => {
    handleError(e.message)
  }, {
    signal: errorAbortController.signal
  })

  if (proxy) {
    console.log(`using proxy ${proxy.host}${proxy.port && `:${proxy.port}`}`)

    net['setProxy']({ hostname: proxy.host, port: proxy.port })
  }

  let localServer
  try {
    Object.assign(serverOptions, _.defaultsDeep({}, connectOptions.serverOverrides ?? {}, options.localServerOptions, serverOptions))
    serverOptions['view-distance'] = renderDistance
    const downloadMcData = async (version) => {
      setLoadingScreenStatus(`Downloading data for ${version}`)
      try {
        await genTexturePackTextures(version)
      } catch (err) {
        console.error(err)
        const doContinue = confirm('Failed to apply texture pack. See errors in the console. Continue?')
        if (!doContinue) {
          throw err
        }
      }
      await loadScript(`./mc-data/${toMajorVersion(version)}.js`)
      viewer.setVersion(version)
    }

    const downloadVersion = connectOptions.botVersion || (singeplayer ? serverOptions.version : undefined)
    if (downloadVersion) {
      await downloadMcData(downloadVersion)
    }

    if (singeplayer) {
      // SINGLEPLAYER EXPLAINER:
      // Note 1: here we have custom sync communication between server Client (flying-squid) and game client (mineflayer)
      // Note 2: custom Server class is used which simplifies communication & Client creation on it's side
      // local server started
      // mineflayer.createBot (see source def)
      // bot._client = bot._client ?? mc.createClient(options) <-- mc-protocol package
      // tcpDns() skipped since we define connect option
      // in setProtocol: we emit 'connect' here below so in that file we send set_protocol and login_start (onLogin handler)
      // Client (class) of flying-squid (in server/login.js of mc-protocol): onLogin handler: skip most logic & go to loginClient() which assigns uuid and sends 'success' back to client (onLogin handler) and emits 'login' on the server (login.js in flying-squid handler)
      // flying-squid: 'login' -> player.login -> now sends 'login' event to the client (handled in many plugins in mineflayer) -> then 'update_health' is sent which emits 'spawn' in mineflayer

      setLoadingScreenStatus('Starting local server')
      localServer = window.localServer = startLocalServer()
      // todo need just to call quit if started
      // loadingScreen.maybeRecoverable = false
      // init world, todo: do it for any async plugins
      if (!localServer.pluginsReady) {
        await new Promise(resolve => {
          localServer.once('pluginsReady', resolve)
        })
      }
    }

    setLoadingScreenStatus('Connecting to server')
    bot = mineflayer.createBot({
      host: server.host,
      port: +server.port,
      version: connectOptions.botVersion || false,
      ...p2pMultiplayer ? {
        stream: await connectToPeer(connectOptions.peerId),
      } : {},
      ...singeplayer || p2pMultiplayer ? {
        keepAlive: false,
      } : {},
      ...singeplayer ? {
        version: serverOptions.version,
        connect () { },
        Client: CustomChannelClient as any,
      } : {},
      username,
      password,
      viewDistance: 'tiny',
      checkTimeoutInterval: 240 * 1000,
      noPongTimeout: 240 * 1000,
      closeTimeout: 240 * 1000,
      respawn: options.autoRespawn,
      async versionSelectedHook (client) {
        // todo keep in sync with esbuild preload, expose cache ideally
        if (client.version === '1.20.1') {
          // ignore cache hit
          versionsByMinecraftVersion.pc['1.20.1']!['dataVersion']++
        }
        await downloadMcData(client.version)
        setLoadingScreenStatus('Connecting to server')
      }
    }) as unknown as typeof __type_bot
    window.bot = bot
    if (singeplayer || p2pMultiplayer) {
      // p2pMultiplayer still uses the same flying-squid server
      const _supportFeature = bot.supportFeature
      bot.supportFeature = (feature) => {
        if (unsupportedLocalServerFeatures.includes(feature)) {
          return false
        }
        return _supportFeature(feature)
      }

      bot.emit('inject_allowed')
      bot._client.emit('connect')
    } else {
      const setupConnectHandlers = () => {
        bot._client.socket.on('connect', () => {
          console.log('TCP connection established')
          //@ts-expect-error
          bot._client.socket._ws.addEventListener('close', () => {
            console.log('TCP connection closed')
            setTimeout(() => {
              if (bot) {
                bot.emit('end', 'TCP connection closed with unknown reason')
              }
            })
          })
        })
      }
      // socket setup actually can be delayed because of dns lookup
      if (bot._client.socket) {
        setupConnectHandlers()
      } else {
        const originalSetSocket = bot._client.setSocket.bind(bot._client)
        bot._client.setSocket = (socket) => {
          originalSetSocket(socket)
          setupConnectHandlers()
        }
      }

    }
  } catch (err) {
    handleError(err)
  }
  if (!bot) return

  const p2pConnectTimeout = p2pMultiplayer ? setTimeout(() => { throw new Error('Spawn timeout. There might be error on other side, check console.') }, 20_000) : undefined
  hud.preload(bot)

  // bot.on('inject_allowed', () => {
  //   loadingScreen.maybeRecoverable = false
  // })

  bot.on('error', handleError)

  bot.on('kicked', (kickReason) => {
    console.log('User was kicked!', kickReason)
    setLoadingScreenStatus(`The Minecraft server kicked you. Kick reason: ${kickReason}`, true)
    destroyAll()
  })

  bot.on('end', (endReason) => {
    if (ended) return
    console.log('disconnected for', endReason)
    setLoadingScreenStatus(`You have been disconnected from the server. End reason: ${endReason}`, true)
    destroyAll()
    if (isCypress()) throw new Error(`disconnected: ${endReason}`)
  })

  bot.once('login', () => {
    // server is ok, add it to the history
    const serverHistory: string[] = JSON.parse(localStorage.getItem('serverHistory') || '[]')
    serverHistory.unshift(connectOptions.server)
    localStorage.setItem('serverHistory', JSON.stringify([...new Set(serverHistory)]))

    setLoadingScreenStatus('Loading world')
  })

  // don't use spawn event, player can be dead
  bot.once('health', () => {
    if (p2pConnectTimeout) clearTimeout(p2pConnectTimeout)
    const mcData = require('minecraft-data')(bot.version)

    setLoadingScreenStatus('Placing blocks (starting viewer)')

    console.log('bot spawned - starting viewer')

    const { version } = bot

    const center = bot.entity.position

    const worldView = window.worldView = new WorldDataEmitter(bot.world, singeplayer ? renderDistance : Math.min(renderDistance, maxMultiplayerRenderDistance), center)
    setRenderDistance()

    const updateFov = () => {
      let fovSetting = options.fov
      // todo check values and add transition
      if (bot.controlState.sprint && !bot.controlState.sneak) {
        fovSetting += 5
      }
      if (gameAdditionalState.isFlying) {
        fovSetting += 5
      }
      viewer.camera.fov = fovSetting
      viewer.camera.updateProjectionMatrix()
    }
    updateFov()
    subscribeKey(options, 'fov', updateFov)
    subscribeKey(gameAdditionalState, 'isFlying', updateFov)
    subscribeKey(gameAdditionalState, 'isSprinting', updateFov)
    subscribeKey(gameAdditionalState, 'isSneaking', () => {
      viewer.isSneaking = gameAdditionalState.isSneaking
      viewer.setFirstPersonCamera(bot.entity.position, bot.entity.yaw, bot.entity.pitch)
    })

    bot.on('physicsTick', () => updateCursor())

    const debugMenu = hud.shadowRoot.querySelector('#debug-overlay')

    window.loadedData = mcData
    window.Vec3 = Vec3
    window.pathfinder = pathfinder
    window.debugMenu = debugMenu

    initVR(bot, renderer, viewer)

    postRenderFrameFn = () => {
      viewer.setFirstPersonCamera(null, bot.entity.yaw, bot.entity.pitch)
    }

    try {
      const gl = renderer.getContext()
      debugMenu.rendererDevice = gl.getParameter(gl.getExtension('WEBGL_debug_renderer_info').UNMASKED_RENDERER_WEBGL)
    } catch (err) {
      console.error(err)
      debugMenu.rendererDevice = '???'
    }

    // Link WorldDataEmitter and Viewer
    viewer.listen(worldView)
    worldView.listenToBot(bot)
    worldView.init(bot.entity.position)

    dayCycle()

    // Bot position callback
    function botPosition () {
      // this might cause lag, but not sure
      viewer.setFirstPersonCamera(bot.entity.position, bot.entity.yaw, bot.entity.pitch)
      worldView.updatePosition(bot.entity.position)
    }
    bot.on('move', botPosition)
    botPosition()

    setLoadingScreenStatus('Setting callbacks')

    const maxPitch = 0.5 * Math.PI
    const minPitch = -0.5 * Math.PI
    mouseMovePostHandle = ({ x, y }) => {
      bot.entity.pitch -= y
      bot.entity.pitch = Math.max(minPitch, Math.min(maxPitch, bot.entity.pitch))
      bot.entity.yaw -= x
    }

    function changeCallback () {
      notification.show = false
      if (!pointerLock.hasPointerLock && activeModalStack.length === 0) {
        showModal(pauseMenu)
      }
    }

    registerListener(document, 'pointerlockchange', changeCallback, false)

    let holdingTouch: { touch: Touch, elem: HTMLElement } | undefined
    document.body.addEventListener('touchend', (e) => {
      if (!isGameActive(true)) return
      if (holdingTouch?.touch.identifier !== e.changedTouches[0].identifier) return
      holdingTouch.elem.click()
      holdingTouch = undefined
    })
    document.body.addEventListener('touchstart', (e) => {
      if (!isGameActive(true)) return
      e.preventDefault()
      holdingTouch = {
        touch: e.touches[0],
        elem: e.composedPath()[0] as HTMLElement
      }
    }, { passive: false })

    const cameraControlEl = hud

    /** after what time of holding the finger start breaking the block */
    const touchStartBreakingBlockMs = 500
    let virtualClickActive = false
    let virtualClickTimeout
    let screenTouches = 0
    let capturedPointer: { id; x; y; sourceX; sourceY; activateCameraMove; time } | null
    registerListener(document, 'pointerdown', (e) => {
      const clickedEl = e.composedPath()[0]
      if (!isGameActive(true) || !miscUiState.currentTouch || clickedEl !== cameraControlEl || e.pointerId === undefined) {
        return
      }
      screenTouches++
      if (screenTouches === 3) {
        window.dispatchEvent(new MouseEvent('mousedown', { button: 1 }))
      }
      if (capturedPointer) {
        return
      }
      cameraControlEl.setPointerCapture(e.pointerId)
      capturedPointer = {
        id: e.pointerId,
        x: e.clientX,
        y: e.clientY,
        sourceX: e.clientX,
        sourceY: e.clientY,
        activateCameraMove: false,
        time: Date.now()
      }
      virtualClickTimeout ??= setTimeout(() => {
        virtualClickActive = true
        document.dispatchEvent(new MouseEvent('mousedown', { button: 0 }))
      }, touchStartBreakingBlockMs)
    })
    registerListener(document, 'pointermove', (e) => {
      if (e.pointerId === undefined || e.pointerId !== capturedPointer?.id) return
      window.scrollTo(0, 0)
      e.preventDefault()
      e.stopPropagation()

      const allowedJitter = 1.1
      // todo support .pressure (3d touch)
      const xDiff = Math.abs(e.pageX - capturedPointer.sourceX) > allowedJitter
      const yDiff = Math.abs(e.pageY - capturedPointer.sourceY) > allowedJitter
      if (!capturedPointer.activateCameraMove && (xDiff || yDiff)) capturedPointer.activateCameraMove = true
      if (capturedPointer.activateCameraMove) {
        clearTimeout(virtualClickTimeout)
      }
      onCameraMove({ movementX: e.pageX - capturedPointer.x, movementY: e.pageY - capturedPointer.y, type: 'touchmove' })
      capturedPointer.x = e.pageX
      capturedPointer.y = e.pageY
    }, { passive: false })

    contro.on('stickMovement', ({ stick, vector }) => {
      if (stick !== 'right') return
      let { x, z } = vector
      if (Math.abs(x) < 0.18) x = 0
      if (Math.abs(z) < 0.18) z = 0
      onCameraMove({ movementX: x * 10, movementY: z * 10, type: 'touchmove' })
    })

    const pointerUpHandler = (e: PointerEvent) => {
      if (e.pointerId === undefined || e.pointerId !== capturedPointer?.id) return
      clearTimeout(virtualClickTimeout)
      virtualClickTimeout = undefined

      if (virtualClickActive) {
        // button 0 is left click
        document.dispatchEvent(new MouseEvent('mouseup', { button: 0 }))
        virtualClickActive = false
      } else if (!capturedPointer.activateCameraMove && (Date.now() - capturedPointer.time < touchStartBreakingBlockMs)) {
        document.dispatchEvent(new MouseEvent('mousedown', { button: 2 }))
        blockInteraction.update()
        document.dispatchEvent(new MouseEvent('mouseup', { button: 2 }))
      }
      capturedPointer = undefined
      screenTouches--
    }
    registerListener(document, 'pointerup', pointerUpHandler)
    registerListener(document, 'pointercancel', pointerUpHandler)
    registerListener(document, 'lostpointercapture', pointerUpHandler)

    registerListener(document, 'contextmenu', (e) => e.preventDefault(), false)

    registerListener(document, 'blur', (e) => {
      bot.clearControlStates()
    }, false)

    console.log('Done!')

    hud.init(renderer, bot, server.host)
    hud.style.display = 'block'
    blockInteraction.init()

    errorAbortController.abort()
    if (loadingScreen.hasError) return
    setLoadingScreenStatus(undefined)
    miscUiState.gameLoaded = true
    void viewer.waitForChunksToRender().then(() => {
      console.log('All done and ready!')
      document.dispatchEvent(new Event('cypress-world-ready'))
    })
  })
}

window.addEventListener('mousedown', () => {
  void pointerLock.requestPointerLock()
})

window.addEventListener('keydown', (e) => {
  if (e.code !== 'Escape') return
  if (activeModalStack.length) {
    hideCurrentModal(undefined, () => {
      if (!activeModalStack.length) {
        pointerLock.justHitEscape = true
      }
    })
  } else if (pointerLock.hasPointerLock) {
    if (options.autoExitFullscreen) {
      void document.exitFullscreen()
    }
  } else {
    document.dispatchEvent(new Event('pointerlockchange'))
  }
})

window.addEventListener('keydown', (e) => {
  if (e.code === 'F11') {
    e.preventDefault()
    goFullscreen(true)
  }
  if (e.code === 'KeyL' && e.altKey) {
    console.clear()
  }
})

addPanoramaCubeMap()
showModal(document.getElementById('title-screen'))
void main()
downloadAndOpenFile().then((downloadAction) => {
  if (downloadAction) return

  window.addEventListener('hud-ready', (e) => {
    // try to connect to peer
    const qs = new URLSearchParams(window.location.search)
    const peerId = qs.get('connectPeer')
    const version = qs.get('peerVersion')
    if (peerId) {
      let username = options.guestUsername
      if (options.askGuestName) username = prompt('Enter your username', username)
      if (!username) return
      options.guestUsername = username
      void connect({
        username,
        botVersion: version || undefined,
        peerId
      })
    }
  })
  if (document.getElementById('hud').isReady) window.dispatchEvent(new Event('hud-ready'))
}, (err) => {
  console.error(err)
  alert(`Failed to download file: ${err}`)
})
