///|
/// Mutable counters and provenance collected within one `load` operation.
priv struct LoadState {
  mut file_count : Int
  mut total_bytes : Int
  sources : Array[SourceFile]
  diagnostics : Array[LoadDiagnostic]
  active_identities : Array[String]
  active_paths : Array[String]
}

///|
priv enum ItemTemplate {
  Global
  Host(@syntax.HostBlock)
  Match(@syntax.MatchBlock)
}

///|
fn failure_chain(
  state : LoadState,
  attempted_path : String,
  append_attempted : Bool,
) -> Array[String] {
  let chain = state.active_paths.copy()
  if append_attempted {
    chain.push(attempted_path)
  }
  chain
}

///|
fn detailed_error(
  kind : LoadFailureKind,
  attempted_path : String,
  include_location : @syntax.SourceLocation?,
  state : LoadState,
  message : String,
  append_attempted? : Bool = false,
  failure_location? : @syntax.SourceLocation,
) -> LoadError {
  Detailed(failure={
    kind,
    attempted_path,
    location: failure_location,
    include_location,
    chain: failure_chain(state, attempted_path, append_attempted),
    message,
  })
}

///|
fn validate_options(
  options : LoadOptions,
  path : String,
  state : LoadState,
) -> Unit raise LoadError {
  let valid = options.max_depth >= 0 &&
    options.max_files > 0 &&
    options.max_file_bytes > 0 &&
    options.max_total_bytes > 0 &&
    options.max_glob_matches > 0 &&
    options.max_include_arguments > 0
  if !valid {
    raise detailed_error(
      InvalidOptions,
      path,
      None,
      state,
      "all resource limits must be positive (max_depth may be zero)",
      append_attempted=true,
    )
  }
}

///|
fn filesystem_failure(
  kind : LoadFailureKind,
  path : String,
  included_from : @syntax.SourceLocation?,
  state : LoadState,
  error : FileSystemError,
  append_attempted? : Bool = false,
) -> LoadError {
  detailed_error(
    kind,
    path,
    included_from,
    state,
    filesystem_error_message(error),
    append_attempted~,
  )
}

///|
fn canonical_identity(
  fs : &FileSystem,
  path : String,
  included_from : @syntax.SourceLocation?,
  state : LoadState,
) -> String raise LoadError {
  match fs.canonical_identity(path) {
    Ok(identity) => identity
    Err(error) =>
      raise filesystem_failure(
        ReadFailed,
        path,
        included_from,
        state,
        error,
        append_attempted=true,
      )
  }
}

///|
fn read_text(
  fs : &FileSystem,
  path : String,
  included_from : @syntax.SourceLocation?,
  state : LoadState,
  limit : Int,
) -> String raise LoadError {
  match fs.read_text(path, max_bytes=limit) {
    Ok(text) => text
    Err(ReadByteLimitExceeded(..)) =>
      raise detailed_error(
        FileTooLarge(limit~),
        path,
        included_from,
        state,
        "file exceeds byte limit \{limit}",
        append_attempted=true,
      )
    Err(error) =>
      raise filesystem_failure(
        ReadFailed,
        path,
        included_from,
        state,
        error,
        append_attempted=true,
      )
  }
}

///|
fn glob(
  fs : &FileSystem,
  pattern : String,
  location : @syntax.SourceLocation,
  state : LoadState,
  limit : Int,
) -> Array[String] raise LoadError {
  match fs.glob(pattern, max_matches=limit) {
    Ok(paths) => paths
    Err(GlobMatchLimitExceeded(..)) =>
      raise detailed_error(
        GlobLimitExceeded(limit~),
        pattern,
        Some(location),
        state,
        "glob exceeds match limit \{limit}",
      )
    Err(error) =>
      raise filesystem_failure(
        GlobFailed,
        pattern,
        Some(location),
        state,
        error,
      )
  }
}

///|
fn is_active(state : LoadState, identity : String) -> Bool {
  for active in state.active_identities {
    if active == identity {
      return true
    }
  }
  false
}

///|
fn syntax_error_location(error : @syntax.ParseError) -> @syntax.SourceLocation {
  match error {
    MissingArgument(location~, ..) => location
    UnterminatedQuote(location~) => location
    DanglingEscape(location~) => location
    UnexpectedNul(location~) => location
    LineTooLong(location~, ..) => location
    SourceTooLarge(location~, ..) => location
    InvalidBlockHeader(location~, ..) => location
    InvalidDirective(location~, ..) => location
  }
}

///|
fn syntax_error_message(error : @syntax.ParseError) -> String {
  match error {
    MissingArgument(..) => "directive requires at least one argument"
    UnterminatedQuote(..) => "unterminated quoted argument"
    DanglingEscape(..) => "escape has no following character"
    UnexpectedNul(..) => "NUL is not valid in SSH configuration"
    LineTooLong(..) => "configuration line exceeds parser limit"
    SourceTooLarge(..) => "configuration source exceeds parser limit"
    InvalidBlockHeader(..) => "invalid section header"
    InvalidDirective(message~, ..) => message
  }
}

///|
fn parse_config(
  text : String,
  display_path : String,
  included_from : @syntax.SourceLocation?,
  state : LoadState,
) -> @syntax.Config raise LoadError {
  @syntax.parse(text, path=display_path) catch {
    error => {
      let location = syntax_error_location(error)
      raise detailed_error(
        ParseFailed,
        display_path,
        included_from,
        state,
        syntax_error_message(error),
        failure_location=location,
      )
    }
  }
}

///|
fn item_from(
  template : ItemTemplate,
  directives : Array[@syntax.Directive],
) -> @syntax.ConfigItem {
  match template {
    Global => Global(directives)
    Host(block) =>
      Host({
        patterns: block.patterns,
        directives,
        location: block.location,
        span: block.span,
      })
    Match(block) =>
      Match({
        conditions: block.conditions,
        directives,
        location: block.location,
        span: block.span,
      })
  }
}

///|
/// Convert a Host pattern-list to one Match token. The resolver's Match parser
/// accepts a comma-separated pattern-list and ANDs repeated predicates.
fn host_guard(patterns : Array[String]) -> Array[String] {
  ["originalhost", patterns.join(",")]
}

///|
fn conditions_are_all(conditions : Array[String]) -> Bool {
  if conditions.length() != 1 {
    return false
  }
  let normalized = StringBuilder()
  for char in conditions[0] {
    normalized.write_char(char.to_ascii_lowercase())
  }
  normalized.to_string() == "all"
}

///|
fn template_guard(template : ItemTemplate) -> Array[String] {
  match template {
    Global => []
    Host(block) => host_guard(block.patterns)
    Match(block) =>
      if conditions_are_all(block.conditions) {
        []
      } else {
        block.conditions.copy()
      }
  }
}

///|
/// Preserve the condition that made an Include directive active when an
/// included file introduces its own Host or Match block.
///
/// OpenSSH skips an Include in an inactive block. A flat syntax tree therefore
/// must retain both predicates. Repeated Match predicates are logical AND, so a
/// synthetic Match block represents the outer guard without changing the
/// included directives or their source location.
fn guard_included_item(
  template : ItemTemplate,
  item : @syntax.ConfigItem,
) -> @syntax.ConfigItem {
  match template {
    Global => item
    Host(_) | Match(_) => {
      let outer = template_guard(template)
      match item {
        Global(_) => item
        Host(block) => {
          let conditions = outer
          for token in host_guard(block.patterns) {
            conditions.push(token)
          }
          Match({
            conditions,
            directives: block.directives,
            location: block.location,
            span: block.span,
          })
        }
        Match(block) => {
          let conditions = outer
          if !conditions_are_all(block.conditions) {
            for token in block.conditions {
              conditions.push(token)
            }
          }
          if conditions.is_empty() {
            Global(block.directives)
          } else {
            Match({
              conditions,
              directives: block.directives,
              location: block.location,
              span: block.span,
            })
          }
        }
      }
    }
  }
}

///|
fn blocks_from(items : Array[@syntax.ConfigItem]) -> Array[@syntax.HostBlock] {
  let global_directives : Array[@syntax.Directive] = []
  let global_location : @syntax.SourceLocation = {
    path: "",
    line: 1,
    column: 1,
  }
  let global_span : @syntax.SourceSpan = {
    start: global_location,
    end_: global_location,
  }
  let blocks : Array[@syntax.HostBlock] = [
    {
      patterns: ["*"],
      directives: global_directives,
      location: global_location,
      span: global_span,
    },
  ]
  for item in items {
    match item {
      Global(directives) =>
        for directive in directives {
          global_directives.push(directive)
        }
      Host(block) => blocks.push(block)
      Match(_) => ()
    }
  }
  blocks
}

///|
fn config_from_items(items : Array[@syntax.ConfigItem]) -> @syntax.Config {
  { items, blocks: blocks_from(items) }
}

///|
fn no_matches(
  resolved_path : String,
  directive : @syntax.Directive,
  options : LoadOptions,
  state : LoadState,
) -> Unit raise LoadError {
  match options.missing_include {
    Ignore => ()
    Warn =>
      state.diagnostics.push(
        MissingInclude(
          path=resolved_path,
          included_from=Some(directive.location),
        ),
      )
    Error =>
      raise detailed_error(
        InvalidInclude,
        resolved_path,
        Some(directive.location),
        state,
        "Include matched no files: \{resolved_path}",
      )
  }
}

///|
fn resolve_config_include_path(
  parent_path : String,
  include_path : String,
  home : String?,
  relative_base : String?,
) -> Result[String, String] {
  if path_has_home_prefix(include_path) || path_is_absolute(include_path) {
    resolve_include_path(parent_path, include_path, home)
  } else {
    match relative_base {
      Some(base) => Ok(join_path(base, include_path))
      None => resolve_include_path(parent_path, include_path, home)
    }
  }
}

///|
fn expand_include(
  fs : &FileSystem,
  parent_path : String,
  directive : @syntax.Directive,
  depth : Int,
  options : LoadOptions,
  state : LoadState,
) -> Array[@syntax.ConfigItem] raise LoadError {
  if directive.arguments.length() > options.max_include_arguments {
    raise detailed_error(
      IncludeArgumentLimitExceeded(limit=options.max_include_arguments),
      parent_path,
      Some(directive.location),
      state,
      "Include argument count exceeds limit \{options.max_include_arguments}",
    )
  }
  let expanded : Array[@syntax.ConfigItem] = []
  for include_argument in directive.arguments {
    let resolved_path = match
      resolve_config_include_path(
        parent_path,
        include_argument,
        fs.home_dir(),
        options.relative_include_base,
      ) {
      Ok(path) => path
      Err(message) =>
        raise detailed_error(
          InvalidInclude,
          include_argument,
          Some(directive.location),
          state,
          message,
        )
    }
    let matches = glob(
      fs,
      resolved_path,
      directive.location,
      state,
      options.max_glob_matches,
    )
    matches.sort()
    if matches.length() > options.max_glob_matches {
      raise detailed_error(
        GlobLimitExceeded(limit=options.max_glob_matches),
        resolved_path,
        Some(directive.location),
        state,
        "glob exceeds match limit \{options.max_glob_matches}",
      )
    }
    if matches.is_empty() {
      no_matches(resolved_path, directive, options, state)
      continue
    }
    for included_path in matches {
      let included = load_file(
        fs,
        included_path,
        Some(directive.location),
        depth + 1,
        options,
        state,
      )
      for item in included.items {
        expanded.push(item)
      }
    }
  }
  expanded
}

///|
fn expand_directives(
  fs : &FileSystem,
  parent_path : String,
  template : ItemTemplate,
  directives : Array[@syntax.Directive],
  depth : Int,
  options : LoadOptions,
  state : LoadState,
) -> Array[@syntax.ConfigItem] raise LoadError {
  let result : Array[@syntax.ConfigItem] = []
  let current : Array[@syntax.Directive] = []
  for directive in directives {
    if directive.keyword == "include" {
      let included = expand_include(
        fs, parent_path, directive, depth, options, state,
      )
      for item in included {
        match item {
          Global(included_directives) =>
            for included_directive in included_directives {
              current.push(included_directive)
            }
          Host(_) | Match(_) => {
            if !current.is_empty() {
              result.push(item_from(template, current.copy()))
              current.clear()
            }
            result.push(guard_included_item(template, item))
          }
        }
      }
    } else {
      current.push(directive)
    }
  }
  if !current.is_empty() || result.is_empty() {
    result.push(item_from(template, current))
  }
  result
}

///|
fn expand_config(
  fs : &FileSystem,
  config : @syntax.Config,
  display_path : String,
  depth : Int,
  options : LoadOptions,
  state : LoadState,
) -> @syntax.Config raise LoadError {
  let items : Array[@syntax.ConfigItem] = []
  for item in config.items {
    let (template, directives) = match item {
      Global(directives) => (Global, directives)
      Host(block) => (Host(block), block.directives)
      Match(block) => (Match(block), block.directives)
    }
    let expanded = expand_directives(
      fs, display_path, template, directives, depth, options, state,
    )
    for expanded_item in expanded {
      items.push(expanded_item)
    }
  }
  let normalized : Array[@syntax.ConfigItem] = []
  if !config.items.is_empty() {
    match config.items[0] {
      Global(_) =>
        if items.is_empty() {
          normalized.push(Global([]))
        } else {
          match items[0] {
            Global(_) => ()
            Host(_) | Match(_) => normalized.push(Global([]))
          }
        }
      Host(_) | Match(_) => ()
    }
  }
  for item in items {
    normalized.push(item)
  }
  config_from_items(normalized)
}

///|
fn load_file(
  fs : &FileSystem,
  display_path : String,
  included_from : @syntax.SourceLocation?,
  depth : Int,
  options : LoadOptions,
  state : LoadState,
) -> @syntax.Config raise LoadError {
  if depth > options.max_depth {
    raise detailed_error(
      IncludeDepthExceeded(limit=options.max_depth),
      display_path,
      included_from,
      state,
      "Include depth exceeds limit \{options.max_depth}",
      append_attempted=true,
    )
  }
  if state.file_count >= options.max_files {
    raise detailed_error(
      FileLimitExceeded(limit=options.max_files),
      display_path,
      included_from,
      state,
      "loaded file count exceeds limit \{options.max_files}",
      append_attempted=true,
    )
  }
  let identity = canonical_identity(fs, display_path, included_from, state)
  if is_active(state, identity) {
    raise detailed_error(
      IncludeCycle,
      display_path,
      included_from,
      state,
      "Include cycle detected",
      append_attempted=true,
    )
  }
  let text = read_text(
    fs,
    display_path,
    included_from,
    state,
    options.max_file_bytes,
  )
  let byte_count = @utf8.encode(text).length()
  if byte_count > options.max_file_bytes {
    raise detailed_error(
      FileTooLarge(limit=options.max_file_bytes),
      display_path,
      included_from,
      state,
      "file exceeds byte limit \{options.max_file_bytes}",
      append_attempted=true,
    )
  }
  if state.total_bytes > options.max_total_bytes - byte_count {
    raise detailed_error(
      TotalBytesExceeded(limit=options.max_total_bytes),
      display_path,
      included_from,
      state,
      "total loaded bytes exceed limit \{options.max_total_bytes}",
      append_attempted=true,
    )
  }
  state.file_count += 1
  state.total_bytes += byte_count
  state.active_identities.push(identity)
  state.active_paths.push(display_path)
  state.sources.push({ identity, display_path, included_from })
  let config = parse_config(text, display_path, included_from, state)
  let expanded = expand_config(fs, config, display_path, depth, options, state)
  ignore(state.active_identities.pop())
  ignore(state.active_paths.pop())
  expanded
}

///|
/// Read, parse, and recursively expand an OpenSSH configuration root.
///
/// By default Include patterns are resolved relative to their including file.
/// Set `LoadOptions::relative_include_base` for a configuration domain such as
/// OpenSSH's user `~/.ssh` base. `~/` is expanded only through
/// `FileSystem::home_dir`, and every glob result is sorted before recursive
/// loading. The same file may be included twice from separate branches; only
/// the active recursion chain is considered a cycle.
pub fn load(
  fs : &FileSystem,
  path : String,
  options? : LoadOptions = LoadOptions::default(),
) -> LoadedConfig raise LoadError {
  let state : LoadState = {
    file_count: 0,
    total_bytes: 0,
    sources: [],
    diagnostics: [],
    active_identities: [],
    active_paths: [],
  }
  validate_options(options, path, state)
  let config = load_file(fs, path, None, 0, options, state)
  { config, sources: state.sources, diagnostics: state.diagnostics }
}