///|
/// Query, statistics, engineering audit and RFC 3264 offer/answer analysis.
pub struct SessionStats {
  media_count : Int
  active_media_count : Int
  rejected_media_count : Int
  session_attribute_count : Int
  media_attribute_count : Int
  rtp_payload_count : Int
  connection_count : Int
  bandwidth_count : Int
  timing_count : Int
  unknown_attribute_count : Int
}

///|
pub fn SessionStats::media_count(self : SessionStats) -> Int {
  self.media_count
}

///|
pub fn SessionStats::active_media_count(self : SessionStats) -> Int {
  self.active_media_count
}

///|
pub fn SessionStats::rejected_media_count(self : SessionStats) -> Int {
  self.rejected_media_count
}

///|
pub fn SessionStats::session_attribute_count(self : SessionStats) -> Int {
  self.session_attribute_count
}

///|
pub fn SessionStats::media_attribute_count(self : SessionStats) -> Int {
  self.media_attribute_count
}

///|
pub fn SessionStats::rtp_payload_count(self : SessionStats) -> Int {
  self.rtp_payload_count
}

///|
pub fn SessionStats::connection_count(self : SessionStats) -> Int {
  self.connection_count
}

///|
pub fn SessionStats::bandwidth_count(self : SessionStats) -> Int {
  self.bandwidth_count
}

///|
pub fn SessionStats::timing_count(self : SessionStats) -> Int {
  self.timing_count
}

///|
pub fn SessionStats::unknown_attribute_count(self : SessionStats) -> Int {
  self.unknown_attribute_count
}

///|
/// Computes structural statistics without altering the document.
pub fn statistics(document : SessionDescription) -> SessionStats {
  let mut active = 0
  let mut rejected = 0
  let mut media_attributes = 0
  let mut payloads = 0
  let mut connections = if document.connection() is Some(_) { 1 } else { 0 }
  let mut bandwidths = document.bandwidths().length()
  let mut unknown = 0
  for item in document.attributes() {
    if !is_registered_attribute(item.name()) {
      unknown = unknown + 1
    }
  }
  for media in document.media_descriptions() {
    if media.is_rejected() {
      rejected = rejected + 1
    } else {
      active = active + 1
    }
    media_attributes = media_attributes + media.attributes().length()
    payloads = payloads + media.formats().length()
    connections = connections + media.connections().length()
    bandwidths = bandwidths + media.bandwidths().length()
    for item in media.attributes() {
      if !is_registered_attribute(item.name()) {
        unknown = unknown + 1
      }
    }
  }
  {
    media_count: document.media_count(),
    active_media_count: active,
    rejected_media_count: rejected,
    session_attribute_count: document.attributes().length(),
    media_attribute_count: media_attributes,
    rtp_payload_count: payloads,
    connection_count: connections,
    bandwidth_count: bandwidths,
    timing_count: document.timings().length(),
    unknown_attribute_count: unknown,
  }
}

///|
pub fn SessionStats::to_text(self : SessionStats) -> String {
  "media=\{self.media_count} active=\{self.active_media_count} rejected=\{self.rejected_media_count} attributes=\{self.session_attribute_count + self.media_attribute_count} payloads=\{self.rtp_payload_count} connections=\{self.connection_count} bandwidths=\{self.bandwidth_count} timings=\{self.timing_count} extensions=\{self.unknown_attribute_count}"
}

///|
/// Returns every media description with a matching media token.
pub fn media_by_type(
  document : SessionDescription,
  media_type : String,
) -> Array[MediaDescription] {
  let out : Array[MediaDescription] = Array::new()
  for media in document.media_descriptions() {
    if media.media().equal_ignore_ascii_case(media_type) {
      out.push(media)
    }
  }
  out
}

///|
/// Returns every non-rejected media description.
pub fn active_media(document : SessionDescription) -> Array[MediaDescription] {
  let out : Array[MediaDescription] = Array::new()
  for media in document.media_descriptions() {
    if !media.is_rejected() {
      out.push(media)
    }
  }
  out
}

///|
/// Finds a media description by its `a=mid` value.
pub fn media_by_mid(
  document : SessionDescription,
  mid : String,
) -> MediaDescription? {
  for media in document.media_descriptions() {
    match media.attribute_value("mid") {
      Some(value) => if value == mid { return Some(media) }
      None => ()
    }
  }
  None
}

///|
/// Returns the distinct codec names advertised by all valid rtpmap lines.
pub fn codec_names(document : SessionDescription) -> Array[String] {
  let out : Array[String] = Array::new()
  for media in document.media_descriptions() {
    for mapping in media.rtp_maps() {
      let name = mapping.encoding_name().to_upper()
      if !string_in(out, name) {
        out.push(name)
      }
    }
  }
  out
}

///|
pub enum AuditSeverity {
  High
  Medium
  Low
}

///|
pub struct AuditFinding {
  severity : AuditSeverity
  code : String
  scope : String
  message : String
}

///|
pub struct AuditReport {
  findings : Array[AuditFinding]
}

///|
fn audit_finding(
  severity : AuditSeverity,
  code : String,
  scope : String,
  message : String,
) -> AuditFinding {
  { severity, code, scope, message }
}

///|
pub fn AuditSeverity::to_string(self : AuditSeverity) -> String {
  match self {
    High => "high"
    Medium => "medium"
    Low => "low"
  }
}

///|
pub fn AuditFinding::severity(self : AuditFinding) -> AuditSeverity {
  self.severity
}

///|
pub fn AuditFinding::code(self : AuditFinding) -> String {
  self.code
}

///|
pub fn AuditFinding::scope(self : AuditFinding) -> String {
  self.scope
}

///|
pub fn AuditFinding::message(self : AuditFinding) -> String {
  self.message
}

///|
pub fn AuditReport::findings(self : AuditReport) -> Array[AuditFinding] {
  self.findings
}

///|
pub fn AuditReport::finding_count(self : AuditReport) -> Int {
  self.findings.length()
}

///|
pub fn AuditReport::high_count(self : AuditReport) -> Int {
  let mut count = 0
  for item in self.findings {
    if item.severity() is High {
      count = count + 1
    }
  }
  count
}

///|
pub fn AuditReport::medium_count(self : AuditReport) -> Int {
  let mut count = 0
  for item in self.findings {
    if item.severity() is Medium {
      count = count + 1
    }
  }
  count
}

///|
pub fn AuditReport::low_count(self : AuditReport) -> Int {
  let mut count = 0
  for item in self.findings {
    if item.severity() is Low {
      count = count + 1
    }
  }
  count
}

///|
fn push_finding(
  findings : Array[AuditFinding],
  severity : AuditSeverity,
  code : String,
  scope : String,
  message : String,
) -> Unit {
  findings.push(audit_finding(severity, code, scope, message))
}

///|
/// Performs operational checks that are deliberately stricter than RFC 8866.
pub fn audit(document : SessionDescription) -> AuditReport {
  let findings : Array[AuditFinding] = Array::new()
  let validation = validate(document)
  if validation.error_count() > 0 {
    push_finding(
      findings,
      High,
      "AUDIT_VALIDATION_ERRORS",
      "session",
      "document has RFC validation errors",
    )
  }
  if document.encryption_key() is Some(_) {
    push_finding(
      findings,
      High,
      "AUDIT_OBSOLETE_KEY",
      "session",
      "obsolete k= material should not be used",
    )
  }
  if document.connection() is Some(_) &&
    document.media_descriptions().length() > 1 {
    push_finding(
      findings,
      Low,
      "AUDIT_SHARED_CONNECTION",
      "session",
      "multiple media sections inherit one connection address; verify this is intentional",
    )
  }
  if document.session_name() == "-" {
    push_finding(
      findings,
      Low,
      "AUDIT_PLACEHOLDER_NAME",
      "session",
      "session name is the interoperable placeholder '-' rather than a descriptive title",
    )
  }
  if document.attributes_named("tool").length() == 0 {
    push_finding(
      findings,
      Low,
      "AUDIT_NO_TOOL",
      "session",
      "a=tool can help operational diagnostics",
    )
  }
  let seen_mids : Array[String] = Array::new()
  for index, media in document.media_descriptions() {
    let scope = "media[\{index}]"
    if media.attributes_named("mid").length() == 0 && document.media_count() > 1 {
      push_finding(
        findings,
        Medium,
        "AUDIT_NO_MID",
        scope,
        "multi-media sessions are easier to correlate when every section has a unique mid",
      )
    }
    match media.attribute_value("mid") {
      Some(value) =>
        if string_in(seen_mids, value) {
          push_finding(
            findings,
            High,
            "AUDIT_DUPLICATE_MID",
            scope,
            "mid must be unique across media descriptions",
          )
        } else {
          seen_mids.push(value)
        }
      None => ()
    }
    if is_rtp_protocol(media.protocol()) &&
      media.attributes_named("rtcp-mux").length() == 0 {
      push_finding(
        findings,
        Low,
        "AUDIT_NO_RTCP_MUX",
        scope,
        "RTP media does not advertise RTCP multiplexing",
      )
    }
    if media.direction(document.attributes()) is Unspecified {
      push_finding(
        findings,
        Low,
        "AUDIT_IMPLICIT_DIRECTION",
        scope,
        "direction is implicit sendrecv",
      )
    }
    if media.port() != 0 && media.formats().length() > 16 {
      push_finding(
        findings,
        Medium,
        "AUDIT_MANY_FORMATS",
        scope,
        "large format lists may reduce interoperability",
      )
    }
  }
  { findings, }
}

///|
pub fn AuditReport::to_text(self : AuditReport) -> String {
  let sb = StringBuilder()
  sb.write_string("audit: high=")
  sb.write_string(self.high_count().to_string())
  sb.write_string(" medium=")
  sb.write_string(self.medium_count().to_string())
  sb.write_string(" low=")
  sb.write_string(self.low_count().to_string())
  for item in self.findings {
    sb.write_string("\n[")
    sb.write_string(item.severity().to_string())
    sb.write_string("] ")
    sb.write_string(item.code())
    sb.write_string(" ")
    sb.write_string(item.scope())
    sb.write_string(": ")
    sb.write_string(item.message())
  }
  sb.to_string()
}

///|
pub struct OfferAnswerIssue {
  code : String
  media_index : Int
  message : String
  reference : String
}

///|
pub struct OfferAnswerReport {
  compatible : Bool
  issues : Array[OfferAnswerIssue]
  accepted_media : Int
  rejected_media : Int
}

///|
fn offer_answer_issue(
  code : String,
  media_index : Int,
  message : String,
  reference : String,
) -> OfferAnswerIssue {
  { code, media_index, message, reference }
}

///|
pub fn OfferAnswerIssue::code(self : OfferAnswerIssue) -> String {
  self.code
}

///|
pub fn OfferAnswerIssue::media_index(self : OfferAnswerIssue) -> Int {
  self.media_index
}

///|
pub fn OfferAnswerIssue::message(self : OfferAnswerIssue) -> String {
  self.message
}

///|
pub fn OfferAnswerIssue::reference(self : OfferAnswerIssue) -> String {
  self.reference
}

///|
pub fn OfferAnswerReport::compatible(self : OfferAnswerReport) -> Bool {
  self.compatible
}

///|
pub fn OfferAnswerReport::issues(
  self : OfferAnswerReport,
) -> Array[OfferAnswerIssue] {
  self.issues
}

///|
pub fn OfferAnswerReport::issue_count(self : OfferAnswerReport) -> Int {
  self.issues.length()
}

///|
pub fn OfferAnswerReport::accepted_media(self : OfferAnswerReport) -> Int {
  self.accepted_media
}

///|
pub fn OfferAnswerReport::rejected_media(self : OfferAnswerReport) -> Int {
  self.rejected_media
}

///|
fn direction_compatible(offer : Direction, answer : Direction) -> Bool {
  match offer {
    SendOnly => answer is RecvOnly || answer is Inactive
    RecvOnly => answer is SendOnly || answer is Inactive
    Inactive => answer is Inactive
    SendRecv =>
      answer is SendRecv ||
      answer is SendOnly ||
      answer is RecvOnly ||
      answer is Inactive
    Unspecified => true
  }
}

///|
fn rtp_mapping_compatible(
  offer_media : MediaDescription,
  answer_media : MediaDescription,
  format : String,
) -> Bool {
  let payload = match parse_non_negative_int(format) {
    Some(value) => value
    None => return true
  }
  let offered = offer_media.rtp_map_for(payload)
  let answered = answer_media.rtp_map_for(payload)
  match (offered, answered) {
    (Some(left), Some(right)) =>
      left.encoding_name().equal_ignore_ascii_case(right.encoding_name()) &&
      left.clock_rate() == right.clock_rate() &&
      left.encoding_parameters() == right.encoding_parameters()
    _ => true
  }
}

///|
/// Performs RFC 3264 structural offer/answer checks without network behavior.
pub fn analyze_offer_answer(
  offer : SessionDescription,
  answer : SessionDescription,
) -> OfferAnswerReport {
  let issues : Array[OfferAnswerIssue] = Array::new()
  let offer_media = offer.media_descriptions()
  let answer_media = answer.media_descriptions()
  if answer_media.length() != offer_media.length() {
    issues.push(
      offer_answer_issue(
        "OA_MEDIA_COUNT", -1, "answer must contain the same number of m= lines as the offer",
        "RFC 3264 §6",
      ),
    )
  }
  let count = if offer_media.length() < answer_media.length() {
    offer_media.length()
  } else {
    answer_media.length()
  }
  let mut accepted = 0
  let mut rejected = 0
  for index = 0; index < count; index = index + 1 {
    let offered = offer_media[index]
    let answered = answer_media[index]
    if !offered.media().equal_ignore_ascii_case(answered.media()) {
      issues.push(
        offer_answer_issue(
          "OA_MEDIA_ORDER", index, "answer media type does not correspond to the offered m= line",
          "RFC 3264 §6",
        ),
      )
    }
    if !offered.protocol().equal_ignore_ascii_case(answered.protocol()) &&
      answered.port() != 0 {
      issues.push(
        offer_answer_issue(
          "OA_PROTOCOL_CHANGED", index, "accepted answer changed the transport protocol",
          "RFC 3264 §6",
        ),
      )
    }
    if answered.port() == 0 {
      rejected = rejected + 1
      continue
    }
    accepted = accepted + 1
    for format in answered.formats() {
      if !offered.has_format(format) {
        issues.push(
          offer_answer_issue(
            "OA_FORMAT_NOT_OFFERED", index, "answer selected a format absent from the offer",
            "RFC 3264 §6.1",
          ),
        )
      } else if !rtp_mapping_compatible(offered, answered, format) {
        issues.push(
          offer_answer_issue(
            "OA_RTPMAP_CHANGED", index, "answer changed encoding identity for a payload type",
            "RFC 3264 §6.1",
          ),
        )
      }
    }
    let offer_direction = offered.direction(offer.attributes())
    let answer_direction = answered.direction(answer.attributes())
    if !direction_compatible(offer_direction, answer_direction) {
      issues.push(
        offer_answer_issue(
          "OA_DIRECTION", index, "answer direction is incompatible with the offer",
          "RFC 3264 §6.1",
        ),
      )
    }
  }
  {
    compatible: issues.length() == 0,
    issues,
    accepted_media: accepted,
    rejected_media: rejected,
  }
}

///|
pub fn OfferAnswerReport::to_text(self : OfferAnswerReport) -> String {
  let sb = StringBuilder()
  sb.write_string(
    if self.compatible {
      "offer-answer: compatible"
    } else {
      "offer-answer: incompatible"
    },
  )
  sb.write_string(" accepted=")
  sb.write_string(self.accepted_media.to_string())
  sb.write_string(" rejected=")
  sb.write_string(self.rejected_media.to_string())
  for item in self.issues {
    sb.write_string("\n[")
    sb.write_string(item.code())
    sb.write_string("] media=")
    sb.write_string(item.media_index().to_string())
    sb.write_string(": ")
    sb.write_string(item.message())
  }
  sb.to_string()
}