src/gleedoc/generate.gleam

import gleam/int
import gleam/list
import gleam/option
import gleam/result
import gleam/string
import gleedoc/parse.{type CodeBlock}
import gleedoc/scan
import simplifile
import snag

/// Configuration for test generation.
pub type Config {
  Config(
    /// Directory to write generated tests to, typically "test"
    output_dir: String,
  )
}

/// Generate test files from extracted code blocks.
pub fn generate_tests(
  blocks: List(CodeBlock),
  config: Config,
) -> Result(List(String), snag.Snag) {
  // Group blocks by the file they came from
  let by_file = group_by_file(blocks)

  by_file
  |> list.try_map(fn(pair) {
    let #(file, file_blocks) = pair
    let test_file_name = test_file_name(file)
    let output_dir = config.output_dir <> "/gleedoc"
    let test_path = output_dir <> "/" <> test_file_name
    let module_name = module_name_from_file(file)

    use _ <- result.try(
      simplifile.create_directory_all(output_dir)
      |> result.map_error(fn(err) {
        snag.new(
          "Failed to create directory: "
          <> output_dir
          <> " - "
          <> string.inspect(err),
        )
      }),
    )

    let block_imports = file_blocks |> list.flat_map(fn(b) { b.imports })
    let auto_imports = unique_imports(file, module_name)
    let all_imports = merge_imports(list.append(block_imports, auto_imports))

    let test_functions = generate_test_functions(file_blocks)

    let content =
      string.join(
        ["// Generated by gleedoc - do not edit manually", "", ..all_imports]
          |> list.append(test_functions),
        "\n",
      )

    use _ <- result.try(
      simplifile.write(test_path, content)
      |> result.map_error(fn(err) {
        snag.new(
          "Failed to write test file: "
          <> test_path
          <> " - "
          <> string.inspect(err),
        )
      }),
    )

    Ok(test_path)
  })
}

fn group_by_file(blocks: List(CodeBlock)) -> List(#(String, List(CodeBlock))) {
  blocks
  |> list.fold([], fn(groups, block) {
    let file = block.source.file
    case find_group(groups, file) {
      Ok(#(_, existing)) -> {
        list.map(groups, fn(pair) {
          case pair.0 == file {
            True -> #(file, [block, ..existing])
            False -> pair
          }
        })
      }
      Error(Nil) -> [#(file, [block]), ..groups]
    }
  })
  |> list.map(fn(pair) { #(pair.0, list.reverse(pair.1)) })
}

fn find_group(
  groups: List(#(String, List(CodeBlock))),
  file: String,
) -> Result(#(String, List(CodeBlock)), Nil) {
  list.find(groups, fn(pair) { pair.0 == file })
}

fn module_name_from_file(file: String) -> String {
  string.split_once(file, "/")
  |> result.map(fn(pair) { pair.1 })
  |> result.unwrap(file)
  |> string.replace(".gleam", "")
}

fn test_file_name(source_file: String) -> String {
  let name =
    string.split_once(source_file, "/")
    |> result.map(fn(pair) { pair.1 })
    |> result.unwrap(source_file)
    |> string.replace(".gleam", "")
    |> string.replace("/", "_")

  name <> "_gleedoc_test.gleam"
}

type Import {
  Import(module: String, names: List(String))
}

fn parse_import(line: String) -> Import {
  let rest = line |> string.trim |> string.drop_start(7) |> string.trim_start
  case string.split_once(rest, ".{") {
    Ok(#(module, names_part)) -> {
      let names =
        names_part
        |> string.replace("}", "")
        |> string.split(",")
        |> list.map(string.trim)
        |> list.filter(fn(s) { s != "" })
      Import(module, names)
    }
    Error(Nil) -> Import(rest, [])
  }
}

fn import_to_string(imp: Import) -> String {
  case imp.names {
    [] -> "import " <> imp.module
    names -> {
      let types =
        names
        |> list.filter(fn(n) { string.starts_with(n, "type ") })
        |> list.sort(string.compare)
      let values =
        names
        |> list.filter(fn(n) { !string.starts_with(n, "type ") })
        |> list.sort(string.compare)
      let names_str = string.join(list.append(types, values), ", ")
      "import " <> imp.module <> ".{" <> names_str <> "}"
    }
  }
}

fn merge_imports(imports: List(String)) -> List(String) {
  imports
  |> list.map(parse_import)
  |> list.fold([], fn(acc, imp) {
    case list_find_import(acc, imp.module) {
      Ok(existing) -> {
        let merged_names = list.unique(list.append(existing.names, imp.names))
        list.map(acc, fn(existing_imp) {
          case existing_imp.module == imp.module {
            True -> Import(..existing_imp, names: merged_names)
            False -> existing_imp
          }
        })
      }
      Error(Nil) -> [imp, ..acc]
    }
  })
  |> list.map(import_to_string)
  |> list.sort(string.compare)
}

fn list_find_import(
  imports: List(Import),
  module: String,
) -> Result(Import, Nil) {
  list.find(imports, fn(imp) { imp.module == module })
}

fn unique_imports(file: String, module_name: String) -> List(String) {
  // Try to read the source file and extract public names for unqualified imports.
  // If parsing fails, fall back to a simple qualified import.
  case simplifile.read(file) {
    Ok(source) -> {
      // Build the import for the target module itself.
      // Unwrap to [] on error or empty, then format accordingly.
      let names =
        scan.public_names(file, source)
        |> result.unwrap([])
      let target_import = case names {
        [] -> "import " <> module_name
        _ -> "import " <> module_name <> ".{" <> string.join(names, ", ") <> "}"
      }
      // Also carry over the source module's own top-level imports so that
      // doc examples can use them without having to restate them in the snippet.
      let source_imports =
        scan.module_imports(file, source)
        |> result.unwrap([])
      [target_import, ..source_imports]
    }
    Error(_) -> ["import " <> module_name]
  }
}

fn generate_test_functions(blocks: List(CodeBlock)) -> List(String) {
  blocks
  |> list.index_map(generate_test_function)
}

fn generate_test_function(block: CodeBlock, index: Int) -> String {
  let target_name = option.unwrap(block.source.target, "module")

  let test_func_name =
    sanitize_name(target_name) <> "_" <> int.to_string(index + 1) <> "_test"

  let source_info =
    "// From: "
    <> block.source.file
    <> ":"
    <> int.to_string(block.source.start_line + block.doc_line_offset - 1)

  let code = block.code |> string.trim

  string.join(
    [
      "",
      source_info,
      "pub fn " <> test_func_name <> "() {",
      "  " <> string.replace(code, "\n", "\n  "),
      "}",
    ],
    "\n",
  )
}

/// Replace characters that aren't valid in function names
fn sanitize_name(name: String) -> String {
  name
  |> string.replace(".", "_")
  |> string.replace("-", "_")
}

/// Clean up generated test files.
pub fn clean_generated(output_dir: String) -> Result(Nil, snag.Snag) {
  let dir = output_dir <> "/gleedoc"
  case simplifile.read_directory(dir) {
    Ok(files) -> {
      files
      |> list.filter(fn(f) {
        string.ends_with(f, "_gleedoc_test.gleam")
      })
      |> list.each(fn(f) {
        let path = dir <> "/" <> f
        let _ = simplifile.delete(path)
        Nil
      })
      Ok(Nil)
    }
    Error(err) -> {
      // If the output dir doesn't exist, that's fine
      case string.inspect(err) {
        "Enoent" -> Ok(Nil)
        _ ->
          Error(snag.new(
            "Failed to clean generated files: " <> string.inspect(err),
          ))
      }
    }
  }
}