import { join } 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 color from 'kleur'

import { transpile } from './utils'
import defaultLayout from './layout'
import { feFunctions, fnStorage } from './frontend'

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

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

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

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

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

    this.props = props ?? {}
  }

  registerRoutes() {
    if (this.routesRegistered) return
    this.routesRegistered = 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
    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'

      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)
        c.res = new Response(formatted, c.res)
      })
    }

    // serve frontend js
    this.use('*', async (c, next) => {
      await fnStorage.run({ fns: [] }, async () => {
        await next()

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

        const fns = feFunctions()
        if (!fns.length) return

        const res = c.res.clone()
        const html = await res.text()
        const newHtml = html.replace('</body>', `<script>${fns.join('\n')}</script></body>`)

        c.res = new Response(newHtml, c.res)
      })
    })

    // 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 => {
      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 (await Bun.file(ts).exists())
        return new Response(await transpile(ts), { headers: { 'Content-Type': 'text/javascript' } })

      else if (await Bun.file(ts + 'x').exists())
        return new Response(await transpile(ts + 'x'), { headers: { 'Content-Type': 'text/javascript' } })

      else if (await Bun.file(path).exists())
        return new Response(Bun.file(path), { headers: { 'Content-Type': 'text/javascript' } })

      else
        return render404(c)
    })

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

      const path = join(process.env.PWD ?? '.', `./src/pages/${pageName}.tsx`)

      if (!(await Bun.file(path).exists()))
        return render404(c)

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

      const page = await import(path + `?t=${Date.now()}`)
      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 = findAvailablePortSync(port)
    }

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

/**
 * Synchronously find an available port starting from the given port
 */
function findAvailablePortSync(startPort: number, maxAttempts = 100): number {
  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)
}