///|
pub struct WechatPage {
  wxml : String
  wxss : String
  js : String
  json : String
  plan : @core.RenderPlan
  render_budget : @core.RenderBudget
  scene_assets : Array[WechatSceneAsset]
} derive(Debug, Eq)

///|
pub struct WechatSceneAsset {
  id : String
  kind : String
  src : String
  source_path : String
  remote : Bool
  estimated_bytes : Int
} derive(Debug, Eq)

///|
pub struct WechatEventPatch {
  message : String
  patches : Array[@core.Patch]
} derive(Debug, Eq)

///|
pub struct WechatRuntime {
  initial_data_json : String
  event_patches : Array[WechatEventPatch]
  page_methods_js : String
} derive(Debug, Eq)

///|
pub struct WechatProjectPage {
  name : String
  route : String
  page : WechatPage
  runtime : WechatRuntime
} derive(Debug, Eq)

///|
pub struct WechatFile {
  path : String
  content : String
} derive(Debug, Eq)

///|
pub struct WechatManifestFile {
  path : String
  kind : String
  route : String
  bytes : Int
} derive(Debug, Eq)

///|
pub struct WechatManifestSceneAsset {
  route : String
  id : String
  kind : String
  src : String
  remote : Bool
  estimated_bytes : Int
} derive(Debug, Eq)

///|
pub struct WechatManifestPage {
  name : String
  route : String
  node_count : Int
  depth : Int
  windowed_list_count : Int
  list_total_count : Int
  list_visible_count : Int
  unwindowed_list_child_count : Int
  unkeyed_list_child_count : Int
  duplicate_list_key_count : Int
  component_count : Int
  event_count : Int
  backend_event_count : Int
  backend_stream_count : Int
  backend_review_count : Int
  backend_cancel_count : Int
  backend_retry_count : Int
  scene_count : Int
  scene_marker_count : Int
  scene_visible_marker_count : Int
  scene_region_count : Int
  scene_visible_region_count : Int
  scene_asset_count : Int
  scene_assets : Array[WechatManifestSceneAsset]
  scene_package_bytes : Int
  scene_remote_asset_count : Int
  degraded_scene_count : Int
  missing_event_patch_count : Int
  orphan_event_patch_count : Int
  initial_data_bytes : Int
  event_patch_bytes : Int
  event_patch_operation_count : Int
  first_screen_bytes : Int
  update_payload_bytes : Int
  update_operation_count : Int
  file_bytes : Int
  render_budget : @core.RenderBudget
  render_diagnostics : Array[String]
  diagnostics : Array[String]
  diagnostic_count : Int
  status : String
} derive(Debug, Eq)

///|
pub struct WechatProjectManifest {
  name : String
  entry_route : String
  page_count : Int
  app_file_count : Int
  app_total_bytes : Int
  scene_asset_count : Int
  scene_remote_asset_count : Int
  scene_package_bytes : Int
  diagnostic_route_count : Int
  diagnostic_count : Int
  pages : Array[WechatManifestPage]
  files : Array[WechatManifestFile]
  scene_assets : Array[WechatManifestSceneAsset]
  summary : String
} derive(Debug, Eq)

///|
pub struct WechatProject {
  name : String
  route : String
  page : WechatPage
  pages : Array[WechatProjectPage]
  page_count : Int
  manifest : WechatProjectManifest
  files : Array[WechatFile]
  total_bytes : Int
  report : WechatBuildReport
  summary : String
} derive(Debug, Eq)

///|
pub fn project_with_report(
  project : WechatProject,
  report : WechatBuildReport,
) -> WechatProject {
  { ..project, report, }
}

///|
pub fn page_with_wxss(page : WechatPage, wxss : String) -> WechatPage {
  { ..page, wxss, }
}

///|
pub fn project_page_with_wxss(
  page : WechatProjectPage,
  wxss : String,
) -> WechatProjectPage {
  { ..page, page: page_with_wxss(page.page, wxss) }
}

///|
pub fn project_page_with_runtime(
  page : WechatProjectPage,
  runtime : WechatRuntime,
) -> WechatProjectPage {
  { ..page, runtime, }
}

///|
pub fn generate_page(name : String, root : @core.Node) -> WechatPage {
  generate_page_with_runtime(name, root, default_runtime())
}

///|
pub fn generate_page_with_runtime(
  name : String,
  root : @core.Node,
  runtime : WechatRuntime,
) -> WechatPage {
  generate_page_with_runtime_and_budget(
    name,
    root,
    runtime,
    @core.default_render_budget(),
  )
}

///|
pub fn generate_page_with_runtime_and_budget(
  name : String,
  root : @core.Node,
  runtime : WechatRuntime,
  render_budget : @core.RenderBudget,
) -> WechatPage {
  let plan = @core.plan_with_budget(root, Wechat, render_budget)
  {
    wxml: render_node(root, @core.adapter(Wechat)),
    wxss: default_wxss(),
    js: page_js(name, plan, runtime),
    json: "{ \"navigationBarTitleText\": \"\{escape_json(name)}\" }",
    plan,
    render_budget,
    scene_assets: collect_scene_assets(root),
  }
}

///|
fn generate_project_page_with_runtime(
  name : String,
  route : String,
  root : @core.Node,
  runtime : WechatRuntime,
  render_budget : @core.RenderBudget,
) -> WechatPage {
  let plan = @core.plan_with_budget(root, Wechat, render_budget)
  {
    wxml: render_node(root, @core.adapter(Wechat)),
    wxss: "",
    js: project_page_js(name, route, plan, runtime),
    json: "{ \"navigationBarTitleText\": \"\{escape_json(name)}\" }",
    plan,
    render_budget,
    scene_assets: collect_scene_assets(root),
  }
}

///|
pub fn generate_project(
  name~ : String,
  route? : String = "pages/index/index",
  root~ : @core.Node,
) -> WechatProject {
  generate_project_with_runtime_and_budget(
    name~,
    route~,
    root~,
    runtime=default_runtime(),
    render_budget=@core.default_render_budget(),
  )
}

///|
pub fn generate_project_with_runtime(
  name~ : String,
  route? : String = "pages/index/index",
  root~ : @core.Node,
  runtime~ : WechatRuntime,
) -> WechatProject {
  generate_project_with_runtime_and_budget(
    name~,
    route~,
    root~,
    runtime~,
    render_budget=@core.default_render_budget(),
  )
}

///|
pub fn generate_project_with_runtime_and_budget(
  name~ : String,
  route? : String = "pages/index/index",
  root~ : @core.Node,
  runtime~ : WechatRuntime,
  render_budget~ : @core.RenderBudget,
) -> WechatProject {
  generate_project_from_pages(name~, pages=[
    project_page(name~, route~, root~, runtime~, render_budget~),
  ])
}

///|
pub fn generate_project_with_runtime_and_backend(
  name~ : String,
  route? : String = "pages/index/index",
  root~ : @core.Node,
  runtime~ : WechatRuntime,
  backend_contract~ : @effects.BackendContract,
) -> WechatProject {
  generate_project_with_runtime_and_budget_and_backend(
    name~,
    route~,
    root~,
    runtime~,
    render_budget=@core.default_render_budget(),
    backend_contract~,
  )
}

///|
pub fn generate_project_with_runtime_and_budget_and_backend(
  name~ : String,
  route? : String = "pages/index/index",
  root~ : @core.Node,
  runtime~ : WechatRuntime,
  render_budget~ : @core.RenderBudget,
  backend_contract~ : @effects.BackendContract,
) -> WechatProject {
  generate_project_from_pages_with_backend(
    name~,
    pages=[project_page(name~, route~, root~, runtime~, render_budget~)],
    backend_contract~,
  )
}

///|
pub fn project_page(
  name~ : String,
  route~ : String,
  root~ : @core.Node,
  runtime? : WechatRuntime = default_runtime(),
  render_budget? : @core.RenderBudget = @core.default_render_budget(),
) -> WechatProjectPage {
  {
    name,
    route,
    page: generate_project_page_with_runtime(
      name, route, root, runtime, render_budget,
    ),
    runtime,
  }
}

///|
pub fn generate_project_from_pages(
  name~ : String,
  pages~ : Array[WechatProjectPage],
) -> WechatProject {
  generate_project_from_pages_internal(name~, pages~, backend_contract=None)
}

///|
pub fn generate_project_from_pages_with_backend(
  name~ : String,
  pages~ : Array[WechatProjectPage],
  backend_contract~ : @effects.BackendContract,
) -> WechatProject {
  generate_project_from_pages_internal(
    name~,
    pages~,
    backend_contract=Some(backend_contract),
  )
}

///|
fn generate_project_from_pages_internal(
  name~ : String,
  pages~ : Array[WechatProjectPage],
  backend_contract~ : @effects.BackendContract?,
) -> WechatProject {
  if pages.length() == 0 {
    panic()
  }
  let generated_pages = project_pages_for_backend(pages, backend_contract)
  let entry = generated_pages[0]
  let shared_wxss = repeated_project_wxss(generated_pages)
  let files : Array[WechatFile] = [
    wechat_file("app.json", app_json(generated_pages)),
    wechat_file("project.config.json", project_config_json(name)),
    wechat_file("sitemap.json", sitemap_json()),
    wechat_file("bunnia.runtime.js", runtime_js()),
    wechat_file("app.wxss", project_app_wxss(shared_wxss)),
  ]
  match backend_contract {
    Some(contract) =>
      files.push(
        wechat_file(
          "bunnia.backend.js",
          generate_request_adapter(
            backend_contract_for_pages(contract, generated_pages),
          ),
        ),
      )
    None => ()
  }
  for item in generated_pages {
    files.push(wechat_file("\{item.route}.wxml", item.page.wxml))
    let compacted_wxss = compact_wxss(item.page.wxss)
    let unique_wxss = project_unique_wxss(compacted_wxss, shared_wxss)
    if unique_wxss != "" {
      files.push(wechat_file("\{item.route}.wxss", unique_wxss))
    }
    files.push(
      wechat_file(
        page_payload_file_path(item.route),
        page_payload_js(item.runtime, item.page.plan),
      ),
    )
    files.push(wechat_file("\{item.route}.js", item.page.js))
    files.push(wechat_file("\{item.route}.json", item.page.json))
  }
  append_packaged_scene_asset_files(files, generated_pages)
  let manifest = project_manifest(
    name,
    entry.route,
    generated_pages,
    files,
    backend_contract,
  )
  files.push(wechat_file("bunnia.manifest.json", manifest_json(manifest)))
  let total = total_file_bytes(files, generated_pages)
  let report = report_project_pages(
    files,
    generated_pages,
    default_build_budget(),
  )
  {
    name,
    route: entry.route,
    page: entry.page,
    pages: generated_pages,
    page_count: generated_pages.length(),
    manifest,
    files,
    total_bytes: total,
    report,
    summary: "Bunnia WeChat project: pages=\{generated_pages.length()} files=\{files.length()} bytes=\{total} route=\{entry.route}",
  }
}

///|
fn project_app_wxss(shared_wxss : Array[String]) -> String {
  let out = StringBuilder::new()
  out.write_string(compact_wxss(default_wxss()))
  for wxss in shared_wxss {
    out.write_string(wxss)
  }
  out.to_string()
}

///|
fn repeated_project_wxss(pages : Array[WechatProjectPage]) -> Array[String] {
  let repeated : Array[String] = []
  let shared_prefix = longest_shared_project_wxss_prefix(pages)
  if shared_prefix != "" {
    repeated.push(shared_prefix)
  }
  for item in pages {
    let wxss = compact_wxss(item.page.wxss)
    if wxss != "" &&
      !repeated.contains(wxss) &&
      project_wxss_occurrence_count(pages, wxss) > 1 {
      repeated.push(wxss)
    }
  }
  repeated
}

///|
fn longest_shared_project_wxss_prefix(
  pages : Array[WechatProjectPage],
) -> String {
  let mut best = ""
  let mut best_savings = 0
  for i in 0.. 1 && savings > best_savings {
        best = prefix
        best_savings = savings
      }
    }
  }
  best
}

///|
fn project_wxss_prefix_occurrence_count(
  pages : Array[WechatProjectPage],
  prefix : String,
) -> Int {
  if prefix == "" {
    return 0
  }
  let mut count = 0
  for item in pages {
    if compact_wxss(item.page.wxss).has_prefix(prefix) {
      count += 1
    }
  }
  count
}

///|
fn shared_css_rule_prefix(left : String, right : String) -> String {
  let limit = if left.length() < right.length() {
    left.length()
  } else {
    right.length()
  }
  let mut rule_end = 0
  for i in 0.. String {
  for chunk in shared {
    if wxss == chunk {
      return ""
    }
  }
  let mut shared_prefix = ""
  for chunk in shared {
    if wxss.has_prefix(chunk) && chunk.length() > shared_prefix.length() {
      shared_prefix = chunk
    }
  }
  if shared_prefix == "" {
    wxss
  } else {
    wxss.sub(start=shared_prefix.length(), end=wxss.length()).to_owned()
  }
}

///|
fn project_wxss_occurrence_count(
  pages : Array[WechatProjectPage],
  wxss : String,
) -> Int {
  let mut count = 0
  for item in pages {
    if compact_wxss(item.page.wxss) == wxss {
      count += 1
    }
  }
  count
}

///|
fn project_pages_for_backend(
  pages : Array[WechatProjectPage],
  backend_contract : @effects.BackendContract?,
) -> Array[WechatProjectPage] {
  let output : Array[WechatProjectPage] = []
  for item in pages {
    match backend_contract {
      Some(contract) =>
        output.push(project_page_with_backend_js(item, contract))
      None => output.push(item)
    }
  }
  output
}

///|
fn backend_contract_for_pages(
  contract : @effects.BackendContract,
  pages : Array[WechatProjectPage],
) -> @effects.BackendContract {
  let endpoints : Array[@effects.BackendEndpoint] = []
  for endpoint in contract.endpoints {
    for page in pages {
      if page.page.plan.event_messages.contains(endpoint.id) {
        endpoints.push(endpoint)
        break
      }
    }
  }
  @effects.backend_contract(
    id=contract.id,
    base_url_key=contract.base_url_key,
    session_token_key=contract.session_token_key,
    session_header_key=contract.session_header_key,
    endpoints~,
  )
}

///|
fn project_page_with_backend_js(
  item : WechatProjectPage,
  contract : @effects.BackendContract,
) -> WechatProjectPage {
  let js = if backend_has_reachable_event(contract, item.page.plan) {
    project_page_js_with_backend(
      item.name,
      item.route,
      item.page.plan,
      item.runtime,
      contract,
    )
  } else {
    project_page_js(item.name, item.route, item.page.plan, item.runtime)
  }
  {
    name: item.name,
    route: item.route,
    page: {
      wxml: item.page.wxml,
      wxss: item.page.wxss,
      js,
      json: item.page.json,
      plan: item.page.plan,
      render_budget: item.page.render_budget,
      scene_assets: item.page.scene_assets,
    },
    runtime: item.runtime,
  }
}

///|
fn backend_has_reachable_event(
  contract : @effects.BackendContract,
  plan : @core.RenderPlan,
) -> Bool {
  for endpoint in contract.endpoints {
    if plan.event_messages.contains(endpoint.id) {
      return true
    }
  }
  false
}

///|
pub fn default_runtime() -> WechatRuntime {
  { initial_data_json: "{}", event_patches: [], page_methods_js: "" }
}

///|
pub fn runtime(
  initial_data_json? : String = "{}",
  event_patches? : Array[WechatEventPatch] = [],
  page_methods_js? : String = "",
) -> WechatRuntime {
  { initial_data_json, event_patches, page_methods_js }
}

///|
pub fn event_patch(
  message : String,
  patches : Array[@core.Patch],
) -> WechatEventPatch {
  { message, patches }
}

///|
pub fn generate_set_data_call(patches : Array[@core.Patch]) -> String {
  "__bunniaApplyPatches(this, \{patches_to_operations(patches)})"
}

///|
pub fn generate_runtime_js() -> String {
  runtime_js()
}

///|
pub fn generate_request_adapter(contract : @effects.BackendContract) -> String {
  "const __bunniaBackend = \{backend_contract_object(contract)}\n" +
  "const __bk='runId reviewId decision buildingId bookId agentId targetUserId noticeId noticeKind targetRef discoveryAction discoveryId discoveryKind query filter kind channel status visibility sensitivity title workspaceId buildingListingId agentListingId memberPrincipalIds limit cursor placeableOnly includeAlerts outcomeSubmission summary guidance instruction parentRunId targetRunId listingId listingKind actionId message channelId messageId mentions audiencePrincipalIds notificationId subscriptionId topicKind topicId enabled targetState targetPrincipalId grantId permission expiresAt expiresAtMs requestId expectedVersion requestMessage decisionSummary reviewSummary claimCeiling maxCostMicroUsd maxRuntimeMs maxTokens maxArtifacts webSearch'.split(' ')\n" +
  "const __bunniaSessionStorageKey='__bunnia_session__'+(__bunniaBackend.s||'default')\n" +
  "\n" +
  "function __bunniaRequest(page, endpointId, payload, settled) {\n" +
  "  const endpoint = __bunniaBackend.e[endpointId]\n" +
  "  if (!endpoint) throw new Error('Unknown Bunnia endpoint: ' + endpointId)\n" +
  "  const baseUrl = page.data[__bunniaBackend.b] || ''\n" +
  "  const loading = {}\n" +
  "  loading['backend.' + endpointId + '.state'] = 'loading'\n" +
  "  loading['backend.' + endpointId + '.error'] = ''\n" +
  "  loading['requestState.state'] = 'loading'\n" +
  "  loading['requestState.error'] = ''\n" +
  "  loading['requestState.statusCode'] = 0\n" +
  "  __bs(loading, endpointId, 'loading', '')\n" +
  "  const requestPayload = payload === undefined && endpoint.p ? page.data[endpoint.p] : (payload || {})\n" +
  "  loading['backend.' + endpointId + '.request'] = requestPayload\n" +
  "  loading['backend.' + endpointId + '.replayKey'] = endpointId + ':' + Date.now()\n" +
  "  page.setData(loading)\n" +
  "  return wx.request({\n" +
  "    url: baseUrl + endpoint.u,\n" +
  "    method: endpoint.m,\n" +
  "    data: requestPayload,\n" +
  "    header: __bunniaRequestHeaders(page, endpointId),\n" +
  "    timeout: endpoint.t,\n" +
  "    success(res) {\n" +
  "      const update = {}\n" +
  "      update['requestState.statusCode'] = Number(res.statusCode || 0)\n" +
  "      if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {\n" +
  "        update['backend.' + endpointId + '.state'] = 'failed'\n" +
  "        update['backend.' + endpointId + '.error'] = 'HTTP ' + res.statusCode\n" +
  "        update['requestState.state'] = 'failed'\n" +
  "        update['requestState.error'] = 'Request failed'\n" +
  "        __bs(update, endpointId, 'failed', 'HTTP ' + res.statusCode)\n" +
  "      } else if (res.data === undefined || res.data === null) {\n" +
  "        update['backend.' + endpointId + '.state'] = 'malformed'\n" +
  "        update['backend.' + endpointId + '.error'] = 'empty response'\n" +
  "        update['requestState.state'] = 'failed'\n" +
  "        update['requestState.error'] = 'Request failed'\n" +
  "        __bs(update, endpointId, 'malformed', 'empty response')\n" +
  "      } else {\n" +
  "        update['backend.' + endpointId + '.state'] = 'ready'\n" +
  "        update['backend.' + endpointId + '.response'] = res.data\n" +
  "        update['backend.' + endpointId + '.error'] = ''\n" +
  "        update['requestState.state'] = 'ready'\n" +
  "        update['requestState.error'] = ''\n" +
  "        __bs(update, endpointId, 'ready', '')\n" +
  "        if (endpoint.r) update[endpoint.r] = res.data\n" +
  "        const sessionId = __bunniaSessionIdFromResponse(res.data)\n" +
  "        if (res.data && res.data.state === 'revoked') __bunniaStoreSession('')\n" +
  "        else if (sessionId && __bunniaBackend.s) { update[__bunniaBackend.s] = sessionId; __bunniaStoreSession(sessionId) }\n" +
  "        if (typeof page.__bunniaResponsePatch === 'function') { try { Object.assign(update, page.__bunniaResponsePatch(endpointId, res.data) || {}) } catch (_) {} }\n" +
  "      }\n" +
  "      page.setData(update, () => { if (typeof settled === 'function') settled(update['requestState.state'] === 'ready', res.data) })\n" +
  "    },\n" +
  "    fail(err) {\n" +
  "      const update = {}\n" +
  "      const message = String(err.errMsg || err)\n" +
  "      update['backend.' + endpointId + '.state'] = message.indexOf('timeout') >= 0 ? 'timeout' : 'failed'\n" +
  "      update['backend.' + endpointId + '.error'] = message\n" +
  "      update['requestState.state'] = 'failed'\n" +
  "      update['requestState.error'] = 'Request failed'\n" +
  "      update['requestState.statusCode'] = 0\n" +
  "      __bs(update, endpointId, update['backend.' + endpointId + '.state'], message)\n" +
  "      page.setData(update, () => { if (typeof settled === 'function') settled(false, err) })\n" +
  "    }\n" +
  "  })\n" +
  "}\n" +
  "\n" +
  "function __bunniaRequestHeaders(page, endpointId) {\n" +
  "  const k = __bunniaBackend.s, h = __bunniaBackend.h\n" +
  "  if (page && typeof page.__bunniaSessionTokenForEndpoint === 'function') {\n" +
  "    let scoped\n" +
  "    try { scoped = page.__bunniaSessionTokenForEndpoint(endpointId) } catch (_) { scoped = undefined }\n" +
  "    if (scoped !== undefined && scoped !== null) return scoped && h ? { [h]: String(scoped) } : {}\n" +
  "  }\n" +
  "  const t = (k && page && page.data ? page.data[k] : '') || __bunniaStoredSession()\n" +
  "  return t && h ? { [h]: t } : {}\n" +
  "}\n" +
  "\n" +
  "function __bunniaStoredSession() {\n" +
  "  try { return typeof wx !== 'undefined' && wx.getStorageSync ? (wx.getStorageSync(__bunniaSessionStorageKey) || '') : '' } catch (_) { return '' }\n" +
  "}\n" +
  "\n" +
  "function __bunniaStoreSession(value) {\n" +
  "  try { if (typeof wx !== 'undefined' && wx.setStorageSync) wx.setStorageSync(__bunniaSessionStorageKey, value || '') } catch (_) {}\n" +
  "}\n" +
  "\n" +
  "function __bunniaSessionIdFromResponse(data) {\n" +
  "  return data && data.session && data.session.id ? data.session.id : (data && data.sessionId ? data.sessionId : (data && data.id && data.kind === 'session' ? data.id : ''))\n" +
  "}\n" +
  "\n" +
  "function __bs(update, endpointId, state, error) {\n" +
  "  const p = 'backendStatus.' + String(endpointId).replace(/[^A-Za-z0-9_]/g, '_')\n" +
  "  update[p + '.state'] = state\n" +
  "  update[p + '.error'] = error || ''\n" +
  "}\n" +
  "\n" +
  "function __bunniaCancelRequest(page, endpointId) {\n" +
  "  const update = {}\n" +
  "  update['backend.' + endpointId + '.state'] = 'cancelled'\n" +
  "  update['backend.' + endpointId + '.error'] = ''\n" +
  "  update['requestState.state'] = 'cancelled'\n" +
  "  update['requestState.error'] = ''\n" +
  "  __bs(update, endpointId, 'cancelled', '')\n" +
  "  page.setData(update)\n" +
  "}\n" +
  "\n" +
  "function __bunniaRetryRequest(page, endpointId) {\n" +
  "  const current = page.data.backend && page.data.backend[endpointId]\n" +
  "  return __bunniaRequest(page, endpointId, current ? current.request : undefined)\n" +
  "}\n" +
  "\n" +
  "module.exports = { __bunniaBackend, __bunniaRequest, __bunniaCancelRequest, __bunniaRetryRequest, __bunniaStoredSession, __bk }"
}

///|
fn wechat_file(path : String, content : String) -> WechatFile {
  { path, content }
}

///|
fn append_packaged_scene_asset_files(
  files : Array[WechatFile],
  pages : Array[WechatProjectPage],
) -> Unit {
  for page in pages {
    for asset in page.page.scene_assets {
      if scene_asset_should_be_packaged(asset) {
        let path = scene_asset_file_path(asset.src)
        if !wechat_file_seen(files, path) {
          files.push(wechat_file(path, packaged_scene_asset_content(asset)))
        }
      }
    }
  }
}

///|
fn packaged_scene_asset_content(asset : WechatSceneAsset) -> String {
  if asset.source_path != "" {
    "copy-file:\{asset.source_path}"
  } else {
    packaged_scene_asset_png_base64()
  }
}

///|
fn scene_asset_should_be_packaged(asset : WechatSceneAsset) -> Bool {
  !asset.remote &&
  asset.src.has_prefix("/assets/") &&
  (
    asset.src.has_suffix(".png") ||
    asset.src.has_suffix(".jpg") ||
    asset.src.has_suffix(".jpeg") ||
    asset.src.has_suffix(".webp")
  )
}

///|
fn scene_asset_file_path(src : String) -> String {
  if src.has_prefix("/") {
    src.sub(start=1, end=src.length()).to_owned()
  } else {
    src
  }
}

///|
fn wechat_file_seen(files : Array[WechatFile], path : String) -> Bool {
  for file in files {
    if file.path == path {
      return true
    }
  }
  false
}

///|
fn packaged_scene_asset_png_base64() -> String {
  "base64:iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l0z9ZAAAAABJRU5ErkJggg=="
}

///|
fn materialized_file_bytes(
  file : WechatFile,
  pages : Array[WechatProjectPage],
) -> Int {
  if file.content.has_prefix("copy-file:") {
    for page in pages {
      for asset in page.page.scene_assets {
        if scene_asset_file_path(asset.src) == file.path {
          return asset.estimated_bytes
        }
      }
    }
  }
  file.content.length()
}

///|
fn total_file_bytes(
  files : Array[WechatFile],
  pages : Array[WechatProjectPage],
) -> Int {
  let mut total = 0
  for item in files {
    total += materialized_file_bytes(item, pages)
  }
  total
}

///|
fn project_manifest(
  name : String,
  entry_route : String,
  pages : Array[WechatProjectPage],
  files : Array[WechatFile],
  backend_contract : @effects.BackendContract?,
) -> WechatProjectManifest {
  let app_total = total_file_bytes(files, pages)
  let manifest_files = manifest_files(files, pages)
  let manifest_pages = manifest_pages(pages, files, backend_contract)
  let scene_assets = manifest_scene_assets(manifest_pages)
  let scene_remote_asset_count = manifest_remote_scene_asset_count(scene_assets)
  let scene_package_bytes = manifest_scene_package_bytes(scene_assets)
  let diagnostic_route_count = manifest_diagnostic_route_count(manifest_pages)
  let diagnostic_count = manifest_diagnostic_count(manifest_pages)
  {
    name,
    entry_route,
    page_count: pages.length(),
    app_file_count: files.length(),
    app_total_bytes: app_total,
    scene_asset_count: scene_assets.length(),
    scene_remote_asset_count,
    scene_package_bytes,
    diagnostic_route_count,
    diagnostic_count,
    pages: manifest_pages,
    files: manifest_files,
    scene_assets,
    summary: "Bunnia WeChat manifest: pages=\{pages.length()} app_files=\{files.length()} app_bytes=\{app_total} scene_assets=\{scene_assets.length()} scene_package_bytes=\{scene_package_bytes} scene_remote_assets=\{scene_remote_asset_count} diagnostic_routes=\{diagnostic_route_count} diagnostics=\{diagnostic_count} entry=\{entry_route}",
  }
}

///|
fn manifest_files(
  files : Array[WechatFile],
  pages : Array[WechatProjectPage],
) -> Array[WechatManifestFile] {
  let output : Array[WechatManifestFile] = []
  for file in files {
    output.push({
      path: file.path,
      kind: manifest_file_kind(file.path),
      route: manifest_file_route(file.path),
      bytes: materialized_file_bytes(file, pages),
    })
  }
  output
}

///|
fn manifest_pages(
  pages : Array[WechatProjectPage],
  files : Array[WechatFile],
  backend_contract : @effects.BackendContract?,
) -> Array[WechatManifestPage] {
  let output : Array[WechatManifestPage] = []
  for item in pages {
    let (
      backend_events,
      backend_streams,
      backend_reviews,
      backend_cancels,
      backend_retries,
    ) = page_backend_pressure(item, backend_contract)
    let scene_assets = page_manifest_scene_assets(item)
    let diagnostics = page_manifest_diagnostics(item)
    output.push({
      name: item.name,
      route: item.route,
      node_count: item.page.plan.node_count,
      depth: item.page.plan.depth,
      windowed_list_count: item.page.plan.windowed_list_count,
      list_total_count: item.page.plan.list_total_count,
      list_visible_count: item.page.plan.list_visible_count,
      unwindowed_list_child_count: item.page.plan.unwindowed_list_child_count,
      unkeyed_list_child_count: item.page.plan.unkeyed_list_child_count,
      duplicate_list_key_count: item.page.plan.duplicate_list_key_count,
      component_count: item.page.plan.component_kinds.length(),
      event_count: item.page.plan.event_messages.length(),
      backend_event_count: backend_events,
      backend_stream_count: backend_streams,
      backend_review_count: backend_reviews,
      backend_cancel_count: backend_cancels,
      backend_retry_count: backend_retries,
      scene_count: item.page.plan.scene_count,
      scene_marker_count: item.page.plan.scene_marker_count,
      scene_visible_marker_count: item.page.plan.scene_visible_marker_count,
      scene_region_count: item.page.plan.scene_region_count,
      scene_visible_region_count: item.page.plan.scene_visible_region_count,
      scene_asset_count: item.page.plan.scene_asset_count,
      scene_assets,
      scene_package_bytes: manifest_scene_package_bytes(scene_assets),
      scene_remote_asset_count: manifest_remote_scene_asset_count(scene_assets),
      degraded_scene_count: item.page.plan.degraded_scene_count,
      missing_event_patch_count: page_missing_event_patch_count(item),
      orphan_event_patch_count: page_orphan_event_patch_count(item),
      initial_data_bytes: item.runtime.initial_data_json.length(),
      event_patch_bytes: page_event_patch_bytes(item),
      event_patch_operation_count: page_event_patch_operation_count(item),
      first_screen_bytes: page_first_screen_bytes(item, files),
      update_payload_bytes: page_update_payload_bytes(item),
      update_operation_count: page_update_operation_count(item),
      file_bytes: route_file_bytes(item.route, files),
      render_budget: item.page.render_budget,
      render_diagnostics: item.page.plan.diagnostics,
      diagnostics,
      diagnostic_count: diagnostics.length(),
      status: route_manifest_status(diagnostics),
    })
  }
  output
}

///|
fn page_manifest_scene_assets(
  page : WechatProjectPage,
) -> Array[WechatManifestSceneAsset] {
  let output : Array[WechatManifestSceneAsset] = []
  for item in page.page.scene_assets {
    output.push({
      route: page.route,
      id: item.id,
      kind: item.kind,
      src: item.src,
      remote: item.remote,
      estimated_bytes: item.estimated_bytes,
    })
  }
  output
}

///|
fn manifest_scene_assets(
  pages : Array[WechatManifestPage],
) -> Array[WechatManifestSceneAsset] {
  let output : Array[WechatManifestSceneAsset] = []
  for page in pages {
    for item in page.scene_assets {
      output.push(item)
    }
  }
  output
}

///|
fn manifest_remote_scene_asset_count(
  items : Array[WechatManifestSceneAsset],
) -> Int {
  let mut total = 0
  for item in items {
    if item.remote {
      total += 1
    }
  }
  total
}

///|
fn manifest_scene_package_bytes(items : Array[WechatManifestSceneAsset]) -> Int {
  let mut total = 0
  for item in items {
    if !item.remote {
      total += item.estimated_bytes
    }
  }
  total
}

///|
fn route_manifest_status(diagnostics : Array[String]) -> String {
  if diagnostics.length() == 0 {
    "ok"
  } else {
    "diagnostic"
  }
}

///|
fn manifest_diagnostic_route_count(pages : Array[WechatManifestPage]) -> Int {
  let mut total = 0
  for page in pages {
    if page.diagnostic_count > 0 {
      total += 1
    }
  }
  total
}

///|
fn manifest_diagnostic_count(pages : Array[WechatManifestPage]) -> Int {
  let mut total = 0
  for page in pages {
    total += page.diagnostic_count
  }
  total
}

///|
fn manifest_file_kind(path : String) -> String {
  if path == "app.json" {
    "app"
  } else if path == "project.config.json" {
    "project-config"
  } else if path == "sitemap.json" {
    "sitemap"
  } else if path == "bunnia.backend.js" {
    "backend-js"
  } else if path.has_suffix(".wxml") {
    "wxml"
  } else if path.has_suffix(".wxss") {
    "wxss"
  } else if path.has_suffix(".data.js") {
    "page-data"
  } else if path.has_suffix(".js") {
    "js"
  } else if path.has_suffix(".json") {
    "json"
  } else {
    "asset"
  }
}

///|
fn manifest_file_route(path : String) -> String {
  if path.has_suffix(".wxml") && path.has_prefix("pages/") {
    trim_suffix(path, ".wxml")
  } else if path.has_suffix(".wxss") && path.has_prefix("pages/") {
    trim_suffix(path, ".wxss")
  } else if path.has_suffix(".data.js") && path.has_prefix("pages/") {
    trim_suffix(path, ".data.js")
  } else if path.has_suffix(".js") && path.has_prefix("pages/") {
    trim_suffix(path, ".js")
  } else if path.has_suffix(".json") && path.has_prefix("pages/") {
    trim_suffix(path, ".json")
  } else {
    ""
  }
}

///|
fn trim_suffix(value : String, suffix : String) -> String {
  value.sub(start=0, end=value.length() - suffix.length()).to_owned()
}

///|
fn route_file_bytes(route : String, files : Array[WechatFile]) -> Int {
  let mut total = 0
  for file in files {
    if manifest_file_route(file.path) == route {
      total += file.content.length()
    }
  }
  total
}

///|
fn page_event_patch_bytes(page : WechatProjectPage) -> Int {
  let mut total = 0
  for item in page.runtime.event_patches {
    if page.page.plan.event_messages.contains(item.message) {
      total += item.message.length()
      for patch in item.patches {
        total += patch.estimated_bytes
      }
    }
  }
  total
}

///|
fn page_event_patch_operation_count(page : WechatProjectPage) -> Int {
  page_update_operation_count(page)
}

///|
fn page_update_operation_count(page : WechatProjectPage) -> Int {
  let mut total = 0
  for item in page.runtime.event_patches {
    if page.page.plan.event_messages.contains(item.message) {
      total += item.patches.length()
    }
  }
  total
}

///|
fn page_missing_event_patch_count(page : WechatProjectPage) -> Int {
  let mut total = 0
  for message in page.page.plan.event_messages {
    if !page_runtime_has_patch(page.runtime, message) {
      total += 1
    }
  }
  total
}

///|
fn page_orphan_event_patch_count(page : WechatProjectPage) -> Int {
  let mut total = 0
  for patch in page.runtime.event_patches {
    if !page.page.plan.event_messages.contains(patch.message) {
      total += 1
    }
  }
  total
}

///|
fn page_runtime_has_patch(runtime : WechatRuntime, message : String) -> Bool {
  for item in runtime.event_patches {
    if item.message == message {
      return true
    }
  }
  false
}

///|
fn page_manifest_diagnostics(page : WechatProjectPage) -> Array[String] {
  let output : Array[String] = []
  for item in page.page.plan.diagnostics {
    output.push("wechat-render:\{page.route}:\{item}")
  }
  let initial = page.runtime.initial_data_json.length()
  let initial_limit = page.page.render_budget.max_initial_data_bytes
  if initial > initial_limit {
    output.push(
      "wechat-route-initial-data-over-render-budget:\{page.route}:\{initial}/\{initial_limit}",
    )
  }
  for message in page.page.plan.event_messages {
    if !page_runtime_has_patch(page.runtime, message) {
      output.push("wechat-event-without-patch:\{page.route}:\{message}")
    }
  }
  for patch in page.runtime.event_patches {
    if !page.page.plan.event_messages.contains(patch.message) {
      output.push("wechat-patch-without-event:\{page.route}:\{patch.message}")
    }
  }
  output
}

///|
fn page_backend_pressure(
  page : WechatProjectPage,
  contract : @effects.BackendContract?,
) -> (Int, Int, Int, Int, Int) {
  match contract {
    Some(value) => {
      let mut event_count = 0
      let mut stream_count = 0
      let mut review_count = 0
      let mut cancel_count = 0
      let mut retry_count = 0
      for endpoint in value.endpoints {
        if page.page.plan.event_messages.contains(endpoint.id) {
          event_count += 1
          if endpoint.streaming {
            stream_count += 1
          }
          if endpoint.requires_review {
            review_count += 1
          }
          if endpoint.kind is @effects.AgentOperationCancel {
            cancel_count += 1
          }
          if endpoint.kind is @effects.AgentOperationRetry {
            retry_count += 1
          }
        }
      }
      (event_count, stream_count, review_count, cancel_count, retry_count)
    }
    None => (0, 0, 0, 0, 0)
  }
}

///|
fn manifest_json(input : WechatProjectManifest) -> String {
  "{\n" +
  "  \"name\": \"\{escape_json(input.name)}\",\n" +
  "  \"entryRoute\": \"\{escape_json(input.entry_route)}\",\n" +
  "  \"pageCount\": \{input.page_count},\n" +
  "  \"appFileCount\": \{input.app_file_count},\n" +
  "  \"appTotalBytes\": \{input.app_total_bytes},\n" +
  "  \"sceneAssetCount\": \{input.scene_asset_count},\n" +
  "  \"sceneRemoteAssetCount\": \{input.scene_remote_asset_count},\n" +
  "  \"scenePackageBytes\": \{input.scene_package_bytes},\n" +
  "  \"diagnosticRouteCount\": \{input.diagnostic_route_count},\n" +
  "  \"diagnosticCount\": \{input.diagnostic_count},\n" +
  "  \"pages\": \{manifest_pages_json(input.pages)},\n" +
  "  \"files\": \{manifest_files_json(input.files)},\n" +
  "  \"sceneAssets\": \{manifest_scene_assets_json(input.scene_assets)}\n" +
  "}\n"
}

///|
fn manifest_pages_json(items : Array[WechatManifestPage]) -> String {
  let out = StringBuilder::new()
  out.write_string("[")
  for i in 0.. 0 {
      out.write_string(", ")
    }
    out.write_string(manifest_page_json(items[i]))
  }
  out.write_string("]")
  out.to_string()
}

///|
fn manifest_page_json(item : WechatManifestPage) -> String {
  "{" +
  "\"name\":\"\{escape_json(item.name)}\"," +
  "\"route\":\"\{escape_json(item.route)}\"," +
  "\"nodes\":\{item.node_count}," +
  "\"depth\":\{item.depth}," +
  "\"windowedLists\":\{item.windowed_list_count}," +
  "\"listTotal\":\{item.list_total_count}," +
  "\"listVisible\":\{item.list_visible_count}," +
  "\"unwindowedListChildren\":\{item.unwindowed_list_child_count}," +
  "\"unkeyedListChildren\":\{item.unkeyed_list_child_count}," +
  "\"duplicateListKeys\":\{item.duplicate_list_key_count}," +
  "\"components\":\{item.component_count}," +
  "\"events\":\{item.event_count}," +
  "\"backendEvents\":\{item.backend_event_count}," +
  "\"backendStreams\":\{item.backend_stream_count}," +
  "\"backendReviews\":\{item.backend_review_count}," +
  "\"backendCancels\":\{item.backend_cancel_count}," +
  "\"backendRetries\":\{item.backend_retry_count}," +
  "\"scenes\":\{item.scene_count}," +
  "\"sceneMarkers\":\{item.scene_marker_count}," +
  "\"sceneVisibleMarkers\":\{item.scene_visible_marker_count}," +
  "\"sceneRegions\":\{item.scene_region_count}," +
  "\"sceneVisibleRegions\":\{item.scene_visible_region_count}," +
  "\"sceneAssets\":\{item.scene_asset_count}," +
  "\"sceneAssetRefs\":\{manifest_scene_assets_json(item.scene_assets)}," +
  "\"scenePackageBytes\":\{item.scene_package_bytes}," +
  "\"sceneRemoteAssets\":\{item.scene_remote_asset_count}," +
  "\"degradedScenes\":\{item.degraded_scene_count}," +
  "\"missingEventPatches\":\{item.missing_event_patch_count}," +
  "\"orphanEventPatches\":\{item.orphan_event_patch_count}," +
  "\"initialDataBytes\":\{item.initial_data_bytes}," +
  "\"eventPatchBytes\":\{item.event_patch_bytes}," +
  "\"eventPatchOperations\":\{item.event_patch_operation_count}," +
  "\"firstScreenBytes\":\{item.first_screen_bytes}," +
  "\"updatePayloadBytes\":\{item.update_payload_bytes}," +
  "\"updateOperations\":\{item.update_operation_count}," +
  "\"fileBytes\":\{item.file_bytes}," +
  "\"renderBudget\":\{render_budget_json(item.render_budget)}," +
  "\"renderDiagnostics\":\{strings_json(item.render_diagnostics)}," +
  "\"diagnostics\":\{strings_json(item.diagnostics)}," +
  "\"diagnosticCount\":\{item.diagnostic_count}," +
  "\"status\":\"\{escape_json(item.status)}\"" +
  "}"
}

///|
fn manifest_scene_assets_json(
  items : Array[WechatManifestSceneAsset],
) -> String {
  let out = StringBuilder::new()
  out.write_string("[")
  for i in 0.. 0 {
      out.write_string(", ")
    }
    out.write_string(manifest_scene_asset_json(items[i]))
  }
  out.write_string("]")
  out.to_string()
}

///|
fn manifest_scene_asset_json(item : WechatManifestSceneAsset) -> String {
  "{" +
  "\"route\":\"\{escape_json(item.route)}\"," +
  "\"id\":\"\{escape_json(item.id)}\"," +
  "\"kind\":\"\{escape_json(item.kind)}\"," +
  "\"src\":\"\{escape_json(item.src)}\"," +
  "\"remote\":\{item.remote}," +
  "\"estimatedBytes\":\{item.estimated_bytes}" +
  "}"
}

///|
fn render_budget_json(input : @core.RenderBudget) -> String {
  "{" +
  "\"maxNodes\":\{input.max_nodes}," +
  "\"maxDepth\":\{input.max_depth}," +
  "\"maxEvents\":\{input.max_events}," +
  "\"maxInitialDataBytes\":\{input.max_initial_data_bytes}," +
  "\"maxUnwindowedListChildren\":\{input.max_unwindowed_list_children}," +
  "\"maxSceneMarkers\":\{input.max_scene_markers}," +
  "\"maxSceneRegions\":\{input.max_scene_regions}," +
  "\"maxSceneAssets\":\{input.max_scene_assets}," +
  "\"maxDegradedScenes\":\{input.max_degraded_scenes}" +
  "}"
}

///|
fn strings_json(items : Array[String]) -> String {
  let out = StringBuilder::new()
  out.write_string("[")
  for i in 0.. 0 {
      out.write_string(", ")
    }
    out.write_string("\"")
    out.write_string(escape_json(items[i]))
    out.write_string("\"")
  }
  out.write_string("]")
  out.to_string()
}

///|
fn manifest_files_json(items : Array[WechatManifestFile]) -> String {
  let out = StringBuilder::new()
  out.write_string("[")
  for i in 0.. 0 {
      out.write_string(", ")
    }
    out.write_string(manifest_file_json(items[i]))
  }
  out.write_string("]")
  out.to_string()
}

///|
fn manifest_file_json(item : WechatManifestFile) -> String {
  "{" +
  "\"path\":\"\{escape_json(item.path)}\"," +
  "\"kind\":\"\{escape_json(item.kind)}\"," +
  "\"route\":\"\{escape_json(item.route)}\"," +
  "\"bytes\":\{item.bytes}" +
  "}"
}

///|
fn app_json(pages : Array[WechatProjectPage]) -> String {
  "{\n" +
  "  \"pages\": \{page_routes_json(pages)},\n" +
  "  \"window\": {\n" +
  "    \"navigationBarTitleText\": \"Bunnia\",\n" +
  "    \"navigationBarBackgroundColor\": \"#ffffff\",\n" +
  "    \"navigationBarTextStyle\": \"black\"\n" +
  "  }\n" +
  "}\n"
}

///|
fn page_routes_json(pages : Array[WechatProjectPage]) -> String {
  let out = StringBuilder::new()
  out.write_string("[")
  for i in 0.. 0 {
      out.write_string(", ")
    }
    out.write_string("\"")
    out.write_string(escape_json(pages[i].route))
    out.write_string("\"")
  }
  out.write_string("]")
  out.to_string()
}

///|
fn project_config_json(name : String) -> String {
  "{\n" +
  "  \"appid\": \"\",\n" +
  "  \"projectname\": \"\{escape_json(name)}\",\n" +
  "  \"compileType\": \"miniprogram\",\n" +
  "  \"setting\": {\n" +
  "    \"es6\": true,\n" +
  "    \"minified\": false,\n" +
  "    \"urlCheck\": false\n" +
  "  }\n" +
  "}\n"
}

///|
fn sitemap_json() -> String {
  "{\n" +
  "  \"rules\": [\n" +
  "    { \"action\": \"allow\", \"page\": \"*\" }\n" +
  "  ]\n" +
  "}\n"
}

///|
fn compact_wxss(input : String) -> String {
  let out = StringBuilder::new()
  for i in 0.. Bool {
  let prev = previous_non_css_space(input, i)
  let next = next_non_css_space(input, i + 1)
  prev != "" &&
  next != "" &&
  !css_space_optional_around(prev[0]) &&
  !css_space_optional_around(next[0])
}

///|
fn previous_non_css_space(input : String, i : Int) -> String {
  for offset in 1..<=i {
    let idx = i - offset
    if !is_css_space(input[idx]) {
      return input.sub(start=idx, end=idx + 1).to_owned()
    }
  }
  ""
}

///|
fn next_non_css_space(input : String, start : Int) -> String {
  for i in start.. Bool {
  for i in start.. Bool {
  let code = ch.to_int()
  code == 32 || code == 10 || code == 9 || code == 13
}

///|
fn css_space_optional_around(ch : UInt16) -> Bool {
  let code = ch.to_int()
  code == 123 ||
  code == 125 ||
  code == 58 ||
  code == 59 ||
  code == 44 ||
  code == 62 ||
  code == 40 ||
  code == 33
}

///|
fn patches_to_operations(patches : Array[@core.Patch]) -> String {
  let out = StringBuilder::new()
  out.write_string("[")
  for i in 0.. 0 {
      out.write_string(",")
    }
    out.write_string("{k:\"")
    out.write_string(patch_kind_id(item.kind))
    out.write_string("\",p:\"")
    out.write_string(escape_json(item.path))
    out.write_string("\",v:")
    out.write_string(item.value_json)
    out.write_string("}")
  }
  out.write_string("]")
  out.to_string()
}

///|
fn collect_scene_assets(root : @core.Node) -> Array[WechatSceneAsset] {
  let output : Array[WechatSceneAsset] = []
  collect_scene_assets_into(output, root)
  output
}

///|
fn collect_scene_assets_into(
  output : Array[WechatSceneAsset],
  root : @core.Node,
) -> Unit {
  let id = attr_value(root.attrs, "data-asset-id")
  if id != "" && !scene_asset_seen(output, id) {
    output.push({
      id,
      kind: attr_value(root.attrs, "data-asset-kind"),
      src: attr_value(root.attrs, "src"),
      source_path: attr_value(root.attrs, "data-asset-source"),
      remote: attr_value(root.attrs, "data-asset-remote") == "true",
      estimated_bytes: attr_int_value(root.attrs, "data-asset-bytes"),
    })
  }
  for child in root.children {
    collect_scene_assets_into(output, child)
  }
}

///|
fn attr_int_value(attrs : Array[@core.Attr], name : String) -> Int {
  let value = attr_value(attrs, name)
  if value == "" {
    0
  } else {
    let mut total = 0
    for i in 0.. 57 {
        return 0
      }
      total = total * 10 + (code - 48)
    }
    total
  }
}

///|
fn scene_asset_seen(items : Array[WechatSceneAsset], id : String) -> Bool {
  for item in items {
    if item.id == id {
      return true
    }
  }
  false
}

///|
fn attr_value(attrs : Array[@core.Attr], name : String) -> String {
  for item in attrs {
    if item.name == name {
      return item.value
    }
  }
  ""
}

///|
fn patch_kind_id(kind : @core.PatchKind) -> String {
  match kind {
    Set => "set"
    Append => "append"
    Remove => "remove"
  }
}

///|
fn render_node(root : @core.Node, adapter : @core.PlatformAdapter) -> String {
  let kind = component_name(root.kind, adapter)
  if kind == "page" {
    render_children(root.children, adapter)
  } else {
    let attrs = render_attrs(root, adapter)
    if root.children.length() == 0 && root.text == "" {
      "<\{kind}\{attrs}>"
    } else {
      "<\{kind}\{attrs}>\{escape_wxml_text(root.text)}\{render_children(root.children, adapter)}"
    }
  }
}

///|
fn render_children(
  children : Array[@core.Node],
  adapter : @core.PlatformAdapter,
) -> String {
  let out = StringBuilder::new()
  for child in children {
    out.write_string(render_node(child, adapter))
  }
  out.to_string()
}

///|
fn render_attrs(root : @core.Node, adapter : @core.PlatformAdapter) -> String {
  let out = StringBuilder::new()
  for attr in root.attrs {
    out.write_string(
      " \{attr.name}=\"\{escape_wxml_attr(wechat_attr_value(attr))}\"",
    )
  }
  for binding in root.events {
    let rendered_event = event_name(binding.event, adapter)
    out.write_string(" \{rendered_event}=\"__bunniaEvent\"")
    out.write_string(" data-bunnia-message=\"\{escape_xml(binding.message)}\"")
  }
  out.to_string()
}

///|
fn wechat_attr_value(attr : @core.Attr) -> String {
  if attr.name == "style" {
    compact_inline_style(attr.value)
  } else {
    attr.value
  }
}

///|
fn compact_inline_style(value : String) -> String {
  let compact = compact_wxss(value)
  let length = compact.length()
  if length > 0 && compact[length - 1].to_int() == 59 {
    compact.sub(start=0, end=length - 1).to_owned()
  } else {
    compact
  }
}

///|
fn event_name(event : String, adapter : @core.PlatformAdapter) -> String {
  if event == "tap" {
    adapter.tap_event
  } else if event == "input" {
    "bindinput"
  } else if event == "submit" {
    "bindsubmit"
  } else {
    event
  }
}

///|
fn component_name(kind : String, adapter : @core.PlatformAdapter) -> String {
  if kind == "page" {
    adapter.page_component
  } else if kind == "windowed-list" {
    "\{adapter.component_prefix}view"
  } else {
    "\{adapter.component_prefix}\{kind}"
  }
}

///|
fn page_js(
  name : String,
  plan : @core.RenderPlan,
  runtime : WechatRuntime,
) -> String {
  "const __bunniaEventPatches = \{event_patches_object(runtime.event_patches, plan)}\n" +
  "\n" +
  runtime_helpers_js() +
  "\n" +
  page_definition_js(
    name,
    plan,
    runtime.initial_data_json,
    "",
    runtime.page_methods_js,
  )
}

///|
fn project_page_js(
  name : String,
  route : String,
  plan : @core.RenderPlan,
  runtime : WechatRuntime,
) -> String {
  "const { __bunniaApplyPatches } = require('\{runtime_require_path(route)}')\n" +
  "const { initialData: __bunniaInitialData, eventPatches: __bunniaEventPatches } = require('\{page_payload_require_path(route)}')\n" +
  "\n" +
  page_definition_js(
    name,
    plan,
    "__bunniaInitialData",
    "",
    runtime.page_methods_js,
  )
}

///|
fn project_page_js_with_backend(
  name : String,
  route : String,
  plan : @core.RenderPlan,
  runtime : WechatRuntime,
  contract : @effects.BackendContract,
) -> String {
  "const { __bunniaApplyPatches } = require('\{runtime_require_path(route)}')\n" +
  "const { initialData: __bunniaInitialData, eventPatches: __bunniaEventPatches } = require('\{page_payload_require_path(route)}')\n" +
  "const { __bunniaBackend, __bunniaRequest, __bunniaRetryRequest, __bunniaStoredSession, __bk } = require('\{backend_require_path(route)}')\n" +
  "const __bunniaInitialDataWithSession = Object.assign({}, __bunniaInitialData)\n" +
  "if (__bunniaBackend.s) __bunniaInitialDataWithSession[__bunniaBackend.s] = __bunniaStoredSession()\n" +
  "const __bunniaBackendEvents = \{backend_event_object(contract, plan)}\n" +
  "\n" +
  backend_request_dispatch_js() +
  "\n" +
  page_definition_js(
    name,
    plan,
    "__bunniaInitialDataWithSession",
    "    const formValues=event&&event.detail&&event.detail.value&&typeof event.detail.value==='object'?event.detail.value:{}\n" +
    "    __bunniaMaybeRequest(this,message,Object.assign({},formValues,event.currentTarget.dataset||{}))\n",
    backend_session_page_methods_js() + runtime.page_methods_js,
  )
}

///|
fn backend_session_page_methods_js() -> String {
  "  onShow(){\n" +
  "    const token=__bunniaStoredSession()\n" +
  "    if(__bunniaBackend.s&&token&&this.data[__bunniaBackend.s]!==token){const update={};update[__bunniaBackend.s]=token;this.setData(update)}\n" +
  "    if(typeof this.__bunniaPageShown==='function')this.__bunniaPageShown()\n" +
  "  },\n"
}

///|
fn page_payload_js(runtime : WechatRuntime, plan : @core.RenderPlan) -> String {
  "const initialData = (\{runtime.initial_data_json})\n" +
  "const eventPatches = \{event_patches_object(runtime.event_patches, plan)}\n" +
  "\n" +
  "module.exports = { initialData, eventPatches }\n"
}

///|
fn runtime_js() -> String {
  runtime_helpers_js() +
  "\n" +
  "module.exports = { __bunniaReadPath, __bunniaApplyPatches }\n"
}

///|
fn runtime_helpers_js() -> String {
  "function __bunniaReadPath(root, path) {\n" +
  "  return String(path).split('.').reduce((value, part) => value == null ? undefined : value[part], root)\n" +
  "}\n" +
  "\n" +
  "function __bunniaApplyPatches(page, operations) {\n" +
  "  const update = {}\n" +
  "  ;(operations || []).forEach((patch) => {\n" +
  "    if (patch.k === 'append') {\n" +
  "      const current = __bunniaReadPath(page.data, patch.p)\n" +
  "      update[patch.p] = Array.isArray(current) ? current.concat([patch.v]) : [patch.v]\n" +
  "    } else if (patch.k === 'remove') {\n" +
  "      update[patch.p] = null\n" +
  "    } else {\n" +
  "      update[patch.p] = patch.v\n" +
  "    }\n" +
  "  })\n" +
  "  page.setData(update)\n" +
  "}\n"
}

///|
fn page_definition_js(
  name : String,
  plan : @core.RenderPlan,
  initial_data_expr : String,
  after_event_js : String,
  page_methods_js : String,
) -> String {
  "Page({\n" +
  "  data: {\n" +
  "    __bunnia: {\n" +
  "      name: \"\{escape_json(name)}\",\n" +
  "      nodes: \{plan.node_count},\n" +
  "      depth: \{plan.depth},\n" +
  "      lastMessage: \"\",\n" +
  "      handledEvents: 0\n" +
  "    },\n" +
  "    ...(\{initial_data_expr})\n" +
  "  },\n" +
  page_methods_js +
  "  __bunniaEvent(event) {\n" +
  "    const message = event.currentTarget.dataset.bunniaMessage\n" +
  "    const patches = __bunniaEventPatches[message] || []\n" +
  "    const inputPath = event.currentTarget.dataset.bunniaInputPath\n" +
  "    const inputPatches = inputPath && event.detail && event.detail.value !== undefined ? [{ k: 'set', p: inputPath, v: event.detail.value }] : []\n" +
  "    const setPath = event.currentTarget.dataset.bunniaSetPath\n" +
  "    const setRaw = event.currentTarget.dataset.bunniaSetValue\n" +
  "    let setValue = setRaw\n" +
  "    if (setPath && event.currentTarget.dataset.bunniaSetJson === 'true') { try { setValue = JSON.parse(setRaw) } catch (_) {} }\n" +
  "    const setPatches = setPath ? [{ k: 'set', p: setPath, v: setValue }] : []\n" +
  "    __bunniaApplyPatches(this, [\n" +
  "      { k: 'set', p: '__bunnia.lastMessage', v: message },\n" +
  "      { k: 'set', p: '__bunnia.handledEvents', v: (this.data.__bunnia.handledEvents || 0) + 1 },\n" +
  "      ...patches,\n" +
  "      ...inputPatches,\n" +
  "      ...setPatches\n" +
  "    ])\n" +
  after_event_js +
  "    const route = event.currentTarget.dataset.bunniaRoute\n" +
  "    if (route && route !== this.route && typeof wx !== 'undefined' && wx.redirectTo) {\n" +
  "      wx.redirectTo({ url: route[0] === '/' ? route : '/' + route })\n" +
  "    }\n" +
  "  }\n" +
  "})"
}

///|
fn backend_request_dispatch_js() -> String {
  "function __bp(p,e,d){let h=0,b=e.p?Object.assign({},p.data[e.p]||{}):{};__bk.map(k=>d&&d[k]!==undefined&&d[k]!==''&&(b[k]=d[k],h=1));return h?b:undefined}\n" +
  "function __bunniaMaybeRequest(p,m,d){\n" +
  "  const id=__bunniaBackendEvents[m]\n" +
  "  if(!id)return\n" +
  "  const e=__bunniaBackend.e[id]\n" +
  "  if(!e)return\n" +
  "  const raw=__bp(p,e,d)\n" +
  "  const send=()=>{const source=raw===undefined?(e.p?p.data[e.p]:{}):raw;const pl=typeof p.__bunniaMapRequest==='function'?p.__bunniaMapRequest(id,source):raw;if(e.k==='agent-operation-cancel')return __bunniaRequest(p,id,pl);if(e.k==='agent-operation-retry')return pl?__bunniaRequest(p,id,pl):__bunniaRetryRequest(p,id);return __bunniaRequest(p,id,pl)}\n" +
  "  if(typeof p.__bunniaBeforeRequest==='function'&&p.__bunniaBeforeRequest(id,raw,send))return\n" +
  "  return send()\n" +
  "}\n"
}

///|
fn page_payload_file_path(route : String) -> String {
  "\{route}.data.js"
}

///|
fn page_payload_require_path(route : String) -> String {
  "./\{route_basename(route)}.data.js"
}

///|
fn route_basename(route : String) -> String {
  let mut start = 0
  for i in 0.. String {
  let depth = route_slash_count(route)
  if depth == 0 {
    "./bunnia.runtime.js"
  } else {
    let out = StringBuilder::new()
    for _ in 0.. String {
  let depth = route_slash_count(route)
  if depth == 0 {
    "./bunnia.backend.js"
  } else {
    let out = StringBuilder::new()
    for _ in 0.. Int {
  let mut count = 0
  for i in 0.. String {
  let out = StringBuilder::new()
  out.write_string("{")
  let mut emitted = 0
  for item in items {
    if plan.event_messages.contains(item.message) {
      if emitted > 0 {
        out.write_string(",")
      }
      emitted += 1
      out.write_string("\"")
      out.write_string(escape_json(item.message))
      out.write_string("\":")
      out.write_string(patches_to_operations(item.patches))
    }
  }
  out.write_string("}")
  out.to_string()
}

///|
fn backend_event_object(
  contract : @effects.BackendContract,
  plan : @core.RenderPlan,
) -> String {
  let out = StringBuilder::new()
  out.write_string("{")
  let mut emitted = 0
  for endpoint in contract.endpoints {
    if plan.event_messages.contains(endpoint.id) {
      if emitted > 0 {
        out.write_string(",")
      }
      emitted += 1
      out.write_string("\"")
      out.write_string(escape_json(endpoint.id))
      out.write_string("\":\"")
      out.write_string(escape_json(endpoint.id))
      out.write_string("\"")
    }
  }
  out.write_string("}")
  out.to_string()
}

///|
fn backend_contract_object(contract : @effects.BackendContract) -> String {
  "{b:\"" +
  escape_json(contract.base_url_key) +
  "\",s:\"" +
  escape_json(contract.session_token_key) +
  "\",h:\"" +
  escape_json(contract.session_header_key) +
  "\",e:" +
  backend_endpoints_object(contract.endpoints) +
  "}"
}

///|
fn backend_endpoints_object(items : Array[@effects.BackendEndpoint]) -> String {
  let out = StringBuilder::new()
  out.write_string("{")
  for i in 0.. 0 {
      out.write_string(",")
    }
    out.write_string("\"")
    out.write_string(escape_json(item.id))
    out.write_string("\":")
    out.write_string(backend_endpoint_object(item))
  }
  out.write_string("}")
  out.to_string()
}

///|
fn backend_endpoint_object(item : @effects.BackendEndpoint) -> String {
  "{" +
  "k:\"" +
  escape_json(@effects.backend_endpoint_kind_id(item.kind)) +
  "\"," +
  "m:\"" +
  escape_json(@effects.http_method_id(item.http_method)) +
  "\"," +
  "u:\"" +
  escape_json(item.path) +
  "\"," +
  "p:\"" +
  escape_json(item.payload_key) +
  "\"," +
  "r:\"" +
  escape_json(item.response_key) +
  "\"," +
  "t:" +
  item.timeout_ms.to_string() +
  "}"
}

///|
fn default_wxss() -> String {
  ".demo-shell { min-height: 100vh; padding: 24rpx; box-sizing: border-box; display: flex; flex-direction: column; gap: 20rpx; background: #f7f8fa; color: #111827; }\n" +
  ".bunnia-message-feed { display: flex; flex-direction: column; gap: 12rpx; }\n" +
  ".bunnia-message-feed-windowed { max-height: 720rpx; }\n" +
  ".bunnia-message { padding: 16rpx 20rpx; border: 1rpx solid #e5e7eb; border-radius: 8rpx; background: #ffffff; box-sizing: border-box; font-size: 28rpx; line-height: 1.5; }\n" +
  ".bunnia-run-running { border-color: #2563eb; box-shadow: 0 0 0 2rpx rgba(37, 99, 235, 0.10); }\n" +
  ".bunnia-run-waiting-review { border-color: #d97706; }\n" +
  ".bunnia-run-failed { border-color: #dc2626; }\n" +
  ".bunnia-artifact-link { margin-top: 8rpx; color: #2563eb; font-size: 24rpx; }\n" +
  ".bunnia-thread { padding: 16rpx 20rpx; border: 1rpx solid #d1d5db; border-radius: 8rpx; background: #ffffff; box-sizing: border-box; font-size: 28rpx; line-height: 1.5; }\n" +
  ".bunnia-subject-link { margin-top: 8rpx; color: #0891b2; font-size: 24rpx; }\n" +
  ".bunnia-communication-trace { display: flex; flex-direction: column; gap: 12rpx; }\n" +
  ".bunnia-communication-trace-windowed { max-height: 720rpx; overflow: hidden; }\n" +
  ".bunnia-communication { padding: 14rpx 18rpx; border-left: 6rpx solid #64748b; border-radius: 8rpx; background: #ffffff; box-sizing: border-box; font-size: 26rpx; line-height: 1.45; }\n" +
  ".bunnia-communication-message { border-left-color: #64748b; }\n" +
  ".bunnia-communication-handoff { border-left-color: #0891b2; }\n" +
  ".bunnia-communication-broadcast { border-left-color: #2563eb; }\n" +
  ".bunnia-communication-review-request { border-left-color: #d97706; }\n" +
  ".bunnia-communication-approval { border-left-color: #16a34a; }\n" +
  ".bunnia-communication-tool-result { border-left-color: #7c3aed; }\n" +
  ".bunnia-communication-recovery-notice { border-left-color: #dc2626; }\n" +
  ".bunnia-list-windowed { display: flex; flex-direction: column; gap: 12rpx; }\n" +
  ".bunnia-status-surface { position: relative; display: flex; flex-direction: column; gap: 10rpx; }\n" +
  ".bunnia-status-badge { align-self: flex-start; padding: 8rpx 12rpx; border: 1rpx solid #94a3b8; border-radius: 8rpx; background: #ffffff; color: #111827; font-size: 24rpx; line-height: 1.35; box-sizing: border-box; }\n" +
  ".bunnia-status-detail { margin-top: 2rpx; color: #475569; font-size: 22rpx; }\n" +
  ".bunnia-surface-state-loading { border-color: #2563eb; }\n" +
  ".bunnia-surface-state-ready { border-color: #16a34a; }\n" +
  ".bunnia-surface-state-stale { border-color: #d97706; }\n" +
  ".bunnia-surface-state-error { border-color: #dc2626; }\n" +
  ".bunnia-surface-state-retry { border-color: #0891b2; }\n" +
  ".bunnia-surface-state-cancelled { border-color: #64748b; }\n" +
  ".bunnia-surface-state-degraded { border-color: #7c3aed; }\n" +
  ".bunnia-review { display: flex; gap: 12rpx; align-items: center; padding: 16rpx; border: 1rpx solid #e5e7eb; border-radius: 8rpx; background: #ffffff; box-sizing: border-box; }\n" +
  ".bunnia-review-button { min-height: 88rpx; padding: 8rpx 14rpx; border: 1rpx solid #94a3b8; border-radius: 8rpx; background: #ffffff; color: #111827; font-size: 24rpx; line-height: 1.35; }\n" +
  ".bunnia-review-button-disabled { color: #64748b; background: #f1f5f9; }\n" +
  ".bunnia-review-pending { border-color: #d97706; }\n" +
  ".bunnia-review-approved { border-color: #16a34a; }\n" +
  ".bunnia-review-rejected { border-color: #dc2626; }\n" +
  ".bunnia-review-cancelled { border-color: #64748b; }\n" +
  ".bunnia-review-failed { border-color: #dc2626; background: #fef2f2; }\n" +
  ".bunnia-scene { position: relative; overflow: hidden; max-width: 100%; border: 1rpx solid #d1d5db; border-radius: 8rpx; background: #eef6f3; box-sizing: border-box; }\n" +
  ".bunnia-scene-layer { position: absolute; left: 0; top: 0; right: 0; bottom: 0; }\n" +
  ".bunnia-scene-marker { position: absolute; transform: translate(-50%, -50%); min-width: 120rpx; min-height: 88rpx; padding: 10rpx 14rpx; border: 2rpx solid #64748b; border-radius: 8rpx; background: #ffffff; box-sizing: border-box; font-size: 24rpx; line-height: 1.4; text-align: center; }\n" +
  ".bunnia-scene-sprite { display: block; width: 48rpx; height: 48rpx; margin: 0 auto 4rpx; }\n" +
  ".bunnia-scene-with-threads { position: relative; display: block; }\n" +
  ".bunnia-scene-thread-overlay { display: flex; flex-wrap: wrap; gap: 8rpx; }\n" +
  ".bunnia-scene-thread-overlay-anchored { position: absolute; left: 0; top: 0; right: 0; bottom: 0; display: block; pointer-events: none; }\n" +
  ".bunnia-scene-thread { min-width: 88rpx; min-height: 88rpx; padding: 6rpx 10rpx; border: 1rpx solid #64748b; border-radius: 8rpx; background: #ffffff; color: #111827; font-size: 22rpx; line-height: 1.35; text-align: center; box-sizing: border-box; }\n" +
  ".bunnia-scene-thread-anchored { position: absolute; transform: translate(-50%, -50%); pointer-events: auto; }\n" +
  ".bunnia-scene-thread-running { border-color: #2563eb; }\n" +
  ".bunnia-scene-thread-review { border-color: #d97706; }\n" +
  ".bunnia-scene-thread-failed { border-color: #dc2626; }\n" +
  ".bunnia-status-stable { border-color: #16a34a; }\n" +
  ".bunnia-status-review { border-color: #d97706; }\n" +
  ".bunnia-status-running { border-color: #2563eb; }\n" +
  ".bunnia-status-selected { border-color: #0891b2; background: #ecfeff; }\n" +
  ".bunnia-status-default { border-color: #64748b; }"
}

///|
fn escape_xml(value : String) -> String {
  value
  .replace_all(old="&", new="&")
  .replace_all(old="<", new="<")
  .replace_all(old=">", new=">")
  .replace_all(old="\"", new=""")
  .to_string()
}

///|
/// WeChat parses `{{ ... }}` as source code inside a WXML attribute. Encoding
/// expression operators such as `&&`, `<`, or `>` as XML entities makes the
/// WXML compiler reject otherwise valid bindings. Literal attributes retain
/// full XML escaping; binding attributes only need their outer quote escaped.
fn escape_wxml_attr(value : String) -> String {
  if value.contains("{{") && value.contains("}}") {
    value.replace_all(old="\"", new=""")
  } else {
    escape_xml(value)
  }
}

///|
/// Text nodes may mix literal copy with one or more `{{ ... }}` expressions.
/// Escape only the literal segments; expression operators must reach the WXML
/// compiler unchanged.
fn escape_wxml_text(value : String) -> String {
  let out = StringBuilder::new()
  let mut rest = value
  while !rest.is_empty() {
    match rest.find("{{") {
      None => {
        out.write_string(escape_xml(rest))
        rest = ""
      }
      Some(start) => {
        out.write_string(escape_xml(rest.sub(start=0, end=start).to_owned()))
        let binding = rest.sub(start~, end=rest.length()).to_owned()
        match binding.find("}}") {
          None => {
            out.write_string(escape_xml(binding))
            rest = ""
          }
          Some(close) => {
            let binding_end = close + 2
            out.write_string(binding.sub(start=0, end=binding_end).to_owned())
            rest = binding
              .sub(start=binding_end, end=binding.length())
              .to_owned()
          }
        }
      }
    }
  }
  out.to_string()
}

///|
fn escape_json(value : String) -> String {
  value
  .replace(old="\\", new="\\\\")
  .replace(old="\"", new="\\\"")
  .replace(old="\n", new="\\n")
  .to_string()
}