///|
/// Internal configuration for the admin dashboard; captured by the route
/// handlers mounted in `App::admin`.
priv struct AdminConfig {
  db : @db.Database
  tables : Array[@db.Table]
  prefix : String
  token : String
}

///|
/// Mount the admin dashboard at `prefix` (default `/admin`) for `tables`,
/// reading and writing through `db`. When `token~` is non-empty, every
/// admin request must carry it as `?token=...` (or the hidden form field
/// on POSTs) — basic protection, not production-grade auth.
pub fn App::admin(
  self : App,
  db : @db.Database,
  tables : Array[@db.Table],
  prefix~ : String = "/admin",
  token~ : String = "",
) -> Unit {
  let config : AdminConfig = { db, tables, prefix, token, }
  self.get(prefix, admin_index_handler(config))
  for t in tables {
    self.get(prefix + "/" + t.name, admin_list_handler(config, t))
    self.get(prefix + "/" + t.name + "/new", admin_form_handler(config, t))
    self.post(prefix + "/" + t.name, admin_create_handler(config, t))
    self.get(prefix + "/" + t.name + "/:id/edit", admin_edit_handler(config, t))
    self.post(prefix + "/" + t.name + "/:id", admin_update_handler(config, t))
    self.post(
      prefix + "/" + t.name + "/:id/delete",
      admin_delete_handler(config, t),
    )
  }
}

///|
fn admin_index_handler(config : AdminConfig) -> (Context) -> Context {
  fn(ctx) {
    if !admin_authorized(config, ctx, admin_form(ctx)) {
      return admin_unauthorized(ctx)
    }
    ctx.html(@admin.render_index(config.prefix, config.tables))
  }
}

///|
fn admin_list_handler(
  config : AdminConfig,
  t : @db.Table,
) -> (Context) -> Context {
  fn(ctx) {
    if !admin_authorized(config, ctx, admin_form(ctx)) {
      return admin_unauthorized(ctx)
    }
    let q = ctx.query_param("q").unwrap_or("")
    let page = admin_page_number(ctx)
    let page_size = 25
    let rows = config.db.select_raw(
      t,
      admin_search_where(t, q),
      [],
      limit=page_size,
      offset=(page - 1) * page_size,
    )
    let has_more = rows.length() == page_size
    ctx.html(@admin.render_list(config.prefix, t, rows, page, has_more, q))
  }
}

///|
fn admin_form_handler(
  config : AdminConfig,
  t : @db.Table,
) -> (Context) -> Context {
  fn(ctx) {
    if !admin_authorized(config, ctx, admin_form(ctx)) {
      return admin_unauthorized(ctx)
    }
    ctx.html(@admin.render_form(config.prefix, t, None))
  }
}

///|
fn admin_edit_handler(
  config : AdminConfig,
  t : @db.Table,
) -> (Context) -> Context {
  fn(ctx) {
    if !admin_authorized(config, ctx, admin_form(ctx)) {
      return admin_unauthorized(ctx)
    }
    match admin_parse_id(ctx) {
      Some(id) =>
        match config.db.find_raw(t, id) {
          Some(row) => ctx.html(@admin.render_form(config.prefix, t, Some(row)))
          None => {
            let ctx = ctx.status(404)
            ctx.text("Not Found")
          }
        }
      None => ctx.status(400).text("invalid id")
    }
  }
}

///|
fn admin_create_handler(
  config : AdminConfig,
  t : @db.Table,
) -> (Context) -> Context {
  fn(ctx) {
    let form = @admin.form_decode(ctx.body())
    if !admin_authorized(config, ctx, form) {
      return admin_unauthorized(ctx)
    }
    if config.db.insert_raw(t, admin_row_from_form(t, form, false)) {
      admin_see_other(ctx, config.prefix + "/" + t.name)
    } else {
      ctx.status(500).text("insert failed")
    }
  }
}

///|
fn admin_update_handler(
  config : AdminConfig,
  t : @db.Table,
) -> (Context) -> Context {
  fn(ctx) {
    let form = @admin.form_decode(ctx.body())
    if !admin_authorized(config, ctx, form) {
      return admin_unauthorized(ctx)
    }
    match admin_parse_id(ctx) {
      Some(id) =>
        if config.db.update_raw(t, id, admin_row_from_form(t, form, true)) {
          admin_see_other(ctx, config.prefix + "/" + t.name)
        } else {
          ctx.status(500).text("update failed")
        }
      None => ctx.status(400).text("invalid id")
    }
  }
}

///|
fn admin_delete_handler(
  config : AdminConfig,
  t : @db.Table,
) -> (Context) -> Context {
  fn(ctx) {
    let form = @admin.form_decode(ctx.body())
    if !admin_authorized(config, ctx, form) {
      return admin_unauthorized(ctx)
    }
    match admin_parse_id(ctx) {
      Some(id) => {
        let _ = config.db.delete_raw(t, id)
        admin_see_other(ctx, config.prefix + "/" + t.name)
      }
      None => ctx.status(400).text("invalid id")
    }
  }
}

///|
fn admin_row_from_form(
  t : @db.Table,
  form : Map[String, String],
  editing : Bool,
) -> Map[String, Json] {
  let row : Map[String, Json] = Map([])
  for col in t.columns {
    if col.primary {
      continue
    }
    match form.get(col.name) {
      Some(value) => row[col.name] = Json(value)
      None =>
        match col.col_type {
          BoolT => row[col.name] = Json(false)
          _ => if editing { row[col.name] = Json("") } else { () }
        }
    }
  }
  row
}

///|
/// Basic token check: an empty token disables the guard. GET requests
/// present the token via ?token=; POSTs may also use a form field.
fn admin_authorized(
  config : AdminConfig,
  ctx : Context,
  form : Map[String, String],
) -> Bool {
  if config.token == "" {
    return true
  }
  ctx.query_param("token") == Some(config.token) ||
  form.get("token") == Some(config.token)
}

///|
fn admin_unauthorized(ctx : Context) -> Context {
  ctx.status(401).html(@admin.render_unauthorized())
}

///|
fn admin_see_other(ctx : Context, location : String) -> Context {
  let ctx = ctx.status(303)
  let ctx = ctx.header("Location", location)
  ctx.text("")
}

///|
fn admin_page_number(ctx : Context) -> Int32 {
  match ctx.query_param("page") {
    Some(raw) =>
      match admin_parse_int(raw) {
        Some(n) => if n < 1 { 1 } else { n }
        None => 1
      }
    None => 1
  }
}

///|
fn admin_parse_id(ctx : Context) -> Int32? {
  match ctx.param("id") {
    Some(raw) => admin_parse_int(raw)
    None => None
  }
}

///|
fn admin_parse_int(raw : String) -> Int32? {
  let mut value = 0
  let mut negative = false
  let mut digits = 0
  for ch in raw {
    if ch == '-' && digits == 0 && !negative {
      negative = true
    } else if ch >= '0' && ch <= '9' {
      value = value * 10 + ch.to_int() - 48
      digits = digits + 1
    } else {
      return None
    }
  }
  if digits == 0 {
    return None
  }
  Some(if negative { -value } else { value })
}

///|
/// WHERE clause filtering text columns with LIKE, or "1=1" when the query
/// is empty. Single quotes in the query are doubled.
fn admin_search_where(t : @db.Table, q : String) -> String {
  if q == "" {
    return "1=1"
  }
  let escaped = admin_double_quotes(q)
  let clauses = []
  for col in t.columns {
    if col.col_type is TextT {
      clauses.push(admin_quote_ident(col.name) + " LIKE '%" + escaped + "%'")
    }
  }
  if clauses.length() == 0 {
    return "1=1"
  }
  let mut where_clause = ""
  for i, clause in clauses {
    if i > 0 {
      where_clause = where_clause + " OR "
    }
    where_clause = where_clause + clause
  }
  where_clause
}

///|
fn admin_quote_ident(name : String) -> String {
  "\"" + admin_double_quotes(name) + "\""
}

///|
fn admin_double_quotes(s : String) -> String {
  let mut out = ""
  for ch in s {
    if ch == '\'' {
      out = out + "''"
    } else {
      out = out + ch.to_string()
    }
  }
  out
}

///|
fn admin_form(ctx : Context) -> Map[String, String] {
  @admin.form_decode(ctx.body())
}