///|
using @devhub {type Devhub}

///|
struct ArtifactDigest {
  hash : Int
  length : Int
} derive(Eq)

///|
struct RunningServer {
  process : @process.Process
  digest : ArtifactDigest
}

///|
async fn artifact_digest(path : SourcePath) -> ArtifactDigest {
  let bytes = @fs.read_file(source_path_to_string(path)).binary()
  { hash: bytes.hash(), length: bytes.length() }
}

///|
fn ArtifactDigest::directory_name(self : ArtifactDigest) -> String {
  "\{self.hash}-\{self.length}"
}

///|
fn server_needs_restart(
  previous : ArtifactDigest?,
  previous_running : Bool,
  next : ArtifactDigest,
) -> Bool {
  match previous {
    None => true
    Some(previous) => previous != next || !previous_running
  }
}

///|
fn wasm_server_args(
  project_root : SourcePath,
  server_entry : SourcePath,
) -> Array[String] {
  ["run", "--target", "wasm", moon_entry(project_root, server_entry)]
}

///|
fn mount_browser_pages(
  vfs : @vfs.VirtualFileSystem,
  app_html : String,
  direct : Bool,
) -> Unit {
  if direct {
    vfs.mount("/index.html", FileContent(inject_direct_assets(app_html)))
    vfs.mount("/__warren/direct.js", FileContent(direct_client_js))
  } else {
    vfs.mount(
      "/__warren/preview/index.html",
      FileContent(inject_preview_assets(app_html)),
    )
    vfs.mount("/index.html", FileContent(shell_html_template))
    vfs.mount("/warren_devtool.js", FileContent(embbed_devtool_js))
    vfs.mount("/warren_devtool.css", FileContent(embbed_devtool_css))
  }
}

///|
async fn publish_browser_static(
  vfs : @vfs.VirtualFileSystem,
  static_dir : SourcePath,
  direct : Bool,
) -> Unit {
  vfs.mount("/", DirectoryMapping(static_dir))
  let index = static_dir.join("index.html")
  let app_html = if @fs.exists(source_path_to_string(index)) {
    @fs.read_file(source_path_to_string(index)).text()
  } else {
    app_html_template
  }
  mount_browser_pages(vfs, app_html, direct)
}

///|
async fn browser_dev(
  layout : ProjectLayout,
  temp : SourcePath,
  port : UInt,
  direct : Bool,
) -> Unit {
  let root = source_path_to_string(layout.root)
  let temp_relative = temp.relative(layout.root)
  let watcher = @fs.Watcher(root, ignored_paths=path => {
    ignored_watch_path_with_temp(path, temp_relative)
  })
  defer watcher.close()
  let vfs = @vfs.new(Map([]))
  mount_browser_pages(vfs, app_html_template, direct)
  let mut next_static = temp.join("static")
  let mut standby_static = temp.join("static-next")
  @async.with_task_group <| group => {
    let html_fallback_path = if direct { Some("/index.html") } else { None }
    let hub = Devhub(port, vfs, html_fallback_path?)
    group.spawn_bg(no_wait=true, allow_failure=true, () => hub.run_forever())
    log("info", "Running server on http://127.0.0.1:\{hub.port()}")
    while true {
      hub.broadcast(Building)
      log("warren", "Building browser entry...")
      try {
        let browser_artifact = run_build_only(
          layout.root,
          layout.browser_entry,
          "js",
          false,
        )
        assemble_static(
          layout.root,
          layout.public_dir,
          browser_artifact,
          next_static,
          false,
        )
        publish_browser_static(vfs, next_static, direct)
      } catch {
        error if @async.is_being_cancelled() => raise error
        error => {
          let diagnostics = match error {
            Failure(message) => message
            error => "\{error}"
          }
          hub.broadcast(BuildFailed(diagnostics))
          log("error", diagnostics)
        }
      } noraise {
        _ => {
          let old_active = next_static
          next_static = standby_static
          standby_static = old_active
          hub.broadcast(Reload)
          log("warren", "Changes detected. Reloading...")
        }
      }
      watcher.wait_any()
    }
  }
}

///|
fn process_is_running(process : @process.Process) -> Bool {
  try process.try_wait() catch {
    _ => false
  } noraise {
    None => true
    Some(_) => false
  }
}

///|
async fn stop_server(server : RunningServer?) -> Unit {
  if server is Some(server) {
    if process_is_running(server.process) {
      // `Process::cancel()` cancels its monitoring task, so a following
      // `wait()` raises `Cancelled` even after the child has terminated.
      // Run the same graceful handler separately and keep waiting for the OS
      // process; once it exits, this group cancels the pending forced kill.
      @async.with_task_group <| group => {
        group.spawn_bg(no_wait=true, () => {
          @process.graceful_cancel(timeout=5000)(server.process.pid)
        })
        ignore(server.process.wait())
      }
    } else {
      ignore(server.process.wait())
    }
  }
}

///|
async fn stage_native_server(
  artifact : SourcePath,
  run_dir : SourcePath,
  digest : ArtifactDigest,
) -> SourcePath {
  let directory = run_dir.join(digest.directory_name())
  if !@fs.exists(source_path_to_string(directory)) {
    @fs.mkdir(
      source_path_to_string(directory),
      permission=0o755,
      recursive=true,
    )
  }
  let basename = Path::basename(source_path_to_string(artifact)).to_owned()
  let executable = directory.join(basename)
  if !@fs.exists(source_path_to_string(executable)) {
    copy(src=artifact, dst=executable)
    chmod_executable(executable)
  }
  executable
}

///|
fn dev_server_environment(
  static_dir : SourcePath,
  port : UInt,
  client : String,
) -> Map[String, String] {
  {
    "WARREN_MODE": "DEV",
    "WARREN_PORT": "\{port}",
    "WARREN_CLIENT": client,
    "WARREN_DIST": source_path_to_string(static_dir),
  }
}

///|
async fn start_server(
  group : @async.TaskGroup[Unit],
  layout : ProjectLayout,
  target : ServerTarget,
  run_dir : SourcePath,
  artifact : SourcePath,
  digest : ArtifactDigest,
  static_dir : SourcePath,
  port : UInt,
  dev_client : String,
) -> @process.Process {
  let environment = dev_server_environment(static_dir, port, dev_client)
  match target {
    Native => {
      let executable = stage_native_server(artifact, run_dir, digest)
      log("server", "Starting \{executable} with WARREN_PORT=\{port}")
      @process.spawn(
        group,
        source_path_to_string(executable),
        [],
        cwd=source_path_to_string(layout.root),
        extra_env=environment,
        inherit_env=true,
        cancel_handler=@process.graceful_cancel(timeout=5000),
        no_wait=true,
      )
    }
    Wasm => {
      let server_entry = match layout.server_entry {
        Some(entry) => entry
        None => fail("Cannot start a wasm server without a server entry.")
      }
      let args = wasm_server_args(layout.root, server_entry)
      log("server", "Running `moon \{args.join(" ")}` with WARREN_PORT=\{port}")
      @process.spawn(
        group,
        "moon",
        args,
        cwd=source_path_to_string(layout.root),
        extra_env=environment,
        inherit_env=true,
        cancel_handler=@process.graceful_cancel(timeout=5000),
        no_wait=true,
      )
    }
  }
}

///|
async fn fullstack_dev(
  layout : ProjectLayout,
  server_entry : SourcePath,
  target : ServerTarget,
  temp : SourcePath,
  port : UInt,
) -> Unit {
  let root = source_path_to_string(layout.root)
  let static_dir = temp.join("static")
  let run_dir = temp.join("run")
  let temp_relative = temp.relative(layout.root)
  let watcher = @fs.Watcher(root, ignored_paths=path => {
    ignored_watch_path_with_temp(path, temp_relative)
  })
  defer watcher.close()
  @async.with_task_group <| group => {
    let hub = Devhub(
      0U,
      @vfs.new({ "/__warren/client.js": FileContent(fullstack_client_js) }),
    )
    group.spawn_bg(no_wait=true, allow_failure=true, () => hub.run_forever())
    let events_port = hub.port()
    let dev_client = "http://127.0.0.1:\{events_port}/__warren/client.js"
    let mut running : RunningServer? = None
    while true {
      hub.broadcast(Building)
      log("warren", "Building browser entry...")
      let browser_artifact = run_build_only(
        layout.root,
        layout.browser_entry,
        "js",
        false,
      )
      assemble_static(
        layout.root,
        layout.public_dir,
        browser_artifact,
        static_dir,
        false,
      )
      log("warren", "Building server entry...")
      let server_artifact = run_build_only(
        layout.root,
        server_entry,
        target.moon_target(),
        false,
      )
      let digest = artifact_digest(server_artifact)
      let (previous_digest, previous_running) = match running {
        None => (None, false)
        Some(server) =>
          (Some(server.digest), process_is_running(server.process))
      }
      let restart = server_needs_restart(
        previous_digest, previous_running, digest,
      )
      if restart {
        stop_server(running)
        let process = start_server(
          group, layout, target, run_dir, server_artifact, digest, static_dir, port,
          dev_client,
        )
        running = Some({ process, digest })
      } else {
        log(
          "server", "Server artifact is unchanged; keeping the server running.",
        )
      }
      hub.broadcast(Reload)
      log("warren", "Changes detected. Reloading browser resources...")
      watcher.wait_any()
    }
  }
}

///|
async fn dev_project(
  layout : ProjectLayout,
  server_target : ServerTarget,
  port : UInt,
  direct : Bool,
) -> Unit {
  let temp = SourcePath::new(@fs.tmpdir(prefix="warren-dev"))
  match layout.server_entry {
    None => browser_dev(layout, temp, port, direct)
    Some(server_entry) =>
      fullstack_dev(layout, server_entry, server_target, temp, port)
  }
}

///|
test "server restart depends on digest and process state" {
  let digest : ArtifactDigest = { hash: 42, length: 10 }
  inspect(server_needs_restart(None, false, digest), content="true")
  inspect(server_needs_restart(Some(digest), true, digest), content="false")
  inspect(server_needs_restart(Some(digest), false, digest), content="true")
  inspect(
    server_needs_restart(Some(digest), true, { hash: 43, length: 10 }),
    content="true",
  )
}

///|
test "fullstack server receives the Warren development environment" {
  let static_dir = SourcePath::new("static")
  let client = "http://127.0.0.1:43123/__warren/client.js"
  let environment = dev_server_environment(static_dir, 4300U, client)
  assert_eq(environment.length(), 4)
  assert_eq(environment.get("WARREN_MODE"), Some("DEV"))
  assert_eq(environment.get("WARREN_PORT"), Some("4300"))
  assert_eq(environment.get("WARREN_CLIENT"), Some(client))
  assert_eq(
    environment.get("WARREN_DIST"),
    Some(source_path_to_string(static_dir)),
  )
}

///|
test "wasm server is launched through moon run" {
  let root = SourcePath::new("test-project")
  let args = wasm_server_args(root, root.join("cmd/server"))
  inspect(args.length(), content="4")
  inspect(args[0], content="run")
  inspect(args[2], content="wasm")
  inspect(args.contains("--target-dir"), content="false")
  inspect(args[3] == "cmd/server" || args[3] == "cmd\\server", content="true")
}