////
// Web server that serves shell commands, websocket connections, etc

import { Hono } from "hono"
import { serveStatic, upgradeWebSocket, websocket } from "hono/bun"
import color from "kleur"

import type { Message } from "./shared/types"
import { rewriteJsImports } from "./build"
import { NOSE_ICON, NOSE_BIN, NOSE_DATA, NOSE_DIR, NOSE_ROOT_BIN, DEFAULT_PROJECT } from "./config"
import { transpile, isFile, tilde, isDir } from "./utils"
import { serveApp, apps, initWebapps } from "./webapp"
import { commands, commandPath, loadCommandModule } from "./commands"
import { runCommandFn } from "./shell"
import { send, addWebsocket, removeWebsocket, closeWebsockets } from "./websocket"
import { initSneakers, disconnectSneakers } from "./sneaker"
import { dispatchMessage } from "./dispatch"
import { fatal } from "./fatal"
import { getState } from "./state"
import { sessionRun } from "./session"

import { Layout } from "./html/layout"
import { Terminal } from "./html/terminal"
import { Error } from "./html/error"

import { initDNS } from "./dns"
import { initNoseDir } from "./nosedir"
import { initCommands } from "./commands"

//
// Hono setup
//

const app = new Hono()

// console logging
app.use("*", async (c, next) => {
    const start = Date.now()
    await next()
    const end = Date.now()
    const fn = c.res.status < 300 ? color.green : c.res.status < 500 ? color.yellow : color.red
    console.log(fn(`${c.res.status} ${c.req.method} ${c.req.url} (${end - start}ms)`))
})

// subdomain route needs to go first
app.use("*", async (c, next) => {
    const url = new URL(c.req.url)
    const localhost = url.hostname.endsWith("localhost")
    const domains = url.hostname.split(".")
    let subdomain = ""

    if (domains.length > (localhost ? 1 : 2))
        subdomain = domains[0]!

    if (subdomain) {
        const app = serveApp(c, subdomain)
        return await app
    }

    return next()
})

// static files
app.use('/*', serveStatic({ root: './public' }))
app.use('/img/*', serveStatic({ root: './public' }))
app.use('/vendor/*', serveStatic({ root: './public' }))
app.use('/css/*', serveStatic({ root: './src' }))

// blue screen of death error page if NOSE_DIR isn't found
app.use("*", async (c, next) => {
    const error = fatal ? fatal : !isDir(NOSE_DIR) ? `NOSE_DIR doesn't exist: ${NOSE_DIR}` : undefined

    if (!error || (["/cmd/restart", "/cmd/reboot"].includes(c.req.path))) {
        await next()
    } else {
        return c.html(<Layout><Error error={error} /></Layout>, 500)
    }
})

// only transpile browser JS on the fly in dev mode
if (process.env.NODE_ENV !== "production")
    app.on("GET", ["/js/:path{.+}", "/shared/:path{.+}"], async c => {
        let path = "./src/" + c.req.path.replace("..", ".")

        // path must end in .js or .ts
        if (!path.endsWith(".js") && !path.endsWith(".ts")) path += ".ts"

        const ts = path.replace(".js", ".ts")
        if (isFile(ts)) {
            return new Response(await transpile(ts), { headers: { "Content-Type": "text/javascript" } })
        } else if (isFile(path)) {
            return new Response(Bun.file(path), { headers: { "Content-Type": "text/javascript" } })
        } else {
            return c.text("File not found", 404)
        }
    })

// inject browser-nav.js into NOSE apps when loaded in a NOSE iframe
app.use("*", async (c, next) => {
    await next()

    const url = new URL(c.req.url)
    const domains = url.hostname.split(".")
    const isSubdomain = domains.length > (url.hostname.endsWith("localhost") ? 1 : 2)

    if (!isSubdomain) return

    const contentType = c.res.headers.get('content-type')
    if (!contentType?.includes('text/html')) return

    const secFetchDest = c.req.header('Sec-Fetch-Dest')
    const isIframe = secFetchDest === 'iframe'
    if (!isIframe) return

    const originalBody = await c.res.text()
    const shimScript = `<script src="/browser-nav.js"></script>`

    let modifiedBody = originalBody
    if (originalBody.includes('</head>')) {
        modifiedBody = originalBody.replace('</head>', `${shimScript}\n</head>`)
    } else if (originalBody.includes('<body>')) {
        modifiedBody = originalBody.replace('<body>', `<body>\n${shimScript}`)
    }

    c.res = new Response(modifiedBody, {
        status: c.res.status,
        headers: c.res.headers
    })
})

// source code of commands - used to load and eval() browser commands
app.get("/source/:name", async c => {
    const name = c.req.param("name")
    const sessionId = c.req.query("session") || "0"
    const path = await sessionRun(sessionId, () => commandPath(name))
    if (!path) return c.text("Command not found", 404)

    return new Response(rewriteJsImports(await transpile(path)), {
        headers: {
            "Content-Type": "text/javascript"
        }
    })
})

// call a command's GET or POST function, if one exists
app.on(["GET", "POST"], ["/cmd/:name"], async c => {
    const sessionId = c.req.header("X-Session") || "0"
    const cmd = c.req.param("name")
    const method = c.req.method

    try {
        const mod = await sessionRun(sessionId, () => loadCommandModule(cmd))
        if (!mod || !mod[method])
            return c.json({ status: "error", output: `No ${method} export in ${cmd}` }, 500)

        return c.json(await runCommandFn({ sessionId }, async () => mod[method](c)))
    } catch (e: any) {
        return c.json({ status: "error", output: e.message || e.toString() }, 500)
    }
})

// the terminal
app.get("/", c => c.html(<Layout><Terminal /></Layout>))

// the main websocket
app.get("/ws", c => {
    const _sessionId = c.req.query("session")
    const url = new URL(c.req.url)
    let hostname = url.hostname + (!url.port || url.port === "80" ? "" : `:${url.port}`)

    return upgradeWebSocket(c, {
        async onOpen(_e, ws) {
            addWebsocket(ws)
            send(ws, { type: "commands", data: await commands() })
            send(ws, { type: "apps", data: apps() })

            send(ws, {
                type: "session:start",
                data: {
                    NOSE_DIR: NOSE_DIR,
                    project: DEFAULT_PROJECT,
                    cwd: "/",
                    mode: getState("ui:mode") || "tall",
                    hostname
                }
            })
        },
        async onMessage(event, ws) {
            let data: Message | undefined

            try {
                data = JSON.parse(event.data.toString())
            } catch (e) {
                console.error("JSON parsing error", e)
                send(ws, { type: "error", data: "json parsing error" })
                return
            }

            if (!data) return

            await dispatchMessage(ws, data)
        },
        onClose: (event, ws) => removeWebsocket(ws)
    })
})

//
// hot reload mode cleanup
//

if (process.env.BUN_HOT) {
    // @ts-ignore
    globalThis.__hot_reload_cleanup?.()

    // @ts-ignore
    globalThis.__hot_reload_cleanup = () => {
        closeWebsockets()
        disconnectSneakers()
    }

    for (const sig of ["SIGINT", "SIGTERM"] as const) {
        process.on(sig, () => {
            // @ts-ignore
            globalThis.__hot_reload_cleanup?.()
            process.exit(0)
        })
    }
}

//
// server start
//

if (process.env.NODE_ENV === "production") {
    initDNS()
}

await initNoseDir()
initCommands()
initWebapps()
initSneakers()

console.log(color.cyan(NOSE_ICON))
console.log(color.blue("     NOSE_BIN:"), color.yellow(tilde(NOSE_BIN)))
console.log(color.blue("    NOSE_DATA:"), color.yellow(tilde(NOSE_DATA)))
console.log(color.blue("     NOSE_DIR:"), color.yellow(tilde(NOSE_DIR)))
console.log(color.blue("NOSE_ROOT_BIN:"), color.yellow(tilde(NOSE_ROOT_BIN)))

export default {
    port: process.env.PORT || 3000,
    hostname: "0.0.0.0",
    fetch: app.fetch,
    idleTimeout: 0,
    websocket
}