import { join, resolve } from 'path'
import { render as formatHTML } from './lib/html-formatter'
import { type Context, Hono, type Schema, type Env } from 'hono'
import { serveStatic } from 'hono/bun'
import { streamSSE } from 'hono/streaming'
import color from 'kleur'

import { transpile } from './utils'
import defaultLayout from './layout'

const SHOW_HTTP_LOG = true
const CSS_RESET = await Bun.file(join(import.meta.dir, '/css/reset.css')).text()
const PICO_CSS = await Bun.file(join(import.meta.dir, '/css/pico.css')).text()

export * from './utils'
export { ReloadScript } from './layout'
export type { Context } from 'hono'

const pageCache = new Map()

export type HypeProps = {
  pico?: boolean
  reset?: boolean
  prettyHTML?: boolean
  layout?: boolean
  logging?: boolean
  ok?: boolean
}

type InternalProps = HypeProps & { _isRouter?: boolean }

export type SSEHandler<E extends Env> = (
  send: (data: unknown, event?: string) => Promise<void>,
  c: Context<E>
) => void | (() => void) | Promise<void | (() => void)>

export class Hype<
  E extends Env = Env,
  S extends Schema = {},
  BasePath extends string = '/'
> extends Hono<E, S, BasePath> {
  props: HypeProps
  middlewareRegistered = false
  routesRegistered = false

  constructor(props?: InternalProps & ConstructorParameters<typeof Hono<E, S, BasePath>>[0]) {
    super(props)

    this.props = props ?? {}
    if (!props?._isRouter) {
      this.registerMiddleware()
    }
  }

  static router<E extends Env = Env>(): Hype<E> {
    return new Hype<E>({ _isRouter: true })
  }

  sse(path: string, handler: SSEHandler<E>) {
    return this.get(path, (c) => {
      return streamSSE(c, async (stream) => {
        const send = async (data: unknown, event?: string) => {
          await stream.writeSSE({
            data: typeof data === 'string' ? data : JSON.stringify(data),
            event,
          })
        }

        const cleanup = await handler(send, c)

        // Keep stream open until client disconnects
        await new Promise<void>((resolve) => {
          stream.onAbort(() => {
            cleanup?.()
            resolve()
          })
        })
      })
    })
  }

  registerMiddleware() {
    if (this.middlewareRegistered) return
    this.middlewareRegistered = true

    // static assets in pub/
    this.use('/*', serveStatic({ root: './pub' }))

    // css lives in src/, close to real code
    this.use('/css/*', serveStatic({ root: './src' }))

    // console logging
    if (this.props.logging !== false) {
      this.use('*', async (c, next) => {
        if (!SHOW_HTTP_LOG) return await next()

        const start = Date.now()
        await next()
        const end = Date.now()
        const fn = c.res.status < 400 ? color.green : c.res.status < 500 ? color.yellow : color.red
        const method = c.req.method === 'GET' ? color.cyan(c.req.method) : color.magenta(c.req.method)
        console.log(fn(`${c.res.status}`), `${color.bold(method)} ${c.req.url} (${end - start}ms)`)
      })
    }

    // exception handler
    this.onError((err, c) => {
      const isDev = process.env.NODE_ENV !== 'production'

      if (isDev) console.error(err)

      return c.html(
        `<!DOCTYPE html>
        <html>
          <body>
            <h1>Error: ${err.message}</h1>
            ${isDev ? `<pre>${err.stack}</pre>` : '<p>An error occurred</p>'}
          </body>
        </html>`,
        500
      )
    })

    // prettify HTML output
    if (this.props.prettyHTML ?? process.env.NODE_ENV !== 'production') {
      this.use('*', async (c, next) => {
        await next()

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

        const res = c.res.clone()
        const html = await res.text()
        const formatted = formatHTML(html)
        const headers = new Headers(c.res.headers)
        headers.delete('content-length')
        c.res = new Response(formatted, { status: c.res.status, headers })
      })
    }
  }

  registerRoutes() {
    if (this.routesRegistered) return
    this.routesRegistered = true

    // live reload endpoint (dev mode only)
    if (process.env.NODE_ENV !== 'production') {
      this.sse('/__hype/reload', () => {})
      this.get('/__hype/ping', c => c.text('ok'))
    }

    // healthcheck
    if (this.props.ok) 
      this.get('/ok', c => c.text('ok'))

    // css reset
    this.get('/css/reset.css', async c => new Response(CSS_RESET, { headers: { 'Content-Type': 'text/css' } }))

    // pico
    this.get('/css/pico.css', async c => new Response(PICO_CSS, { headers: { 'Content-Type': 'text/css' } }))

    // serve transpiled js
    this.on('GET', ['/client/:path{.+}', '/shared/:path{.+}'], async c => {
      const reqPath = resolve('./src/', c.req.path.slice(1))
      if (!reqPath.startsWith(resolve('./src/'))) return render404(c)

      // strip known extension to get base path
      const base = reqPath.replace(/\.(js|jsx|ts|tsx)$/, '')

      // try TS extensions first (needs transpilation)
      for (const ext of ['.ts', '.tsx', '.jsx']) {
        const file = base + ext
        if (await Bun.file(file).exists())
          return new Response(await transpile(file), { headers: { 'Content-Type': 'text/javascript' } })
      }

      // try plain .js (serve raw)
      const jsFile = base + '.js'
      if (await Bun.file(jsFile).exists())
        return new Response(Bun.file(jsFile), { headers: { 'Content-Type': 'text/javascript' } })

      return render404(c)
    })

    // file based routing
    this.on('GET', ['/', '/:page{.+}'], async c => {
      const pageName = (c.req.param('page') ?? 'index').replace(/\.\./g, '')
      if (pageName.split('/').some(s => s.startsWith('_'))) return render404(c)

      // try pageName.tsx, then pageName/index.tsx
      const base = join(process.env.PWD ?? process.cwd(), './src/pages')
      let path = join(base, `${pageName}.tsx`)
      if (!(await Bun.file(path).exists())) {
        path = join(base, `${pageName}/index.tsx`)
        if (!(await Bun.file(path).exists()))
          return render404(c)
      }

      let Layout = defaultLayout
      const layoutPath = join(process.env.PWD ?? process.cwd(), `./src/pages/_layout.tsx`)
      if (await Bun.file(layoutPath).exists()) {
        Layout = pageCache.get(layoutPath)
        if (!Layout) {
          Layout = (await import(layoutPath + `?t=${Date.now()}`)).default
          pageCache.set(layoutPath, Layout)
        }
      }

      let page = pageCache.get(path)
      if (!page) {
        page = await import(path + `?t=${Date.now()}`)
        pageCache.set(path, page)
      }

      const innerHTML = typeof page.default === 'function' ? <page.default c={c} req={c.req} /> : page.default
      const withLayout = this.props.layout !== false ? <Layout props={this.props}>{innerHTML}</Layout> : innerHTML
      return c.html(withLayout)
    })
  }

  get defaults() {
    this.registerRoutes()

    const isDev = process.env.NODE_ENV !== 'production'
    let port = process.env.PORT ? Number(process.env.PORT) : 3000

    if (isDev) port = findAvailablePort(port)

    return {
      port,
      fetch: this.fetch,
      idleTimeout: 255
    }
  }
}

// find an available port starting from the given port
function findAvailablePort(startPort: number, maxAttempts = 100): number {
  if (process.env.NO_AUTOPORT) return startPort

  for (let port = startPort; port < startPort + maxAttempts; port++) {
    try {
      const server = Bun.serve({ port, fetch: () => new Response() })
      server.stop(true)
      return port
    } catch {
      continue
    }
  }
  throw new Error(`Could not find an available port after ${maxAttempts} attempts starting from ${startPort}`)
}

function render404(c: Context) {
  return c.text('File not found', 404)
}

