///|
enum ServerTarget {
  Native
  Wasm
}

///|
fn ServerTarget::moon_target(self : ServerTarget) -> String {
  match self {
    Native => "native"
    Wasm => "wasm"
  }
}

///|
struct ProjectLayout {
  root : SourcePath
  browser_entry : SourcePath
  server_entry : SourcePath?
  public_dir : SourcePath?
}

///|
fn resolve_from(root : String, raw : String) -> String {
  if Path::is_absolute(raw) {
    Path::normalize(raw).to_string()
  } else {
    Path::join(root, raw).normalize().to_string()
  }
}

///|
fn relative_is_inside(relative : String, allow_equal : Bool) -> Bool {
  if relative == "" || relative == "." {
    return allow_equal
  }
  !Path::is_absolute(relative) &&
  relative != ".." &&
  !relative.has_prefix("../") &&
  !relative.has_prefix("..\\")
}

///|
fn path_is_inside(child : String, parent : String, allow_equal~ : Bool) -> Bool {
  relative_is_inside(
    Path::relative(child, base=parent).to_string(),
    allow_equal,
  )
}

///|
async fn effective_root(raw : String?) -> SourcePath {
  let cwd = match @env.current_dir() {
    Some(path) => path
    None => fail("Unable to determine the current directory.")
  }
  let candidate = resolve_from(cwd, raw.unwrap_or("."))
  guard @fs.exists(candidate) else {
    fail("Project directory does not exist: `\{candidate}`")
  }
  guard @fs.kind(candidate) is Directory else {
    fail("Project path is not a directory: `\{candidate}`")
  }
  SourcePath::new(@fs.realpath(candidate))
}

///|
async fn resolve_input_dir(
  root : SourcePath,
  raw : String,
  label : String,
) -> SourcePath {
  let root_string = source_path_to_string(root)
  let candidate = resolve_from(root_string, raw)
  guard @fs.exists(candidate) else {
    fail("\{label} directory does not exist: `\{candidate}`")
  }
  let canonical = @fs.realpath(candidate)
  guard @fs.kind(canonical) is Directory else {
    fail("\{label} path is not a directory: `\{candidate}`")
  }
  guard path_is_inside(canonical, root_string, allow_equal=true) else {
    fail("\{label} must stay inside -C `\{root_string}`: `\{candidate}`")
  }
  SourcePath::new(canonical)
}

///|
async fn nearest_existing_parent(path : String) -> (String, String) {
  for current = path {
    if @fs.exists(current) {
      break (current, @fs.realpath(current))
    }
    let parent = Path::dirname(current).to_string()
    if parent == current {
      break fail("No existing parent directory for `\{path}`.")
    }
    continue parent
  }
}

///|
fn rebuild_from_canonical_parent(
  path : String,
  lexical_parent : String,
  canonical_parent : String,
) -> String {
  let remainder = Path::relative(path, base=lexical_parent).to_string()
  if remainder == "" || remainder == "." {
    canonical_parent
  } else {
    Path::join(canonical_parent, remainder).normalize().to_string()
  }
}

///|
fn protected_output_path(relative : String) -> Bool {
  let normalized = relative.split("\\").join("/")
  match normalized.split("/").collect() {
    [first, ..] => first == ".git" || first == "_build" || first == ".mooncakes"
    [] => false
  }
}

///|
async fn resolve_dist_dir(root : SourcePath, raw : String) -> SourcePath {
  let root_string = source_path_to_string(root)
  let candidate = resolve_from(root_string, raw)
  let relative = Path::relative(candidate, base=root_string).to_string()
  guard relative_is_inside(relative, false) else {
    fail("--dist must be a child of -C `\{root_string}`: `\{candidate}`")
  }
  guard !protected_output_path(relative) else {
    fail("Refusing dangerous --dist path: `\{candidate}`")
  }
  let resolved_output = if @fs.exists(candidate) {
    let output_kind = @fs.kind(candidate, follow_symlink=false)
    guard !(output_kind is SymLink) else {
      fail("--dist must not be a symbolic link: `\{candidate}`")
    }
    guard output_kind is Directory else {
      fail("--dist exists but is not a directory: `\{candidate}`")
    }
    let canonical = @fs.realpath(candidate)
    guard path_is_inside(canonical, root_string, allow_equal=false) else {
      fail("--dist resolves outside -C: `\{candidate}`")
    }
    canonical
  } else {
    let (lexical_parent, canonical_parent) = nearest_existing_parent(candidate)
    guard @fs.kind(canonical_parent) is Directory else {
      fail("--dist parent is not a directory: `\{lexical_parent}`")
    }
    let canonical = rebuild_from_canonical_parent(
      candidate, lexical_parent, canonical_parent,
    )
    guard path_is_inside(canonical, root_string, allow_equal=false) else {
      fail("--dist has a parent that resolves outside -C: `\{candidate}`")
    }
    canonical
  }
  let resolved_relative = Path::relative(resolved_output, base=root_string).to_string()
  guard !protected_output_path(resolved_relative) else {
    fail("Refusing dangerous --dist path: `\{candidate}`")
  }
  SourcePath::new(resolved_output)
}

///|
fn paths_overlap(a : SourcePath, b : SourcePath) -> Bool {
  let a = source_path_to_string(a)
  let b = source_path_to_string(b)
  path_is_inside(a, b, allow_equal=true) ||
  path_is_inside(b, a, allow_equal=true)
}

///|
fn validate_dist_inputs(
  root : SourcePath,
  dist : SourcePath,
  browser_entry : SourcePath,
  server_entry : SourcePath?,
  public_dir : SourcePath?,
) -> Unit raise {
  if browser_entry != root {
    guard !paths_overlap(dist, browser_entry) else {
      fail("--dist must not overlap --browser-entry.")
    }
  }
  if server_entry is Some(entry) {
    guard !paths_overlap(dist, entry) else {
      fail("--dist must not overlap --server-entry.")
    }
  }
  if public_dir is Some(dir) {
    guard !paths_overlap(dist, dir) else {
      fail("--dist must not overlap --public-dir.")
    }
  }
}

///|
async fn resolve_project_layout(
  root : SourcePath,
  browser_entry_raw : String?,
  server_entry_raw : String?,
  public_dir_raw : String?,
) -> ProjectLayout {
  let browser_entry = if browser_entry_raw is Some(raw) {
    guard raw != "" else { fail("Browser entry is required.") }
    resolve_input_dir(root, raw, "Browser entry")
  } else {
    resolve_input_dir(root, "cmd/browser", "Browser entry")
  }
  let server_entry = if server_entry_raw is Some(raw) {
    if raw == "" {
      None
    } else {
      Some(resolve_input_dir(root, raw, "Server entry"))
    }
  } else {
    let conventional = resolve_from(source_path_to_string(root), "cmd/server")
    if @fs.exists(conventional) {
      Some(resolve_input_dir(root, "cmd/server", "Server entry"))
    } else {
      None
    }
  }
  let public_dir = match public_dir_raw {
    Some(raw) => Some(resolve_input_dir(root, raw, "Public"))
    None => {
      let conventional = resolve_from(source_path_to_string(root), "public")
      if @fs.exists(conventional) {
        Some(resolve_input_dir(root, "public", "Public"))
      } else {
        None
      }
    }
  }
  { root, browser_entry, server_entry, public_dir }
}

///|
async fn resolve_mbtx_layout(
  root : SourcePath,
  raw : String,
  public_dir_raw : String?,
) -> ProjectLayout {
  guard raw.has_suffix(".mbtx") else {
    fail("Standalone browser entry must use the `.mbtx` extension.")
  }
  let root_string = source_path_to_string(root)
  let candidate = resolve_from(root_string, raw)
  guard @fs.exists(candidate) else {
    fail("Standalone browser entry does not exist.")
  }
  let canonical = @fs.realpath(candidate)
  guard @fs.kind(canonical) is Regular else {
    fail("Standalone browser entry must be a regular file.")
  }
  guard path_is_inside(canonical, root_string, allow_equal=false) else {
    fail("Standalone browser entry must stay inside the project root.")
  }
  let public_dir = match public_dir_raw {
    Some(value) => Some(resolve_input_dir(root, value, "Public"))
    None => {
      let conventional = resolve_from(root_string, "public")
      if @fs.exists(conventional) {
        Some(resolve_input_dir(root, "public", "Public"))
      } else {
        None
      }
    }
  }
  {
    root,
    browser_entry: SourcePath::new(canonical),
    server_entry: None,
    public_dir,
  }
}

///|
fn parse_server_target(raw : String?) -> ServerTarget raise {
  match raw {
    None | Some("wasm") => Wasm
    Some("native") => Native
    Some(value) =>
      fail("Invalid --server-target `\{value}`. Expected `native` or `wasm`.")
  }
}

///|
fn parse_port(raw : String?) -> UInt raise {
  let value = raw.unwrap_or("4300")
  let parsed = @string.parse_int(value) catch {
    _ =>
      fail(
        "Invalid --port value `\{value}`. Expected a number from 1 to 65535.",
      )
  }
  guard parsed > 0 && parsed <= 65535 else {
    fail("Invalid --port value `\{value}`. Expected a number from 1 to 65535.")
  }
  parsed.reinterpret_as_uint()
}

///|
fn ignored_watch_path(path : String) -> Bool {
  let normalized = path.split("\\").join("/")
  normalized
  .split("/")
  .any(part => {
    part == ".git" || part == "_build" || part == ".mooncakes" || part == "dist"
  })
}

///|
fn ignored_watch_path_with_temp(path : String, temp_relative : String) -> Bool {
  if ignored_watch_path(path) {
    return true
  }
  guard relative_is_inside(temp_relative, false) else { return false }
  let normalized = path.split("\\").join("/")
  let normalized_temp = temp_relative.split("\\").join("/")
  normalized == normalized_temp || normalized.has_prefix("\{normalized_temp}/")
}

///|
test "relative path containment" {
  inspect(relative_is_inside("", true), content="true")
  inspect(relative_is_inside("child/file", false), content="true")
  inspect(relative_is_inside("../outside", true), content="false")
  inspect(relative_is_inside("..\\outside", true), content="false")
}

///|
test "watch ignores fixed generated directories" {
  inspect(ignored_watch_path("cmd/browser/main.mbt"), content="false")
  inspect(ignored_watch_path("cmd/browser/_build/out.js"), content="true")
  inspect(ignored_watch_path("nested/dist/index.js"), content="true")
  inspect(ignored_watch_path(".git/index"), content="true")
  inspect(
    ignored_watch_path_with_temp(".warren-temp/static/index.js", ".warren-temp"),
    content="true",
  )
}

///|
test "server target defaults to wasm" {
  assert_true(parse_server_target(None) is Wasm)
  assert_true(parse_server_target(Some("wasm")) is Wasm)
  assert_true(parse_server_target(Some("native")) is Native)
}

///|
async test "empty entry options clear their defaults" {
  let root_string = @fs.tmpdir(prefix="warren-entry-options")
  let root = SourcePath::new(@fs.realpath(root_string))
  @fs.mkdir(
    source_path_to_string(root.join("cmd/browser")),
    permission=0o755,
    recursive=true,
  )
  @fs.mkdir(
    source_path_to_string(root.join("cmd/server")),
    permission=0o755,
    recursive=true,
  )
  let browser_only = resolve_project_layout(root, None, Some(""), None)
  assert_true(browser_only.server_entry is None)
  let missing_browser = try
    resolve_project_layout(root, Some(""), None, None)
  catch {
    Failure(_) => true
    _ => false
  } noraise {
    _ => false
  }
  assert_true(missing_browser)
  @fs.rmdir(root_string, recursive=true)
}

///|
test "dist may be nested under a root browser entry" {
  let root = SourcePath::new("test-project")
  validate_dist_inputs(root, root.join("dist"), root, None, None)
}

///|
test "dist may not contain an entry" {
  let root = SourcePath::new("test-project")
  let rejected = try
    validate_dist_inputs(
      root,
      root.join("output"),
      root.join("output/cmd/browser"),
      None,
      None,
    )
  catch {
    Failure(_) => true
    _ => false
  } noraise {
    _ => false
  }
  inspect(rejected, content="true")
}