///|
/// Immutable mixed KV1/text KV3 workspace. Operations return a new snapshot; hosts
/// decide how to present or save it. No multi-file filesystem atomicity is claimed.
struct Workspace {
  documents : Map[String, SourceDocument]
  kv3_documents : Map[String, KV3SourceDocument]
  options : ParseOptions
}

///|
fn workspace_path(name : String) -> String raise {
  if name.is_empty() ||
    name.has_prefix("/") ||
    name.contains("\\") ||
    name.contains(":") ||
    name.contains("\u0000") ||
    normalize_reference(name) != name {
    raise InvalidReference("Workspace paths must be normalized relative names")
  }
  name
}

///|
pub fn new_workspace(
  files : Map[String, String],
  options~ : ParseOptions = ParseOptions::default(),
) -> Workspace raise {
  if files.is_empty() || files.length() > 64 {
    raise InvalidReference("Workspace requires 1..64 files")
  }
  let documents : Map[String, SourceDocument] = Map([])
  let kv3_documents : Map[String, KV3SourceDocument] = Map([])
  let mut total = 0
  for name, text in files {
    workspace_path(name) |> ignore
    total += text.char_length()
    if total > 16777216 {
      raise InvalidReference("Workspace exceeds 16 million characters")
    }
    if lsp_is_kv3(name, text) {
      kv3_documents[name] = parse_kv3_preserving(text, options~)
    } else {
      documents[name] = parse_preserving(text, options~)
    }
  }
  { documents, kv3_documents, options, }
}

///|
pub fn Workspace::files(self : Workspace) -> Map[String, String] {
  let files : Map[String, String] = Map([])
  for name, document in self.documents {
    files[name] = document.text()
  }
  for name, document in self.kv3_documents {
    files[name] = document.text()
  }
  files
}

///|
fn Workspace::has_file(self : Workspace, name : String) -> Bool {
  self.documents.contains(name) || self.kv3_documents.contains(name)
}

///|
fn relative_reference(source : String, target : String) -> String {
  let parent = source.split("/").map(s => s.to_owned()).collect()
  parent.pop() |> ignore
  let parts = target.split("/").map(s => s.to_owned()).collect()
  let mut common = 0
  while common < parent.length() &&
        common < parts.length() &&
        parent[common] == parts[common] {
    common += 1
  }
  let result : Array[String] = []
  for _ in common.. SourceDocument raise {
  let changes : Array[Change] = []
  let occurrences : Map[String, Int32] = Map([])
  for entry in document.doc.entries {
    let occurrence = occurrences.get(entry.key).unwrap_or(0)
    occurrences[entry.key] = occurrence + 1
    if !entry.key.equal_ignore_ascii_case("#include") &&
      !entry.key.equal_ignore_ascii_case("#base") {
      continue
    }
    guard entry.value is Text(reference) else {
      raise InvalidReference("Reference must have a text path")
    }
    let original_target = child_reference(old_name, reference)
    let target = if moved_from == Some(original_target) {
      moved_to.unwrap()
    } else {
      original_target
    }
    if target != original_target || old_name != new_name {
      let path = relative_reference(new_name, target)
      if path != reference {
        changes.push({
          path: [{ key: entry.key, occurrence, }],
          operation: Replace(Text(path)),
        })
      }
    }
  }
  if changes.is_empty() {
    document
  } else {
    document.apply_snapshot(changes)
  }
}

///|
/// Rename incoming references, including inactive branches, and rebase outgoing
/// references when the moved file changes directory.
pub fn Workspace::rename_file(
  self : Workspace,
  from : String,
  to : String,
) -> Workspace raise {
  workspace_path(from) |> ignore
  workspace_path(to) |> ignore
  if !self.has_file(from) {
    raise InvalidReference("Source file does not exist")
  }
  if from == to {
    return self
  }
  if self.has_file(to) {
    raise InvalidReference("Destination already exists")
  }
  let files : Map[String, String] = Map([])
  for name, document in self.documents {
    let renamed = if name == from { to } else { name }
    files[renamed] = rewritten_references(
      document,
      name,
      renamed,
      Some(from),
      Some(to),
    ).text()
  }
  for name, document in self.kv3_documents {
    files[if name == from { to } else { name }] = document.text()
  }
  new_workspace(files, options=self.options)
}

///|
pub fn Workspace::copy_file(
  self : Workspace,
  from : String,
  to : String,
) -> Workspace raise {
  workspace_path(to) |> ignore
  if !self.has_file(from) {
    raise InvalidReference("Source file does not exist")
  }
  if self.has_file(to) {
    raise InvalidReference("Destination already exists")
  }
  let files = self.files()
  files[to] = match self.documents.get(from) {
    Some(document) =>
      rewritten_references(document, from, to, None, None).text()
    None => self.kv3_documents[from].text()
  }
  new_workspace(files, options=self.options)
}

///|
pub fn Workspace::add_file(
  self : Workspace,
  name : String,
  text : String,
) -> Workspace raise {
  workspace_path(name) |> ignore
  if self.has_file(name) {
    raise InvalidReference("File already exists")
  }
  let files = self.files()
  files[name] = text
  new_workspace(files, options=self.options)
}

///|
pub fn Workspace::remove_file(
  self : Workspace,
  name : String,
) -> Workspace raise {
  if !self.has_file(name) {
    raise InvalidReference("File does not exist")
  }
  let files : Map[String, String] = Map([])
  for source, document in self.documents {
    if source == name {
      continue
    }
    for entry in document.doc.entries {
      if entry.key.equal_ignore_ascii_case("#include") ||
        entry.key.equal_ignore_ascii_case("#base") {
        guard entry.value is Text(path) else {
          raise InvalidReference("Invalid file directive")
        }
        if child_reference(source, path) == name {
          raise InvalidReference("File is still referenced by " + source)
        }
      }
    }
    files[source] = document.text()
  }
  for source, document in self.kv3_documents {
    if source != name {
      files[source] = document.text()
    }
  }
  new_workspace(files, options=self.options)
}

///|
pub fn Workspace::apply(self : Workspace, plan : Json) -> Workspace raise {
  guard plan is Array(changes) && changes.length() <= 1000 else {
    raise InvalidReference("Expected at most 1000 workspace operations")
  }
  let mut result = self
  for change in changes {
    guard change is Object(row) && row.get("op") is Some(String(op)) else {
      raise InvalidReference("Invalid workspace operation")
    }
    result = match op {
      "kv3-query" if row.length() == 4 &&
        row.get("expression") is Some(String(expression)) &&
        row.get("operation") is Some(operation) &&
        row.get("files") is Some(files) =>
        result.edit_query_kv3(
          expression,
          kv3_query_edit_from_json(operation),
          files=workspace_selection(files),
        )
      "query" if row.length() == 4 &&
        row.get("expression") is Some(String(expression)) &&
        row.get("operation") is Some(operation) &&
        row.get("files") is Some(files) =>
        result.edit_query(
          expression,
          query_edit_from_json(operation),
          files=workspace_selection(files),
        )
      "rename" | "copy" if row.length() == 3 &&
        row.get("from") is Some(String(from)) &&
        row.get("to") is Some(String(to)) =>
        if op == "rename" {
          result.rename_file(from, to)
        } else {
          result.copy_file(from, to)
        }
      "add" if row.length() == 3 &&
        row.get("name") is Some(String(name)) &&
        row.get("text") is Some(String(text)) => result.add_file(name, text)
      "remove" if row.length() == 2 && row.get("name") is Some(String(name)) =>
        result.remove_file(name)
      _ => raise InvalidReference("Unknown workspace operation or fields")
    }
  }
  result
}

///|
pub fn workspace_from_json(
  json : Json,
  options~ : ParseOptions = ParseOptions::default(),
) -> Workspace raise {
  guard json is Object(rows) else {
    raise InvalidReference("Expected a workspace file map")
  }
  let files : Map[String, String] = Map([])
  for name, value in rows {
    guard value is String(text) else {
      raise InvalidReference("Workspace file contents must be text")
    }
    files[name] = text
  }
  new_workspace(files, options~)
}

///|
pub fn Workspace::to_json(self : Workspace) -> Json {
  let rows : Map[String, Json] = Map([])
  for name, text in self.files() {
    rows[name] = Json::string(text)
  }
  Json::object(rows)
}

///|
pub fn edit_workspace_snapshot(
  snapshot : Json,
  plan : Json,
  options~ : ParseOptions = ParseOptions::default(),
) -> Json raise {
  guard snapshot is Object(fields) &&
    fields.get("format") == Some(Json::string("moonvalve-project-v1")) &&
    fields.get("source") is Some(String(source)) &&
    fields.get("files") is Some(files) else {
    raise InvalidReference("Expected MoonValve project snapshot")
  }
  guard plan is Array(changes) && changes.length() <= 1000 else {
    raise InvalidReference("Expected bounded workspace plan")
  }
  let mut workspace = workspace_from_json(files, options~)
  let mut entry = source
  if !workspace.has_file(entry) {
    raise InvalidReference("Snapshot entry is missing")
  }
  for change in changes {
    workspace = workspace.apply(Json::array([change]))
    if change is Object(row) &&
      row.get("op") == Some(Json::string("rename")) &&
      row.get("from") == Some(Json::string(entry)) &&
      row.get("to") is Some(String(to)) {
      entry = to
    }
    if !workspace.has_file(entry) {
      let names = workspace.files().keys().collect()
      names.sort()
      entry = names[0]
    }
  }
  Json::object({
    "format": Json::string("moonvalve-project-v1"),
    "source": Json::string(entry),
    "files": workspace.to_json(),
  })
}

///|
fn workspace_selection(json : Json) -> Array[String] raise {
  guard json is Array(values) && values.length() <= 64 else {
    raise InvalidQuery("Expected at most 64 selected files")
  }
  values.map(value => {
    guard value is String(name) else {
      raise InvalidQuery("Expected selected file names")
    }
    name
  })
}

///|
fn Workspace::selected_names(
  self : Workspace,
  files : Array[String],
) -> Array[String] raise {
  let selected : Map[String, Bool] = Map([])
  for name in files {
    if !self.has_file(name) || selected.contains(name) {
      raise InvalidQuery("Missing or duplicate selected file")
    }
    selected[name] = true
  }
  let names = if files.is_empty() {
    self.files().keys().collect()
  } else {
    files.map(s => s)
  }
  names.sort_by((a, b) => a.lexical_compare(b))
  names
}

///|
/// Empty file selection means all files. Results are ordered by file then source.
pub fn Workspace::select(
  self : Workspace,
  expression : String,
  files~ : Array[String] = [],
) -> Json raise {
  let query = compile_query(expression)
  let rows : Array[Json] = []
  let mut total = 0
  for name in self.selected_names(files) {
    if !self.documents.contains(name) {
      if !files.is_empty() {
        raise InvalidQuery("Selected file is KV3; use typed workspace queries")
      }
      continue
    }
    let matches = query.select(self.documents[name].doc)
    total += matches.length()
    if total > 10000 {
      raise InvalidQuery("Workspace exceeds 10000 query matches")
    }
    if !matches.is_empty() {
      rows.push(
        Json::object({
          "file": Json::string(name),
          "matches": located_values_to_json(matches),
        }),
      )
    }
  }
  Json::array(rows)
}

///|
/// Every edit addresses original file contents. Failure returns no new workspace.
pub fn Workspace::edit_query(
  self : Workspace,
  expression : String,
  operation : QueryEdit,
  files~ : Array[String] = [],
) -> Workspace raise {
  self.select(expression, files~) |> ignore
  let output = self.files()
  for name in self.selected_names(files) {
    if !self.documents.contains(name) {
      continue
    }
    output[name] = self.documents[name].edit_query(expression, operation).text()
  }
  new_workspace(output, options=self.options)
}

///|
pub fn query_workspace_snapshot(
  snapshot : Json,
  expression : String,
  files~ : Array[String] = [],
  options~ : ParseOptions = ParseOptions::default(),
) -> Json raise {
  guard snapshot is Object(fields) &&
    fields.get("format") == Some(Json::string("moonvalve-project-v1")) &&
    fields.get("source") is Some(String(source)) &&
    fields.get("files") is Some(contents) else {
    raise InvalidReference("Expected MoonValve project snapshot")
  }
  let workspace = workspace_from_json(contents, options~)
  if !workspace.has_file(source) {
    raise InvalidReference("Snapshot entry is missing")
  }
  workspace.select(expression, files~)
}