///|
priv struct CssParser {
  input : String
  mut pos : Int
  license_comments : Array[CssNode]
}

///|
fn CssParser::new(input : String) -> CssParser {
  { input, pos: 0, license_comments: [] }
}

///|
fn CssParser::eof(self : CssParser) -> Bool {
  self.pos >= self.input.length()
}

///|
fn CssParser::current(self : CssParser) -> UInt16? {
  if self.eof() {
    None
  } else {
    Some(self.input[self.pos])
  }
}

///|
fn is_css_whitespace(c : UInt16) -> Bool {
  c == ' ' || c == '\n' || c == '\r' || c == '\t' || c == '\u000c'
}

///|
/// A "plain" value char is one that `read_until_top_level` copies through
/// verbatim: not an escape, quote, comment-introducing `/`, bracket/paren (those
/// need depth tracking), whitespace (collapsed to single spaces), or a terminal
/// (may end the value). Runs of these are copied in one `write_view` instead of
/// one code unit at a time — the parser's hottest inner loop (see the tokenizers
/// in mizchi/css and moonbitlang/parser, which likewise scan a run then copy once).
fn is_plain_value_char(c : UInt16, terminals : ArrayView[UInt16]) -> Bool {
  if c == '\\' ||
    c == '\'' ||
    c == '"' ||
    c == '/' ||
    c == '(' ||
    c == ')' ||
    c == '[' ||
    c == ']' {
    return false
  }
  if is_css_whitespace(c) {
    return false
  }
  for t in terminals {
    if c == t {
      return false
    }
  }
  true
}

///|
fn CssParser::skip_whitespace(self : CssParser) -> Unit {
  while self.current() is Some(c) && is_css_whitespace(c) {
    self.pos += 1
  }
}

///|
fn CssParser::error(self : CssParser, message : String) -> CompileError {
  InvalidCss("\{message} at offset \{self.pos}")
}

///|
fn CssParser::consume_string(
  self : CssParser,
  quote : UInt16,
) -> Unit raise CompileError {
  self.pos += 1
  while self.current() is Some(c) {
    if c == '\\' {
      self.pos += 1
      if !self.eof() {
        self.pos += 1
      }
    } else if c == quote {
      self.pos += 1
      return
    } else if c == '\n' || c == '\r' {
      raise self.error("Unterminated string")
    } else {
      self.pos += 1
    }
  }
  raise self.error("Unterminated string")
}

///|
fn CssParser::consume_comment(self : CssParser) -> CssNode raise CompileError {
  let start = self.pos
  self.pos += 2
  let value_start = self.pos
  while self.pos + 1 < self.input.length() {
    if self.input[self.pos] == '*' && self.input[self.pos + 1] == '/' {
      let value = self.input[value_start:self.pos].to_owned()
      self.pos += 2
      return Comment(value~, license=value.has_prefix("!"), span={
        start,
        end: self.pos,
      })
    }
    self.pos += 1
  }
  raise self.error("Unterminated comment")
}

///|
/// Materialize the token accumulated by `read_until_top_level`.
///
/// In the common case nothing was ever copied: the token is the source range
/// `[content_start, content_end)` and this is its only copy.
fn CssParser::finish_token(
  self : CssParser,
  output : StringBuilder,
  verbatim : Bool,
  content_start : Int,
  content_end : Int,
) -> String {
  if verbatim {
    if content_start < 0 {
      ""
    } else {
      trim(self.input[content_start:content_end].to_owned())
    }
  } else {
    trim(output.to_string())
  }
}

///|
fn CssParser::read_until_top_level(
  self : CssParser,
  terminals : ArrayView[UInt16],
) -> (String, UInt16?) raise CompileError {
  // Almost every token is exactly a slice of the source. Only two things make
  // the result differ from `input[content_start:content_end]`: a comment, which
  // is dropped from the output, and a whitespace run that is not already a
  // single space, which is collapsed to one. Until one of those turns up the
  // token is just a pair of offsets — no builder is filled and nothing is
  // copied, where before every token was copied into the `StringBuilder` and
  // then copied a second time by `to_string()`. On the first such event the
  // offsets are flushed into the builder and the original path takes over.
  let output = StringBuilder()
  let mut verbatim = true
  let mut content_start = -1
  let mut content_end = -1
  let mut has_output = false
  let mut pending_space = false
  // Whether the pending whitespace is a single space already sitting between
  // `content_end` and whatever comes next, so extending the slice reproduces it.
  let mut pending_space_verbatim = false
  let mut parens = 0
  let mut brackets = 0
  let len = self.input.length()
  while self.pos < len {
    let c = self.input[self.pos]
    let run_start = self.pos
    if is_plain_value_char(c, terminals) {
      // Fast path: take a maximal run of plain content chars at once rather
      // than one code unit per iteration.
      self.pos += 1
      while self.pos < len &&
            is_plain_value_char(self.input[self.pos], terminals) {
        self.pos += 1
      }
    } else if c == '\\' {
      self.pos += 1
      if !self.eof() {
        self.pos += 1
      }
    } else if c == '\'' || c == '"' {
      self.consume_string(c)
    } else if c == '/' && self.pos + 1 < len && self.input[self.pos + 1] == '*' {
      // A comment is dropped, so the source slice can no longer stand in for
      // the token.
      if verbatim {
        if content_start >= 0 {
          output.write_view(self.input[content_start:content_end])
        }
        verbatim = false
      }
      let comment = self.consume_comment()
      if comment is Comment(license=true, ..) {
        self.license_comments.push(comment)
      }
      continue
    } else if is_css_whitespace(c) {
      self.skip_whitespace()
      pending_space = has_output
      pending_space_verbatim = self.pos - run_start == 1 &&
        self.input[run_start] == ' '
      continue
    } else {
      match c {
        '(' => parens += 1
        ')' => {
          if parens == 0 {
            raise self.error("Unexpected closing parenthesis")
          }
          parens -= 1
        }
        '[' => brackets += 1
        ']' => {
          if brackets == 0 {
            raise self.error("Unexpected closing bracket")
          }
          brackets -= 1
        }
        _ => ()
      }
      if parens == 0 && brackets == 0 && terminals.contains(c) {
        return (
          self.finish_token(output, verbatim, content_start, content_end),
          Some(c),
        )
      }
      self.pos += 1
    }
    // Emit `input[run_start:self.pos]`, either by extending the slice or, once
    // the slice has been given up, by writing it to the builder.
    if verbatim && pending_space && !pending_space_verbatim {
      if content_start >= 0 {
        output.write_view(self.input[content_start:content_end])
      }
      verbatim = false
    }
    if verbatim {
      if content_start < 0 {
        content_start = run_start
      }
      content_end = self.pos
      pending_space = false
    } else {
      if pending_space {
        output.write_char(' ')
        pending_space = false
      }
      output.write_view(self.input[run_start:self.pos])
    }
    has_output = true
  }
  if parens != 0 || brackets != 0 {
    raise self.error("Unbalanced value")
  }
  (self.finish_token(output, verbatim, content_start, content_end), None)
}

///|
fn split_important(value : String) -> (String, Bool) {
  let trimmed = trim(value)
  if trimmed.has_suffix("!important") {
    (trim(trimmed[:trimmed.length() - 10].to_owned()), true)
  } else {
    (trimmed, false)
  }
}

///|
fn CssParser::parse_custom_property(
  self : CssParser,
) -> CssNode raise CompileError {
  let start = self.pos
  let mut colon = -1
  let mut parens = 0
  let mut brackets = 0
  let mut braces = 0
  while self.current() is Some(current) {
    if current == '\\' {
      self.pos += 1
      if !self.eof() {
        self.pos += 1
      }
      continue
    }
    if current == '\'' || current == '"' {
      self.consume_string(current)
      continue
    }
    if current == '/' &&
      self.pos + 1 < self.input.length() &&
      self.input[self.pos + 1] == '*' {
      ignore(self.consume_comment())
      continue
    }
    if colon < 0 && current == ':' {
      colon = self.pos
    } else if colon >= 0 {
      match current {
        '(' => parens += 1
        ')' => if parens > 0 { parens -= 1 }
        '[' => brackets += 1
        ']' => if brackets > 0 { brackets -= 1 }
        '{' => braces += 1
        '}' =>
          if braces > 0 {
            braces -= 1
          } else if parens == 0 && brackets == 0 {
            break
          }
        ';' => if parens == 0 && brackets == 0 && braces == 0 { break }
        _ => ()
      }
    }
    self.pos += 1
  }
  if colon < 0 {
    raise self.error("Invalid custom property, expected a value")
  }
  let name = trim(self.input[start:colon].to_owned())
  let (value, important) = split_important(
    self.input[colon + 1:self.pos].to_owned(),
  )
  if self.current() is Some(';') {
    self.pos += 1
  }
  Declaration(name~, value~, important~, span={ start, end: self.pos })
}

///|
fn CssParser::parse_statement(self : CssParser) -> CssNode raise CompileError {
  let start = self.pos
  if self.pos + 1 < self.input.length() &&
    self.input[self.pos] == '-' &&
    self.input[self.pos + 1] == '-' {
    self.parse_custom_property()
  } else if self.current() is Some('@') {
    let (header, terminal) = self.read_until_top_level([';', '{', '}'])
    let parts = header.split(" ")
    let name = parts.next().unwrap_or("").to_owned()
    let params = trim(header[name.length():].to_owned())
    match terminal {
      Some(';') => {
        self.pos += 1
        AtRule(name~, params~, nodes=None, span={ start, end: self.pos })
      }
      Some('{') => {
        self.pos += 1
        let children = self.parse_nodes(stop_at_closing_brace=true)
        AtRule(name~, params~, nodes=Some(children), span={
          start,
          end: self.pos,
        })
      }
      Some('}') =>
        AtRule(name~, params~, nodes=None, span={ start, end: self.pos })
      _ => AtRule(name~, params~, nodes=None, span={ start, end: self.pos })
    }
  } else {
    let (statement, terminal) = self.read_until_top_level([';', '{', '}'])
    match terminal {
      Some('{') => {
        if statement == "" {
          raise self.error("Expected selector")
        }
        self.pos += 1
        let children = self.parse_nodes(stop_at_closing_brace=true)
        Rule(selector=statement, nodes=children, span={ start, end: self.pos })
      }
      Some(';') => {
        guard statement.split_once(":") is Some((name, raw_value)) else {
          raise self.error("Expected declaration")
        }
        self.pos += 1
        let (value, important) = split_important(raw_value.to_owned())
        Declaration(name=trim(name.to_owned()), value~, important~, span={
          start,
          end: self.pos,
        })
      }
      Some('}') | None =>
        match statement.split_once(":") {
          Some((name, raw_value)) => {
            let (value, important) = split_important(raw_value.to_owned())
            Declaration(name=trim(name.to_owned()), value~, important~, span={
              start,
              end: self.pos,
            })
          }
          None => raise self.error("Expected declaration")
        }
      _ => raise self.error("Unexpected statement")
    }
  }
}

///|
fn CssParser::parse_nodes(
  self : CssParser,
  stop_at_closing_brace~ : Bool,
) -> Array[CssNode] raise CompileError {
  let nodes : Array[CssNode] = []
  while !self.eof() {
    self.skip_whitespace()
    if self.eof() {
      break
    }
    if self.current() is Some('}') {
      if !stop_at_closing_brace {
        raise self.error("Unexpected closing brace")
      }
      self.pos += 1
      return nodes
    }
    if self.current() is Some(';') {
      self.pos += 1
      continue
    }
    if self.current() is Some('/') &&
      self.pos + 1 < self.input.length() &&
      self.input[self.pos + 1] == '*' {
      let comment = self.consume_comment()
      if comment is Comment(license=true, ..) {
        self.license_comments.push(comment)
      }
    } else {
      nodes.push(self.parse_statement())
    }
  }
  if stop_at_closing_brace {
    raise self.error("Unclosed block")
  }
  nodes
}

///|
fn parse_css(input : String) -> Array[CssNode] raise CompileError {
  let normalized = if input.has_prefix("\uFEFF") {
    " \{input[1:]}"
  } else {
    input
  }
  let parser = CssParser::new(normalized)
  let nodes = parser.parse_nodes(stop_at_closing_brace=false)
  parser.license_comments + nodes
}