///|
pub struct Expansion {
  value : String
  unresolved : Array[String]
} derive(Debug, Eq, ToJson)

///|
fn variable_char(c : Char, first : Bool) -> Bool {
  (c >= 'a' && c <= 'z') ||
  (c >= 'A' && c <= 'Z') ||
  c == '_' ||
  (!first && c >= '0' && c <= '9')
}

///|
/// Docker-style parameter expansion without evaluating shell commands. Unknown
/// variables are preserved and listed; base-image environments are not fetched.
pub fn expand_variables(
  source : String,
  values : Map[String, String],
) -> Expansion raise ParseError {
  expand_tracked(source, values, Map([]), false, Map([]))
}

///|
// Keep an approximate value for display, but never lose uncertainty when it
// travels through an assignment. External image ENV may shadow an ARG or alter
// a fallback until this Dockerfile explicitly sets that ENV key.
fn expand_tracked(
  source : String,
  values : Map[String, String],
  uncertain : Map[String, Bool],
  unknown_base : Bool,
  environment : Map[String, String],
) -> Expansion raise ParseError {
  if !valid_unicode(source) ||
    values.iter().any(pair => !valid_unicode(pair.0) || !valid_unicode(pair.1)) {
    raise Invalid("invalid variable Unicode")
  }
  fn expand(source : String, depth : Int) -> Expansion raise ParseError {
    if depth > 32 || source.length() > 1000000 {
      raise Invalid("variable expansion limit")
    }
    let chars = source.to_array()
    let out = StringBuilder()
    let mut units = 0
    fn put_string(value : String) -> Unit raise ParseError {
      units += value.length()
      if units > 1000000 {
        raise Invalid("expanded value limit")
      }
      out.write_string(value)
    }
    fn put_char(value : Char) -> Unit raise ParseError {
      units += if value.to_int() > 65535 { 2 } else { 1 }
      if units > 1000000 {
        raise Invalid("expanded value limit")
      }
      out.write_char(value)
    }
    let unresolved : Array[String] = []
    let mut quote : Char? = None
    let mut i = 0
    while i < chars.length() {
      let c = chars[i]
      if c == '\\' && quote != Some('\'') {
        i += 1
        if i == chars.length() {
          raise Invalid("trailing variable escape")
        }
        if quote == Some('"') && !"$`\"\\".contains(chars[i].to_string()) {
          put_char('\\')
        }
        if chars[i] != '\n' {
          put_char(chars[i])
        }
        i += 1
        continue
      }
      if (c == '\'' || c == '"') && (quote == None || quote == Some(c)) {
        quote = if quote == None { Some(c) } else { None }
        i += 1
        continue
      }
      if c != '$' || quote == Some('\'') {
        put_char(c)
        i += 1
        continue
      }
      let start = i
      i += 1
      let braced = i < chars.length() && chars[i] == '{'
      if braced {
        i += 1
      }
      let name_start = i
      while i < chars.length() && variable_char(chars[i], i == name_start) {
        i += 1
      }
      if name_start == i {
        if braced {
          raise Invalid("invalid variable name")
        }
        put_char('$')
        continue
      }
      let name = String::from_array(chars[name_start:i])
      let mut operator = ""
      let mut fallback = ""
      if braced {
        let op_start = i
        if i < chars.length() && chars[i] == ':' {
          i += 1
        }
        if i < chars.length() && (chars[i] == '-' || chars[i] == '+') {
          i += 1
          operator = String::from_array(chars[op_start:i])
          let fallback_start = i
          let mut nesting = 0
          while i < chars.length() {
            if chars[i] == '\\' {
              i += 2
              continue
            }
            if chars[i] == '{' {
              nesting += 1
            }
            if chars[i] == '}' {
              if nesting == 0 {
                break
              }
              nesting -= 1
            }
            i += 1
          }
          if i > chars.length() {
            raise Invalid("invalid variable fallback")
          }
          fallback = String::from_array(chars[fallback_start:i])
        }
        if i >= chars.length() || chars[i] != '}' {
          raise Invalid("unsupported or unclosed parameter expansion")
        }
        i += 1
      }
      let value = values.get(name)
      if (
          uncertain.contains(name) ||
          (unknown_base && !environment.contains(name))
        ) &&
        !unresolved.contains(name) {
        unresolved.push(name)
      }
      let set = value != None &&
        (!operator.has_prefix(":") || value != Some(""))
      let use_fallback = (operator.has_suffix("-") && !set) ||
        (operator.has_suffix("+") && set)
      if use_fallback {
        let expanded = expand(fallback, depth + 1)
        put_string(expanded.value)
        for name in expanded.unresolved {
          if !unresolved.contains(name) {
            unresolved.push(name)
          }
        }
      } else if operator.has_suffix("+") {
        ()
      } else if value is Some(v) {
        put_string(v)
      } else {
        put_string(String::from_array(chars[start:i]))
        if !unresolved.contains(name) {
          unresolved.push(name)
        }
      }
    }
    if quote != None {
      raise Invalid("unclosed variable quote")
    }
    { value: out.to_string(), unresolved, }
  }
  expand(source, 0)
}

///|
pub struct VariableScope {
  line : Int
  stage : Int
  instruction : String
  expanded : String
  arguments : Map[String, String]
  environment : Map[String, String]
  unresolved : Array[String]
  uncertain_arguments : Array[String]
  uncertain_environment : Array[String]
  unknown_base_environment : Bool
} derive(Debug, Eq, ToJson)

///|
/// Snapshot after every instruction. Global ARG is only visible in FROM unless
/// redeclared; child stages inherit declared ARG and ENV from their named parent.
pub fn analyze_variables(
  source : String,
  build_args? : Map[String, String] = Map([]),
) -> Array[VariableScope] raise ParseError {
  for key, value in build_args {
    if !valid_unicode(key) || !valid_unicode(value) || value.length() > 1000000 {
      raise Invalid("invalid build argument value")
    }
  }
  let global : Map[String, String] = Map([])
  let parents : Map[
    String,
    (
      Map[String, String],
      Map[String, String],
      Map[String, Bool],
      Map[String, Bool],
      Bool,
    ),
  ] = Map([])
  let global_uncertain : Map[String, Bool] = Map([])
  let mut args_uncertain : Map[String, Bool] = Map([])
  let mut env_uncertain : Map[String, Bool] = Map([])
  let mut unknown_base = false
  let mut args : Map[String, String] = Map([])
  let mut env : Map[String, String] = Map([])
  let mut stage_name : String? = None
  let mut stage = -1
  let result : Array[VariableScope] = []
  let mut snapshot_units = 0
  for ins in parse(source) {
    if result.length() >= 10000 {
      raise Invalid("variable snapshot count limit")
    }
    for key, value in args {
      snapshot_units += key.length() + value.length()
    }
    for key, value in env {
      snapshot_units += key.length() + value.length()
    }
    for key, value in global {
      snapshot_units += key.length() + value.length()
    }
    if snapshot_units > 8000000 {
      raise Invalid("variable snapshot size limit")
    }
    if ins.name == "FROM" {
      if stage_name is Some(name) {
        parents[name] = (
          args.copy(),
          env.copy(),
          args_uncertain.copy(),
          env_uncertain.copy(),
          unknown_base,
        )
      }
      let expanded = expand_tracked(
        ins.arguments,
        global,
        global_uncertain,
        false,
        Map([]),
      )
      let words = split_quoted(expanded.value)
      if words.is_empty() {
        raise Invalid("empty expanded FROM")
      }
      let offset = if words[0].has_prefix("--platform=") { 1 } else { 0 }
      if words.length() <= offset {
        raise Invalid("malformed FROM")
      }
      let inherited = parents
        .get(words[offset].to_lower())
        .unwrap_or(
          (
            Map([]),
            Map([]),
            Map([]),
            Map([]),
            words[offset].to_lower() != "scratch" ||
            !expanded.unresolved.is_empty(),
          ),
        )
      args = inherited.0.copy()
      env = inherited.1.copy()
      args_uncertain = inherited.2.copy()
      env_uncertain = inherited.3.copy()
      unknown_base = inherited.4 || !expanded.unresolved.is_empty()
      stage += 1
      stage_name = if words.length() == offset + 3 &&
        words[offset + 1].to_upper() == "AS" {
        Some(words[offset + 2].to_lower())
      } else {
        None
      }
      result.push({
        line: ins.line,
        stage,
        instruction: ins.name,
        expanded: expanded.value,
        arguments: args.copy(),
        environment: env.copy(),
        unresolved: expanded.unresolved,
        uncertain_arguments: args_uncertain.keys().collect(),
        uncertain_environment: env_uncertain.keys().collect(),
        unknown_base_environment: unknown_base,
      })
      continue
    }
    let values = if stage < 0 { global.copy() } else { args.copy() }
    let before_environment = env.copy()
    for key, value in env {
      values[key] = value
    }
    let uncertain = if stage < 0 {
      global_uncertain.copy()
    } else {
      args_uncertain.copy()
    }
    for key, _ in env {
      ignore(uncertain.remove(key))
    }
    for key, _ in env_uncertain {
      uncertain[key] = true
    }
    let unresolved : Array[String] = []
    let expanded = if ins.name == "ARG" {
      let parts = ins.arguments.split("=").collect()
      let name = parts[0].trim().to_owned()
      let name_chars = name.to_array()
      if name.is_empty() ||
        !variable_char(name_chars[0], true) ||
        !name.iter().all(c => variable_char(c, false)) {
        raise Invalid("invalid ARG name")
      }
      let value = if build_args.get(name) is Some(v) {
        Some(v)
      } else if parts.length() > 1 {
        let value = expand_tracked(
          parts[1:].map(s => s.to_owned()).join("="),
          values,
          uncertain,
          unknown_base,
          env,
        )
        for name in value.unresolved {
          unresolved.push(name)
        }
        Some(value.value)
      } else if stage < 0 {
        if global_uncertain.contains(name) {
          unresolved.push(name)
        }
        global.get(name)
      } else {
        match args.get(name) {
          Some(v) => {
            if args_uncertain.contains(name) {
              unresolved.push(name)
            }
            Some(v)
          }
          None => {
            if global_uncertain.contains(name) {
              unresolved.push(name)
            }
            global.get(name)
          }
        }
      }
      if value is Some(v) {
        if stage < 0 {
          global[name] = v
          if unresolved.is_empty() {
            ignore(global_uncertain.remove(name))
          } else {
            global_uncertain[name] = true
          }
        } else {
          args[name] = v
          if unresolved.is_empty() {
            ignore(args_uncertain.remove(name))
          } else {
            args_uncertain[name] = true
          }
        }
      }
      if value is Some(v) {
        name + "=" + v
      } else {
        name
      }
    } else if ins.name == "ENV" {
      let tokens = raw_arguments(ins.arguments)
      if tokens.is_empty() {
        raise Invalid("empty ENV")
      }
      let pairs = if tokens[0].contains("=") {
        tokens
      } else {
        let name = tokens[0]
        let rest = ins.arguments[tokens[0].length():].trim_start().to_owned()
        [name + "=" + rest]
      }
      let outputs = []
      for pair in pairs {
        let parts = pair.split("=").collect()
        if parts.length() < 2 || parts[0].is_empty() {
          raise Invalid("invalid ENV assignment")
        }
        let key = parts[0].to_owned()
        let value = expand_tracked(
          parts[1:].map(s => s.to_owned()).join("="),
          values,
          uncertain,
          unknown_base,
          // All assignments in one ENV use the environment before that ENV.
          before_environment,
        )
        env[key] = value.value
        if value.unresolved.is_empty() {
          ignore(env_uncertain.remove(key))
        } else {
          env_uncertain[key] = true
        }
        outputs.push(key + "=" + value.value)
        for name in value.unresolved {
          if !unresolved.contains(name) {
            unresolved.push(name)
          }
        }
      }
      outputs.join(" ")
    } else if [
        "ADD", "COPY", "WORKDIR", "USER", "EXPOSE", "LABEL", "STOPSIGNAL", "VOLUME",
      ].contains(ins.name) {
      let value = expand_tracked(
        ins.arguments,
        values,
        uncertain,
        unknown_base,
        env,
      )
      for name in value.unresolved {
        unresolved.push(name)
      }
      value.value
    } else {
      ins.arguments
    }
    result.push({
      line: ins.line,
      stage,
      instruction: ins.name,
      expanded,
      arguments: if stage < 0 {
        global.copy()
      } else {
        args.copy()
      },
      environment: env.copy(),
      unresolved,
      uncertain_arguments: if stage < 0 {
        global_uncertain.keys().collect()
      } else {
        args_uncertain.keys().collect()
      },
      uncertain_environment: env_uncertain.keys().collect(),
      unknown_base_environment: unknown_base,
    })
  }
  result
}