///|
priv struct TextResourceLoadState {
  options : RenderOptions
  diagnostics : Array[RenderDiagnostic]
  media_environment : @css_media.MediaEnvironment
  cache : Map[String, String]
  documents : Map[String, SVGDocument]
  active : Map[String, Bool]
  mut depth : Int
  mut resource_count : Int
  mut byte_count : Int
}

///|
fn TextResourceLoadState::new(
  options : RenderOptions,
  diagnostics : Array[RenderDiagnostic],
  viewport_width : Double,
  viewport_height : Double,
) -> TextResourceLoadState {
  let color_scheme = match options.environment.color_scheme {
    Light => @css_media.Light
    Dark => Dark
  }
  {
    options,
    diagnostics,
    media_environment: {
      viewport_width,
      viewport_height,
      device_pixel_ratio: options.environment.device_pixel_ratio,
      color_scheme,
    },
    cache: Map([]),
    documents: Map([]),
    active: Map([]),
    depth: 0,
    resource_count: 0,
    byte_count: 0,
  }
}

///|
fn resource_last_slash(value : String) -> Int {
  let mut found = -1
  for i = 0; i < value.length(); i = i + 1 {
    if value[i] == '/' {
      found = i
    }
  }
  found
}

///|
fn resource_strip_fragment(value : String) -> String {
  match value.find("#") {
    Some(index) => value[:index].to_owned()
    None => value
  }
}

///|
fn resource_has_scheme(value : String) -> Bool {
  for i = 0; i < value.length(); i = i + 1 {
    match value[i] {
      ':' => return i > 0
      '/' | '?' | '#' => return false
      _ => ()
    }
  }
  false
}

///|
fn resource_origin(base : String) -> String {
  match base.find("://") {
    None => ""
    Some(scheme_end) => {
      let authority_start = scheme_end + 3
      let suffix = base[authority_start:]
      match suffix.find("/") {
        Some(index) => base[:authority_start + index].to_owned()
        None => base
      }
    }
  }
}

///|
fn normalize_resource_path(value : String) -> String {
  let prefix_end = match value.find("://") {
    Some(index) => {
      let authority_start = index + 3
      match value[authority_start:].find("/") {
        Some(slash) => authority_start + slash
        None => return value
      }
    }
    None => 0
  }
  let prefix = value[:prefix_end].to_owned()
  let path = value[prefix_end:].to_owned()
  let absolute = path.has_prefix("/")
  let segments : Array[String] = []
  for part in path.split("/") {
    let item = part.to_owned()
    if item == "" || item == "." {
      continue
    }
    if item == ".." {
      if !segments.is_empty() {
        let _ = segments.pop()
      }
    } else {
      segments.push(item)
    }
  }
  prefix + (if absolute { "/" } else { "" }) + segments.join("/")
}

///|
fn resolve_resource_uri(base : String, href : String) -> String {
  let href = trim_string(href)
  if href == "" || resource_has_scheme(href) {
    return href
  }
  let document_uri = resource_strip_fragment(base)
  if href.has_prefix("#") {
    return document_uri + href
  }
  if href.has_prefix("//") {
    match document_uri.find(":") {
      Some(index) => return document_uri[:index + 1].to_owned() + href
      None => return href
    }
  }
  if href.has_prefix("/") {
    return normalize_resource_path(resource_origin(document_uri) + href)
  }
  let slash = resource_last_slash(document_uri)
  let directory = if slash >= 0 {
    document_uri[:slash + 1].to_owned()
  } else {
    ""
  }
  normalize_resource_path(directory + href)
}

///|
fn strip_resource_quotes(value : String) -> String {
  let value = trim_string(value)
  if value.length() >= 2 {
    let first = value[0]
    let last = value[value.length() - 1]
    if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
      return value[1:value.length() - 1].to_owned()
    }
  }
  value
}

///|
fn canonicalize_fragment_reference(base : String, reference : String) -> String {
  let reference = strip_resource_quotes(reference)
  if reference.has_prefix("#") || reference.find("#") is None {
    reference
  } else {
    resolve_resource_uri(base, reference)
  }
}

///|
fn canonicalize_url_references(value : String, base : String) -> String {
  let lower = value.to_lower()
  let output = StringBuilder::new()
  let mut pos = 0
  while pos < value.length() {
    let found = match lower[pos:].find("url(") {
      Some(index) => pos + index
      None => {
        output.write_string(value[pos:].to_owned())
        break
      }
    }
    output.write_string(value[pos:found].to_owned())
    let content_start = found + 4
    let close = match value[content_start:].find(")") {
      Some(index) => content_start + index
      None => {
        output.write_string(value[found:].to_owned())
        break
      }
    }
    output.write_string("url(")
    output.write_string(
      canonicalize_fragment_reference(
        base,
        value[content_start:close].to_owned(),
      ),
    )
    output.write_char(')')
    pos = close + 1
  }
  output.to_string()
}

///|
fn parse_pi_attributes(data : String) -> Map[String, String] {
  let result : Map[String, String] = Map([])
  let mut pos = 0
  while pos < data.length() {
    while pos < data.length() &&
          css_is_whitespace(Int::unsafe_to_char(data[pos].to_int())) {
      pos = pos + 1
    }
    let name_start = pos
    while pos < data.length() &&
          !css_is_whitespace(Int::unsafe_to_char(data[pos].to_int())) &&
          data[pos] != '=' {
      pos = pos + 1
    }
    if pos == name_start {
      break
    }
    let name = data[name_start:pos].to_owned().to_lower()
    while pos < data.length() &&
          css_is_whitespace(Int::unsafe_to_char(data[pos].to_int())) {
      pos = pos + 1
    }
    if pos >= data.length() || data[pos] != '=' {
      continue
    }
    pos = pos + 1
    while pos < data.length() &&
          css_is_whitespace(Int::unsafe_to_char(data[pos].to_int())) {
      pos = pos + 1
    }
    if pos >= data.length() {
      break
    }
    let quote = data[pos]
    let quoted = quote == '"' || quote == '\''
    if quoted {
      pos = pos + 1
    }
    let value_start = pos
    while pos < data.length() &&
          (if quoted {
            data[pos] != quote
          } else {
            !css_is_whitespace(Int::unsafe_to_char(data[pos].to_int()))
          }) {
      pos = pos + 1
    }
    result[name] = data[value_start:pos].to_owned()
    if quoted && pos < data.length() {
      pos = pos + 1
    }
  }
  result
}

///|
fn TextResourceLoadState::record(
  self : TextResourceLoadState,
  kind : RenderDiagnosticKind,
  stage : RenderStage,
  resource : String,
) -> Unit {
  self.diagnostics.push({ kind, stage, resource, node_id: "" })
}

///|
fn TextResourceLoadState::resolve_text(
  self : TextResourceLoadState,
  uri : String,
  kind : TextResourceKind,
  stage : RenderStage,
) -> String? {
  let cache_key = match kind {
    Stylesheet => "css:" + uri
    SvgDocument => "svg:" + uri
  }
  match self.cache.get(cache_key) {
    Some(text) => return Some(text)
    None => ()
  }
  if self.resource_count >= self.options.max_external_resources {
    self.record(ResourceLimitExceeded, stage, uri)
    return None
  }
  let resolver = match self.options.text_resource_resolver {
    Some(resolver) => resolver
    None => {
      self.record(ResourceUnresolved, stage, uri)
      return None
    }
  }
  let resolved = resolver(uri, kind)
  match resolved {
    None => {
      self.record(ResourceUnresolved, stage, uri)
      None
    }
    Some(text) => {
      if self.byte_count + text.length() > self.options.max_external_bytes {
        self.record(ResourceLimitExceeded, stage, uri)
        return None
      }
      self.resource_count = self.resource_count + 1
      self.byte_count = self.byte_count + text.length()
      self.cache[cache_key] = text
      Some(text)
    }
  }
}

///|
fn TextResourceLoadState::load_stylesheet_uri(
  self : TextResourceLoadState,
  uri : String,
) -> Array[@css_cascade.Stylesheet] {
  let active_key = "css:" + uri
  if self.active.contains(active_key) {
    self.record(ResourceCycle, Stylesheet, uri)
    return []
  }
  if self.depth >= self.options.max_external_depth {
    self.record(ResourceLimitExceeded, Stylesheet, uri)
    return []
  }
  self.active[active_key] = true
  self.depth = self.depth + 1
  let result = match self.resolve_text(uri, Stylesheet, Stylesheet) {
    Some(text) => self.load_stylesheet_text(text, uri)
    None => []
  }
  self.active.remove(active_key)
  self.depth = self.depth - 1
  result
}

///|
fn TextResourceLoadState::load_stylesheet_text(
  self : TextResourceLoadState,
  text : String,
  base_uri : String,
) -> Array[@css_cascade.Stylesheet] {
  let parsed = @css.parse_stylesheet(
    canonicalize_url_references(text, base_uri),
  )
  let result : Array[@css_cascade.Stylesheet] = []
  for import_rule in parsed.imports {
    if import_rule.media != "" {
      let query = @css_media.parse_media_query_list(import_rule.media)
      if !query.evaluate(self.media_environment) {
        continue
      }
    }
    let uri = resolve_resource_uri(base_uri, import_rule.href)
    for sheet in self.load_stylesheet_uri(uri) {
      result.push(sheet)
    }
  }
  result.push(parsed)
  result
}

///|
fn load_document_stylesheets(
  events : Array[SVGXmlEvent],
  state : TextResourceLoadState,
  base_uri : String,
) -> Array[@css_cascade.Stylesheet] {
  let stylesheets : Array[@css_cascade.Stylesheet] = []
  let content = StringBuilder::new()
  let mut in_style = false
  for event in events {
    match event {
      XmlPI(target~, data~) if target.to_lower() == "xml-stylesheet" => {
        let attrs = parse_pi_attributes(data)
        let css_type = attrs.get("type").unwrap_or("text/css").to_lower()
        match attrs.get("href") {
          Some(href) if css_type == "text/css" => {
            let uri = resolve_resource_uri(base_uri, href)
            for sheet in state.load_stylesheet_uri(uri) {
              stylesheets.push(sheet)
            }
          }
          _ => ()
        }
      }
      XmlStart(element) =>
        if element.name == "style" {
          content.reset()
          in_style = true
        }
      XmlEmpty(element) =>
        if element.name == "style" {
          for sheet in state.load_stylesheet_text("", base_uri) {
            stylesheets.push(sheet)
          }
        }
      XmlText(text) | XmlCData(text) =>
        if in_style {
          content.write_string(text)
        }
      XmlEnd(name) =>
        if in_style && name == "style" {
          for
            sheet in state.load_stylesheet_text(
              trim_string(content.to_string()),
              base_uri,
            ) {
            stylesheets.push(sheet)
          }
          in_style = false
        }
      _ => ()
    }
  }
  stylesheets
}