///|
/// One parsed property line: `NAME;PARAM=VAL;PARAM2="A,B":value`.
///
/// `value` is still the escaped form as it appears on the wire; call
/// [`unescape_value`] to get the text a human should see.
pub struct ContentLine {
  name : String
  params : Array[PropertyParam]
  value : String
} derive(Debug, Eq)

///|
pub extend ContentLine with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend ContentLine with Eq::{not_equal, equal}

///|
/// A single parameter of a content line, with surrounding quotes already
/// stripped from its value.
pub struct PropertyParam {
  name : String
  value : String
} derive(Debug, Eq)

///|
pub extend PropertyParam with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend PropertyParam with Eq::{not_equal, equal}

///|
/// Parse one logical (already unfolded) content line.
///
/// Raises [`ParseError`] when there is no value separator or the property name
/// is missing — either way the text is not a usable iCalendar property.
///
/// # Example
/// ```mbt nocheck
/// fn test_example() raise {
///   let line = @text.parse_content_line(
///     "DTSTART;TZID=Asia/Shanghai:20260908T093000", 1,
///   )
///   assert_eq(line.name, "DTSTART")
///   assert_eq(line.params[0].value, "Asia/Shanghai")
///   assert_eq(line.value, "20260908T093000")
/// }
/// ```
pub fn parse_content_line(line : String, line_no : Int) -> ContentLine raise {
  let view = line.view()
  let n = view.length()
  // The first ':' outside a quoted param value starts the value. A ':' inside
  // quotes belongs to that parameter, so splitting on ':' blindly is wrong.
  let mut colon = -1
  let mut quoted = false
  let mut i = 0
  while i < n && colon < 0 {
    let c = view[i]
    if c == '"' {
      quoted = !quoted
    } else if c == ':' && !quoted {
      colon = i
    }
    i = i + 1
  }
  if colon < 0 {
    raise ParseError::BadLine(line_no~, line~, message="no ':' value separator")
  }
  let head = view[:colon]
  let value = view[colon + 1:].to_owned()
  let parts = split_unquoted(head, ';')
  let name = trim_ascii(parts[0]).to_upper().to_owned()
  if name.length() == 0 {
    raise ParseError::BadLine(line_no~, line~, message="empty property name")
  }
  let params : Array[PropertyParam] = []
  for raw in parts[1:] {
    let eq = match find_unquoted(raw, '=') {
      Some(index) => index
      None =>
        raise ParseError::BadLine(
          line_no~,
          line~,
          message="parameter without '=': \{raw}",
        )
    }
    let pname = trim_ascii(raw[:eq]).to_upper().to_owned()
    let pvalue = unquote(trim_ascii(raw[eq + 1:])).to_owned()
    params.push({ name: pname, value: pvalue, })
  }
  { name, params, value, }
}

///|
/// Trim protocol whitespace without materializing an intermediate string.
fn trim_ascii(s : StringView) -> StringView {
  s.trim(chars=" \t".view())
}

///|
/// The value of the first parameter with this name (`TZID`, `VALUE`, ...).
/// Parameter names were normalised to upper case while parsing, and the lookup
/// key is normalised the same way, so callers can pass either spelling.
pub fn ContentLine::param(self : ContentLine, name : String) -> String? {
  let want = String::to_upper(name)
  let mut found : String? = None
  for p in self.params {
    if p.name == want {
      found = Some(p.value)
      break
    }
  }
  found
}

///|
/// Turn the RFC 5545 escaped text of a property value into display text.
///
/// `\\n` and `\\N` both mean a newline (the latter is common in Apple feeds),
/// `\\,` and `\\;` are literal delimiters, `\\\\` is a literal backslash. An
/// unknown escape is passed through rather than dropped, so odd feed data
/// still survives a parse.
///
/// # Example
/// ```mbt nocheck
/// fn test_example() {
///   assert_eq(
///     @text.unescape_value("Team sync\\, 2026\\nRoom B"),
///     "Team sync, 2026\nRoom B",
///   )
/// }
/// ```
pub fn unescape_value(value : String) -> String {
  let out : Array[Char] = []
  let chars = value.to_array()
  let n = chars.length()
  let mut i = 0
  while i < n {
    let c = chars[i]
    if c == '\\' && i + 1 < n {
      let esc = chars[i + 1]
      if esc == 'n' || esc == 'N' {
        out.push('\n')
        i = i + 2
      } else if esc == ',' || esc == ';' {
        out.push(esc)
        i = i + 2
      } else if esc == '\\' {
        out.push('\\')
        i = i + 2
      } else {
        // Unknown escape: keep the backslash and continue after it.
        out.push(c)
        i = i + 1
      }
    } else {
      out.push(c)
      i = i + 1
    }
  }
  String::from_array(out)
}

///|
/// Escape text for use as a property value, the inverse of
/// [`unescape_value`]: a newline becomes `\n`, and a literal comma,
/// semicolon, or backslash gains its backslash. Characters that need no
/// escape pass through untouched.
///
/// Parsed values keep their escaped wire form, so a round trip through
/// parse and serialize never calls this — it is for *building* content
/// from plain text.
///
/// # Example
/// ```mbt nocheck
/// fn test_example() {
///   assert_eq(
///     @text.escape_value("Team sync, 2026\nRoom B"),
///     "Team sync\\, 2026\\nRoom B",
///   )
/// }
/// ```
pub fn escape_value(text : String) -> String {
  let out : Array[Char] = []
  for c in text {
    if c == '\n' {
      out.push('\\')
      out.push('n')
    } else if c == ',' || c == ';' || c == '\\' {
      out.push('\\')
      out.push(c)
    } else {
      out.push(c)
    }
  }
  String::from_array(out)
}

///|
/// Split on `sep`, ignoring separators that sit inside double quotes.
fn split_unquoted(s : StringView, sep : UInt16) -> Array[StringView] {
  let parts : Array[StringView] = []
  let n = s.length()
  let mut quoted = false
  let mut start = 0
  let mut i = 0
  while i < n {
    let c = s[i]
    if c == '"' {
      quoted = !quoted
    } else if c == sep && !quoted {
      parts.push(s[start:i])
      start = i + 1
    }
    i = i + 1
  }
  parts.push(s[start:])
  parts
}

///|
/// Index of `target` outside double quotes, or -1 when it is absent.
fn find_unquoted(s : StringView, target : UInt16) -> Int? {
  let mut quoted = false
  let mut i = 0
  while i < s.length() {
    let c = s[i]
    if c == '"' {
      quoted = !quoted
    } else if c == target && !quoted {
      return Some(i)
    }
    i = i + 1
  }
  None
}

///|
/// Strip one layer of surrounding double quotes.
fn unquote(s : StringView) -> StringView {
  let n = s.length()
  if n >= 2 && s[0] == '"' && s[n - 1] == '"' {
    s[1:n - 1]
  } else {
    s
  }
}