obol

Owner: IIIlllIIIllI URL: git@git.0x00nyx.xyz:seb/obolserver.git

obol.nim

# (c) 2025 Sebastian Michalk <sebastian.michalk@pm.me>

import std/asynchttpserver
import std/asyncdispatch
import std/httpcore
import std/json
import std/os
import std/options
import std/sets
import std/strutils
import std/tables
import std/times
import std/uri

const
  defaultPort = 8080
  defaultMimeType = "application/octet-stream"

# ----------------
# Request/Response
# ----------------

type
  Headers = Table[string, string]

  Request = object
    httpMethod: string
    path: string
    headers: Headers
    body: string
    params: Headers

  Response = object
    status: int
    headers: Headers
    body: string

proc newHeaders(): Headers =
  initTable[string, string]()

proc newResponse(status: int = 200, body: string = ""): Response =
  result.status = status
  result.headers = newHeaders()
  result.body = body

proc notFoundResponse(): Response =
  newResponse(404, "not found")

proc serverErrorResponse(): Response =
  newResponse(500, "internal server error")

# ------
# Router
# ------

type
  SegmentKind = enum
    segLiteral
    segParam

  RouteSegment = object
    kind: SegmentKind
    name: string
    value: string

  Handler = proc (req: Request): Response {.closure, gcsafe.}

  Route = object
    httpMethod: string
    segments: seq[RouteSegment]
    paramCount: int
    handler: Handler

  Router = object
    exact: Table[string, Handler]
    parametric: seq[Route]

  DataSegmentKind = enum
    dskLiteral
    dskParam

  DataSegment = object
    kind: DataSegmentKind
    value: string

proc normalizeUrlPath(raw: string): string =
  var cleaned = raw.strip()
  if cleaned.len == 0:
    return "/"
  let queryPos = cleaned.find('?')
  if queryPos >= 0:
    cleaned = cleaned[0 ..< queryPos]
  if cleaned.len == 0:
    return "/"
  if not cleaned.startsWith('/'):
    cleaned = "/" & cleaned
  var parts: seq[string]
  for part in cleaned.split('/'):
    if part.len > 0:
      parts.add(part)
  if parts.len == 0:
    return "/"
  "/" & parts.join("/")

proc splitPath(path: string): seq[string] =
  let normalized = normalizeUrlPath(path)
  if normalized == "/":
    return @[]
  for part in normalized[1 .. ^1].split('/'):
    if part.len > 0:
      result.add(part)

proc isValidParamName(name: string): bool =
  if name.len == 0:
    return false
  for ch in name:
    if not (ch.isAlphaNumeric or ch == '_'):
      return false
  true

proc parsePattern(pattern: string): Route =
  let segments = splitPath(pattern)
  var route = Route()
  route.segments = @[]
  for raw in segments:
    if raw.len == 0:
      continue
    if raw[0] == ':':
      let name = raw[1 .. ^1]
      if not isValidParamName(name):
        raise newException(ValueError, "invalid parameter name in pattern '" & pattern & "'")
      route.segments.add(RouteSegment(kind: segParam, name: name))
      inc route.paramCount
    else:
      if raw.contains(':'):
        raise newException(ValueError, "unexpected ':' in literal segment of pattern '" & pattern & "'")
      route.segments.add(RouteSegment(kind: segLiteral, value: raw))
  route

proc parseDataPath(pattern: string, allowed: HashSet[string]): seq[DataSegment] =
  if pattern.len == 0:
    return @[]
  var idx = 0
  var literal = newStringOfCap(pattern.len)
  while idx < pattern.len:
    let ch = pattern[idx]
    if ch == '{':
      inc idx
      var name = ""
      while idx < pattern.len and pattern[idx] != '}':
        name.add(pattern[idx])
        inc idx
      if idx >= pattern.len or pattern[idx] != '}':
        raise newException(ValueError, "unterminated parameter in data path '" & pattern & "'")
      if literal.len > 0:
        result.add(DataSegment(kind: dskLiteral, value: literal))
        literal.setLen(0)
      if not isValidParamName(name):
        raise newException(ValueError, "invalid parameter name '" & name & "' in data path '" & pattern & "'")
      if name notin allowed:
        raise newException(ValueError, "data path references unknown parameter '" & name & "'")
      result.add(DataSegment(kind: dskParam, value: name))
    elif ch == '}':
      raise newException(ValueError, "unexpected '}' in data path '" & pattern & "'")
    else:
      literal.add(ch)
    inc idx
  if literal.len > 0:
    result.add(DataSegment(kind: dskLiteral, value: literal))

proc decodeComponent(value: string): string =
  try:
    result = value.decodeUrl()
  except ValueError:
    result = value

proc routeMatches(route: Route, httpMethod: string, pathSegments: seq[string], params: var Headers): bool =
  if route.httpMethod != httpMethod:
    return false
  if pathSegments.len != route.segments.len:
    return false
  for idx, segment in route.segments:
    let value = pathSegments[idx]
    case segment.kind
    of segLiteral:
      if value != segment.value:
        return false
    of segParam:
      params[segment.name] = decodeComponent(value)
  true

proc initRouter(): Router =
  Router(
    exact: initTable[string, Handler](),
    parametric: @[]
  )

proc routeKey(httpMethod, path: string): string =
  httpMethod & "\x1f" & path

proc addRoute(router: var Router, httpMethod, path: string, handler: Handler) =
  let normalized = normalizeUrlPath(path)
  var pattern = parsePattern(normalized)
  pattern.httpMethod = httpMethod.toUpperAscii()
  pattern.handler = handler
  if pattern.paramCount == 0:
    let key = routeKey(pattern.httpMethod, normalized)
    if key in router.exact:
      raise newException(ValueError, "duplicate route for " & httpMethod & " " & normalized)
    router.exact[key] = handler
  else:
    var inserted = false
    for idx, existing in router.parametric:
      if pattern.paramCount < existing.paramCount:
        router.parametric.insert(pattern, idx)
        inserted = true
        break
    if not inserted:
      router.parametric.add(pattern)

proc match(router: Router, httpMethod, path: string): Option[tuple[handler: Handler, params: Headers]] =
  let normalized = normalizeUrlPath(path)
  let methodUpper = httpMethod.toUpperAscii()
  let key = routeKey(methodUpper, normalized)
  if key in router.exact:
    return some((router.exact[key], newHeaders()))

  let segments = splitPath(normalized)
  for route in router.parametric:
    var params = newHeaders()
    if routeMatches(route, methodUpper, segments, params):
      return some((route.handler, params))
  none(tuple[handler: Handler, params: Headers])

# ----------------
# Template Engine
# ----------------

type
  NodeKind = enum
    nkText
    nkVar
    nkIf
    nkRange
    nkInclude
    nkDefine

  TemplateNode = ref object
    kind: NodeKind
    text: string
    name: string
    children: seq[TemplateNode]
    elseBranch: seq[TemplateNode]

  Template = object
    nodes: seq[TemplateNode]
    definitions: TemplateDefs

  TemplateEntry = object
    compiled: Template
    mtime: Time

  TemplateCache = object
    dir: string
    entries: Table[string, TemplateEntry]

  TemplateDefs = TableRef[string, seq[TemplateNode]]

proc newTemplateDefs(): TemplateDefs =
  newTable[string, seq[TemplateNode]]()

proc newTextNode(content: string): TemplateNode =
  TemplateNode(kind: nkText, text: content)

proc newVarNode(name: string): TemplateNode =
  TemplateNode(kind: nkVar, name: name)

proc newBlockNode(kind: NodeKind, name: string): TemplateNode =
  TemplateNode(kind: kind, name: name, children: @[], elseBranch: @[])

proc addChild(stack: seq[TemplateNode], inElse: seq[bool], child: TemplateNode) =
  if stack.len == 0:
    return
  let current = stack[^1]
  if current.kind == nkIf and inElse[^1]:
    current.elseBranch.add(child)
  else:
    current.children.add(child)

proc pushText(stack: seq[TemplateNode], inElse: seq[bool], content: string) =
  if content.len == 0:
    return
  addChild(stack, inElse, newTextNode(content))

proc parseQuotedName(raw: string, directive: string): string =
  if raw.len < 2 or raw[0] != '"' or raw[^1] != '"':
    raise newException(ValueError, directive & " expects quoted identifier")
  let name = raw[1 ..< raw.len - 1]
  if name.len == 0:
    raise newException(ValueError, directive & " received empty name")
  name

proc parseTemplateString(source: string): Template =
  var tpl = Template(nodes: @[], definitions: newTemplateDefs())
  var root = newBlockNode(nkIf, "__root__")
  var stack = @[root]
  var inElse = @[false]
  var cursor = 0
  while cursor < source.len:
    let openIdx = source.find("{{", cursor)
    if openIdx < 0:
      pushText(stack, inElse, source[cursor .. ^1])
      break
    pushText(stack, inElse, source[cursor ..< openIdx])
    let closeIdx = source.find("}}", openIdx + 2)
    if closeIdx < 0:
      raise newException(ValueError, "unclosed template tag")
    let rawTag = source[(openIdx + 2) ..< closeIdx].strip()
    cursor = closeIdx + 2
    if rawTag.len == 0:
      continue
    if rawTag.startsWith("if "):
      let name = rawTag[3 .. ^1].strip()
      let node = newBlockNode(nkIf, name)
      addChild(stack, inElse, node)
      stack.add(node)
      inElse.add(false)
    elif rawTag == "end":
      if stack.len <= 1:
        raise newException(ValueError, "unexpected {{end}}")
      let finished = stack.pop()
      discard inElse.pop()
      if finished.kind == nkDefine:
        tpl.definitions[finished.name] = finished.children
    elif rawTag == "else":
      if stack.len <= 1 or stack[^1].kind != nkIf:
        raise newException(ValueError, "{{else}} without matching {{if}}")
      if inElse[^1]:
        raise newException(ValueError, "duplicate {{else}}")
      inElse[^1] = true
    elif rawTag.startsWith("range "):
      let name = rawTag[6 .. ^1].strip()
      let node = newBlockNode(nkRange, name)
      addChild(stack, inElse, node)
      stack.add(node)
      inElse.add(false)
    elif rawTag.startsWith("template "):
      let arg = rawTag[8 .. ^1].strip()
      if arg.len == 0:
        raise newException(ValueError, "{{template}} requires a name")
      let name = parseQuotedName(arg, "template")
      addChild(stack, inElse, TemplateNode(kind: nkInclude, name: name))
    elif rawTag.startsWith("define "):
      if stack.len != 1:
        raise newException(ValueError, "{{define}} must appear at top level")
      let arg = rawTag[6 .. ^1].strip()
      if arg.len == 0:
        raise newException(ValueError, "{{define}} requires a name")
      let name = parseQuotedName(arg, "define")
      let node = TemplateNode(kind: nkDefine, name: name, children: @[], elseBranch: @[])
      stack.add(node)
      inElse.add(false)
    else:
      addChild(stack, inElse, newVarNode(rawTag))
  if stack.len != 1:
    raise newException(ValueError, "unclosed block in template")
  tpl.nodes = root.children
  result = tpl

proc initTemplateCache(dir: string): TemplateCache =
  TemplateCache(dir: dir, entries: initTable[string, TemplateEntry]())

proc templatePath(cache: TemplateCache, name: string): string =
  if cache.dir.len == 0:
    return name
  if cache.dir.isAbsolute():
    return cache.dir / name
  getCurrentDir() / cache.dir / name

proc loadTemplate(cache: var TemplateCache, name: string): Template {.gcsafe.} =
  let path = templatePath(cache, name)
  if not fileExists(path):
    raise newException(IOError, "template not found: " & path)
  let info = getFileInfo(path)
  if name in cache.entries:
    let entry = cache.entries[name]
    if entry.mtime == info.lastWriteTime:
      return entry.compiled
  let content = readFile(path)
  let tpl = parseTemplateString(content)
  cache.entries[name] = TemplateEntry(compiled: tpl, mtime: info.lastWriteTime)
  tpl

proc lookupField(node: JsonNode, parts: seq[string]): JsonNode {.gcsafe.} =
  var current = node
  for part in parts:
    if part == ".":
      continue
    if current.kind == JObject and part in current:
      current = current[part]
    else:
      return newJNull()
  current

proc resolveValue(identifier: string, ctx, parent: JsonNode): JsonNode {.gcsafe.} =
  if identifier == ".":
    return ctx
  let parts = identifier.split('.')
  var value = lookupField(ctx, parts)
  if value.kind != JNull:
    return value
  lookupField(parent, parts)

proc isTruthy(node: JsonNode): bool {.gcsafe.} =
  case node.kind
  of JNull:
    false
  of JBool:
    node.getBool()
  of JInt:
    node.getInt() != 0
  of JFloat:
    node.getFloat() != 0.0
  of JString:
    node.getStr().len > 0
  of JArray:
    node.len > 0
  of JObject:
    node.len > 0

const
  maxIncludeDepth = 10

proc combineDefinitions(base, overrides: TemplateDefs): TemplateDefs {.gcsafe.} =
  let combined = newTemplateDefs()
  if base != nil:
    for key, nodes in base.pairs:
      combined[key] = nodes
  if overrides != nil:
    for key, nodes in overrides.pairs:
      combined[key] = nodes
  combined

proc renderNodes(cache: var TemplateCache, nodes: seq[TemplateNode], ctx, parent: JsonNode, acc: var string, defs: TemplateDefs, stack: var seq[string], depth: int) {.gcsafe.}

proc renderInclude(cache: var TemplateCache, name: string, ctx: JsonNode, acc: var string, defs: TemplateDefs, stack: var seq[string], depth: int) {.gcsafe.} =
  if depth > maxIncludeDepth:
    raise newException(ValueError, "template include depth exceeded")
  if defs != nil and defs.hasKey(name):
    renderNodes(cache, defs[name], ctx, ctx, acc, defs, stack, depth + 1)
    return
  let fileName = if name.endsWith(".html"): name else: name & ".html"
  for existing in stack:
    if existing == fileName:
      raise newException(ValueError, "circular template include: " & stack.join(" -> ") & " -> " & fileName)
  stack.add(fileName)
  let tpl = loadTemplate(cache, fileName)
  let combined = combineDefinitions(tpl.definitions, defs)
  renderNodes(cache, tpl.nodes, ctx, newJNull(), acc, combined, stack, depth + 1)
  discard stack.pop()

proc renderNode(cache: var TemplateCache, node: TemplateNode, ctx, parent: JsonNode, acc: var string, defs: TemplateDefs, stack: var seq[string], depth: int) {.gcsafe.} =
  case node.kind
  of nkText:
    acc.add(node.text)
  of nkVar:
    let value = resolveValue(node.name, ctx, parent)
    if value.kind == JString:
      acc.add(value.getStr())
    elif value.kind in {JInt, JFloat, JBool}:
      acc.add($value)
  of nkIf:
    let value = resolveValue(node.name, ctx, parent)
    if isTruthy(value):
      renderNodes(cache, node.children, ctx, parent, acc, defs, stack, depth)
    elif node.elseBranch.len > 0:
      renderNodes(cache, node.elseBranch, ctx, parent, acc, defs, stack, depth)
  of nkRange:
    let value = resolveValue(node.name, ctx, parent)
    if value.kind == JArray:
      for element in value.items:
        renderNodes(cache, node.children, element, ctx, acc, defs, stack, depth)
    elif value.kind == JObject:
      renderNodes(cache, node.children, value, ctx, acc, defs, stack, depth)
  of nkInclude:
    renderInclude(cache, node.name, ctx, acc, defs, stack, depth)
  of nkDefine:
    discard

proc renderNodes(cache: var TemplateCache, nodes: seq[TemplateNode], ctx, parent: JsonNode, acc: var string, defs: TemplateDefs, stack: var seq[string], depth: int) {.gcsafe.} =
  for node in nodes:
    renderNode(cache, node, ctx, parent, acc, defs, stack, depth)

proc renderTemplate(cache: var TemplateCache, tpl: Template, data: JsonNode, overrides: TemplateDefs, stack: var seq[string], depth: int, acc: var string) {.gcsafe.} =
  let defs = combineDefinitions(tpl.definitions, overrides)
  renderNodes(cache, tpl.nodes, data, newJNull(), acc, defs, stack, depth)

proc render(cache: var TemplateCache, name: string, data: JsonNode): string {.gcsafe.} =
  let tpl = loadTemplate(cache, name)
  var acc = newStringOfCap(256)
  var stack: seq[string] = @[]
  stack.add(name)
  renderTemplate(cache, tpl, data, tpl.definitions, stack, 0, acc)
  discard stack.pop()
  result = acc

# ------------------
# Static File Server
# ------------------

proc guessMimeType(path: string): string =
  let ext = splitFile(path).ext.toLowerAscii()
  case ext
  of ".html", ".htm": result = "text/html; charset=utf-8"
  of ".css": result = "text/css"
  of ".js": result = "application/javascript"
  of ".json": result = "application/json"
  of ".png": result = "image/png"
  of ".jpg", ".jpeg": result = "image/jpeg"
  of ".gif": result = "image/gif"
  of ".svg": result = "image/svg+xml"
  of ".txt": result = "text/plain; charset=utf-8"
  else: result = defaultMimeType

proc serveFile(path: string): Response =
  if not fileExists(path):
    return notFoundResponse()
  var resp = newResponse(200, readFile(path))
  resp.headers["content-type"] = guessMimeType(path)
  resp

proc cleanStaticPath(baseDir, rel: string): string =
  let baseAbs = absolutePath(baseDir)
  var segments: seq[string]
  for raw in rel.split({'/', '\\'}):
    let part = raw.strip()
    if part.len == 0 or part == ".":
      continue
    if part == "..":
      return ""
    segments.add(part)
  var target = baseAbs
  for segment in segments:
    target = joinPath(target, segment)
  if not target.startsWith(baseAbs):
    return ""
  target

proc serveDir(baseDir, urlPrefix: string): Handler =
  let prefix = normalizeUrlPath(urlPrefix)
  result = proc (req: Request): Response {.gcsafe.} =
    var relative = ""
    if prefix == "/":
      relative = req.path
    else:
      if not req.path.startsWith(prefix):
        return notFoundResponse()
      if req.path.len > prefix.len:
        relative = req.path[prefix.len .. ^1]
      else:
        relative = ""
    if relative.startsWith('/'):
      if relative.len == 1:
        relative = ""
      else:
        relative = relative[1 .. ^1]
    let target = cleanStaticPath(baseDir, relative)
    if target.len == 0 or dirExists(target) or not fileExists(target):
      return notFoundResponse()
    serveFile(target)

# ---------------------
# Site configuration
# ---------------------

type
  PageSpec = object
    path: string
    templateFile: string
    dataFile: string
    raw: JsonNode
    paramNames: seq[string]
    dataSegments: seq[DataSegment]

  Site = ref object
    dir: string
    configPath: string
    contentDir: string
    pagesDir: string
    templatesDir: string
    staticDir: string
    configNode: JsonNode
    globalData: JsonNode
    notFoundTemplate: string
    pages: seq[PageSpec]
    devMode: bool
    configMtime: Time
    globalMtime: Time
    cache: TemplateCache
    staticHandler: Handler

proc sitePath(site: Site, parts: varargs[string]): string =
  var path = site.dir
  for part in parts:
    path = path / part
  path

proc readJsonFile(path: string): JsonNode =
  if not fileExists(path):
    raise newException(IOError, "missing JSON file: " & path)
  let text = readFile(path)
  try:
    result = parseJson(text)
  except JsonParsingError as err:
    raise newException(ValueError, "invalid JSON in " & path & ": " & err.msg)

proc requireString(node: JsonNode, key: string): string =
  if node.kind != JObject or not node.hasKey(key):
    raise newException(ValueError, "missing required key '" & key & "'")
  let value = node[key]
  if value.kind != JString:
    raise newException(ValueError, "key '" & key & "' must be a string")
  value.getStr()

proc getString(node: JsonNode, key, fallback: string): string =
  if node.kind != JObject or not node.hasKey(key):
    return fallback
  let value = node[key]
  if value.kind != JString:
    return fallback
  value.getStr()

proc initSite(dir: string, devMode: bool): Site =
  result = Site(
    dir: absolutePath(dir),
    configPath: absolutePath(dir / "config.json"),
    contentDir: absolutePath(dir / "content"),
    pagesDir: absolutePath(dir / "content" / "pages"),
    templatesDir: absolutePath(dir / "templates"),
    staticDir: absolutePath(dir / "static"),
    notFoundTemplate: "404.html",
    devMode: devMode,
    configNode: newJObject(),
    globalData: newJObject(),
    pages: @[],
    cache: initTemplateCache(absolutePath(dir / "templates")),
    staticHandler: nil
  )

proc ensureSiteLayout(site: Site) =
  if not dirExists(site.dir):
    raise newException(IOError, "site directory not found: " & site.dir)
  if not fileExists(site.configPath):
    raise newException(IOError, "missing config.json in site directory")
  if not dirExists(site.templatesDir):
    raise newException(IOError, "templates directory missing in site")
  if not dirExists(site.contentDir):
    raise newException(IOError, "content directory missing in site")
  if not dirExists(site.pagesDir):
    raise newException(IOError, "content/pages directory missing in site")

proc loadSiteConfig(site: Site) =
  let info = getFileInfo(site.configPath)
  site.configMtime = info.lastWriteTime
  let node = readJsonFile(site.configPath)
  if node.kind != JObject:
    raise newException(ValueError, "config.json must contain a JSON object")
  site.configNode = node
  site.notFoundTemplate = getString(node, "notFoundTemplate", "404.html")

  site.pages.setLen(0)
  if node.hasKey("pages"):
    let pagesNode = node["pages"]
    if pagesNode.kind != JArray:
      raise newException(ValueError, "config.pages must be an array")
    for pageNode in pagesNode.items:
      if pageNode.kind != JObject:
        raise newException(ValueError, "page entry must be an object")
      let rawPath = requireString(pageNode, "path")
      let templateFile = requireString(pageNode, "template")
      let dataFile = getString(pageNode, "data", "")
      let normalizedPath = normalizeUrlPath(rawPath)
      var pattern = parsePattern(normalizedPath)
      var paramNames: seq[string] = @[]
      var allowed = initHashSet[string]()
      for segment in pattern.segments:
        if segment.kind == segParam:
          paramNames.add(segment.name)
          allowed.incl(segment.name)
      var dataSegments: seq[DataSegment] = @[]
      if dataFile.len > 0:
        dataSegments = parseDataPath(dataFile, allowed)
      site.pages.add(PageSpec(
        path: normalizedPath,
        templateFile: templateFile,
        dataFile: dataFile,
        raw: pageNode,
        paramNames: paramNames,
        dataSegments: dataSegments
      ))
  else:
    raise newException(ValueError, "config.json must define a 'pages' array")

proc loadGlobalData(site: Site) =
  let globalPath = sitePath(site, "content", "global.json")
  if fileExists(globalPath):
    let info = getFileInfo(globalPath)
    site.globalMtime = info.lastWriteTime
    try:
      site.globalData = readJsonFile(globalPath)
    except CatchableError:
      site.globalData = newJObject()
  else:
    site.globalData = newJObject()
    site.globalMtime = Time()

proc refreshSite(site: Site) =
  if not site.devMode:
    return
  try:
    let cfgInfo = getFileInfo(site.configPath)
    if cfgInfo.lastWriteTime > site.configMtime:
      loadSiteConfig(site)
  except OSError:
    discard
  let globalPath = sitePath(site, "content", "global.json")
  if fileExists(globalPath):
    let info = getFileInfo(globalPath)
    if info.lastWriteTime > site.globalMtime:
      loadGlobalData(site)

proc resolveContentPath(site: Site, relative: string, baseDir: string): string =
  if relative.len == 0:
    return ""
  if relative.startsWith('/') or relative.startsWith('\\'):
    return ""
  let baseAbs = absolutePath(baseDir)
  let target = absolutePath(baseDir / relative)
  if not target.startsWith(baseAbs):
    return ""
  target

proc loadPageData(site: Site, spec: PageSpec, params: Headers): tuple[data: JsonNode, found: bool] =
  if spec.dataSegments.len == 0:
    return (newJObject(), true)

  var builder = newStringOfCap(64)
  for segment in spec.dataSegments:
    case segment.kind
    of dskLiteral:
      builder.add(segment.value)
    of dskParam:
      if segment.value notin params:
        echo "[info] missing route parameter '" & segment.value & "' for data path in " & spec.path
        return (newJObject(), false)
      builder.add(params[segment.value])

  let relativePath = builder
  let baseDir =
    if spec.dataFile.find({'/', '\\'}) >= 0: site.contentDir else: site.pagesDir
  let fullPath = resolveContentPath(site, relativePath, baseDir)
  if fullPath.len == 0:
    echo "[warn] unsafe data path resolved for " & spec.path & ": " & relativePath
    return (newJObject(), false)
  if not fileExists(fullPath):
    echo "[info] data file not found for " & spec.path & ": " & relativePath
    return (newJObject(), false)

  result.found = true
  result.data = readJsonFile(fullPath)

proc cloneOrEmpty(node: JsonNode): JsonNode =
  if node.isNil:
    return newJObject()
  node.copy()

proc mergeData(site: Site, pageData: JsonNode, req: Request, pageConfig: JsonNode): JsonNode =
  result = newJObject()
  result["config"] = cloneOrEmpty(site.configNode)
  result["global"] = cloneOrEmpty(site.globalData)
  result["page"] = if pageData.isNil: newJObject() else: pageData.copy()
  if not pageConfig.isNil:
    result["pageConfig"] = pageConfig.copy()
  var requestNode = newJObject()
  requestNode["path"] = %req.path
  requestNode["method"] = %req.httpMethod
  var paramsNode = newJObject()
  for key, value in req.params.pairs:
    paramsNode[key] = %value
  requestNode["params"] = paramsNode
  result["request"] = requestNode

proc envBool(name: string): bool =
  let raw = getEnv(name, "").toLowerAscii()
  raw in ["1", "true", "yes", "on"]

proc makePageHandler(site: Site, spec: PageSpec): Handler =
  result = proc (req: Request): Response {.gcsafe.} =
    if site.devMode:
      refreshSite(site)
    try:
      let (pageData, found) = site.loadPageData(spec, req.params)
      if not found:
        return notFoundResponse()
      if site.devMode:
        var paramPairs: seq[string] = @[]
        for key, val in req.params.pairs:
          paramPairs.add(key & "=" & val)
        echo "[debug] page data for ", spec.path, " (", paramPairs.join(","), ") => ", $pageData
      let merged = mergeData(site, pageData, req, spec.raw)
      if site.devMode:
        echo "[debug] merged payload => ", $merged
      let body = render(site.cache, spec.templateFile, merged)
      var resp = newResponse(200, body)
      resp.headers["content-type"] = "text/html; charset=utf-8"
      return resp
    except CatchableError as err:
      echo "[error] page render failed for " & spec.path & ": " & err.msg
      return serverErrorResponse()

proc renderNotFound(site: Site, req: Request): Response =
  if site.devMode:
    refreshSite(site)
  try:
    let data = mergeData(site, newJObject(), req, newJObject())
    let tplName = site.notFoundTemplate
    if tplName.len > 0:
      let body = render(site.cache, tplName, data)
      var resp = newResponse(404, body)
      resp.headers["content-type"] = "text/html; charset=utf-8"
      return resp
  except CatchableError as err:
    echo "[warn] 404 template failed: " & err.msg
  newResponse(404, "page not found")

# ----------------------
# Async server plumbing
# ----------------------

proc headersToTable(src: HttpHeaders): Headers =
  var table = newHeaders()
  for key, value in src.pairs:
    table[key.toLowerAscii()] = value
  table

proc tableToHttpHeaders(src: Headers): HttpHeaders =
  var pairs: seq[(string, string)]
  for key, value in src.pairs:
    pairs.add((key, value))
  newHttpHeaders(pairs)

proc toFrameworkRequest(req: asynchttpserver.Request, params: Headers): Request =
  result.httpMethod = $req.reqMethod
  result.path = normalizeUrlPath(req.url.path)
  result.headers = headersToTable(req.headers)
  result.body = req.body
  result.params = params

proc respondAsync(req: asynchttpserver.Request, resp: Response): Future[void] =
  var headers = resp.headers
  if "content-type" notin headers:
    headers["content-type"] = "text/plain; charset=utf-8"
  let status = if resp.status in 100..599: HttpCode(resp.status) else: Http200
  return req.respond(status, resp.body, tableToHttpHeaders(headers))

proc runServer(port: int, site: Site, router: Router) {.async.} =
  var server = newAsyncHttpServer()
  server.listen(Port(port))
  let actualPort = server.getPort
  echo "listening on http://127.0.0.1:" & $actualPort.uint16

  proc onRequest(req: asynchttpserver.Request) {.async, gcsafe.} =
    try:
      if site.devMode:
        refreshSite(site)
      let normalizedPath = normalizeUrlPath(req.url.path)
      let methodStr = $req.reqMethod
      if not site.staticHandler.isNil and (methodStr == "GET" or methodStr == "HEAD") and normalizedPath.startsWith("/static"):
        var staticReq = Request(
          httpMethod: methodStr,
          path: normalizedPath,
          headers: headersToTable(req.headers),
          body: "",
          params: newHeaders()
        )
        let resp = site.staticHandler(staticReq)
        await req.respondAsync(resp)
        return
      let matchResult = match(router, methodStr, normalizedPath)
      if matchResult.isNone:
        let frameworkReq = toFrameworkRequest(req, newHeaders())
        await req.respondAsync(renderNotFound(site, frameworkReq))
        return
      let (handler, routeParams) = matchResult.get()
      var frameworkReq = toFrameworkRequest(req, routeParams)
      echo $req.reqMethod & " " & req.url.path
      let response = handler(frameworkReq)
      await req.respondAsync(response)
    except CatchableError:
      await req.respondAsync(serverErrorResponse())

  while true:
    if server.shouldAcceptRequest():
      await server.acceptRequest(onRequest)
    else:
      await sleepAsync(50)

# ----
# Main
# ----

proc main() {.async.} =
  let portStr = getEnv("PORT", "")
  var port = defaultPort
  if portStr.len > 0:
    try:
      port = parseInt(portStr)
    except ValueError:
      echo "[warn] invalid PORT value '" & portStr & "', falling back to " & $defaultPort
      port = defaultPort

  let siteDir = getEnv("SITE_DIR", ".")
  let devMode = envBool("DEV_MODE")
  var site = initSite(siteDir, devMode)

  try:
    ensureSiteLayout(site)
    loadSiteConfig(site)
    loadGlobalData(site)
  except CatchableError as err:
    echo "[fatal] site initialization failed: " & err.msg
    return

  if site.devMode:
    echo "[info] development mode enabled"

  var router = initRouter()

  var hasHealthRoute = false
  for spec in site.pages:
    if spec.path == "/health":
      hasHealthRoute = true
    router.addRoute("GET", spec.path, makePageHandler(site, spec))

  if dirExists(site.staticDir):
    site.staticHandler = serveDir(site.staticDir, "/static")
  else:
    echo "[info] static directory missing, /static disabled"

  if not hasHealthRoute:
    router.addRoute("GET", "/health", proc (req: Request): Response {.gcsafe.} =
      var resp = newResponse(200, "ok\n")
      resp.headers["content-type"] = "text/plain; charset=utf-8"
      resp
    )

  await runServer(port, site, router)

when isMainModule:
  waitFor main()