///|
/// Scan mode for text extraction helpers.
pub(all) enum ScanMode {
  ScanGeneric
  ScanSegwit
} derive(Eq, Debug)

///|
/// Boundary policy used when extracting candidates from a larger text.
pub(all) enum ScanBoundaryStyle {
  ScanBoundaryLoose
  ScanBoundaryToken
  ScanBoundaryStrict
} derive(Eq, Debug)

///|
/// Policy issue emitted by scan lint helpers.
pub(all) enum ScanIssueKind {
  ScanIssueInvalidCandidate
  ScanIssueNonCanonical
  ScanIssueNotSegwit
  ScanIssueUnexpectedHrp
  ScanIssueUnexpectedNetwork
  ScanIssueNonStandardNetwork
  ScanIssueMixedCase
  ScanIssueUppercase
  ScanIssueDuplicate
} derive(Eq, Debug)

///|
/// Options for extracting Bech32-like candidates from text.
pub(all) struct ScanOptions {
  min_length : Int
  max_length : Int
  include_invalid : Bool
  mode : ScanMode
  require_canonical : Bool
  require_standard_segwit_hrp : Bool
  expected_hrp : String
  expected_network : String
  accept_uppercase : Bool
  accept_mixed_case : Bool
  boundary_style : ScanBoundaryStyle
  deduplicate : Bool
} derive(Eq, Debug)

///|
/// Location of one extracted candidate.
pub(all) struct CandidateSpan {
  text : String
  start_offset : Int
  end_offset : Int
  length : Int
  line_number : Int
  column_number : Int
} derive(Eq, Debug)

///|
/// One candidate and its diagnostic output.
pub(all) struct ScanFinding {
  span : CandidateSpan
  diagnostic : Diagnostic
  accepted : Bool
  reason : String
  duplicate_of : Int
} derive(Eq, Debug)

///|
/// Aggregated scan output.
pub(all) struct ScanReport {
  source : String
  findings : Array[ScanFinding]
  summary : ValidationSummary
  total_candidates : Int
  accepted : Int
  rejected : Int
  valid : Int
  invalid : Int
  segwit_valid : Int
  canonical : Int
  duplicate : Int
} derive(Eq, Debug)

///|
/// A policy issue derived from scan findings.
pub(all) struct ScanIssue {
  kind : ScanIssueKind
  severity : DiagnosticSeverity
  finding_index : Int
  related_index : Int
  input : String
  normalized : String
  message : String
  recommendation : DiagnosticRecommendation
} derive(Eq, Debug)

///|
priv struct ScanCollectResult {
  findings : Array[ScanFinding]
  total_candidates : Int
  rejected : Int
  duplicate : Int
}

///|
pub fn default_scan_options() -> ScanOptions {
  {
    min_length: 8,
    max_length: 90,
    include_invalid: false,
    mode: ScanGeneric,
    require_canonical: false,
    require_standard_segwit_hrp: false,
    expected_hrp: "",
    expected_network: "",
    accept_uppercase: true,
    accept_mixed_case: false,
    boundary_style: ScanBoundaryToken,
    deduplicate: false,
  }
}

///|
pub fn segwit_scan_options() -> ScanOptions {
  {
    min_length: 14,
    max_length: 90,
    include_invalid: false,
    mode: ScanSegwit,
    require_canonical: true,
    require_standard_segwit_hrp: true,
    expected_hrp: "",
    expected_network: "",
    accept_uppercase: true,
    accept_mixed_case: false,
    boundary_style: ScanBoundaryToken,
    deduplicate: false,
  }
}

///|
pub fn scan_options_with_min_length(
  options : ScanOptions,
  min_length : Int,
) -> ScanOptions {
  {
    min_length,
    max_length: options.max_length,
    include_invalid: options.include_invalid,
    mode: options.mode,
    require_canonical: options.require_canonical,
    require_standard_segwit_hrp: options.require_standard_segwit_hrp,
    expected_hrp: options.expected_hrp,
    expected_network: options.expected_network,
    accept_uppercase: options.accept_uppercase,
    accept_mixed_case: options.accept_mixed_case,
    boundary_style: options.boundary_style,
    deduplicate: options.deduplicate,
  }
}

///|
pub fn scan_options_with_max_length(
  options : ScanOptions,
  max_length : Int,
) -> ScanOptions {
  {
    min_length: options.min_length,
    max_length,
    include_invalid: options.include_invalid,
    mode: options.mode,
    require_canonical: options.require_canonical,
    require_standard_segwit_hrp: options.require_standard_segwit_hrp,
    expected_hrp: options.expected_hrp,
    expected_network: options.expected_network,
    accept_uppercase: options.accept_uppercase,
    accept_mixed_case: options.accept_mixed_case,
    boundary_style: options.boundary_style,
    deduplicate: options.deduplicate,
  }
}

///|
pub fn scan_options_include_invalid(
  options : ScanOptions,
  include_invalid : Bool,
) -> ScanOptions {
  {
    min_length: options.min_length,
    max_length: options.max_length,
    include_invalid,
    mode: options.mode,
    require_canonical: options.require_canonical,
    require_standard_segwit_hrp: options.require_standard_segwit_hrp,
    expected_hrp: options.expected_hrp,
    expected_network: options.expected_network,
    accept_uppercase: options.accept_uppercase,
    accept_mixed_case: options.accept_mixed_case,
    boundary_style: options.boundary_style,
    deduplicate: options.deduplicate,
  }
}

///|
pub fn scan_options_mode(options : ScanOptions, mode : ScanMode) -> ScanOptions {
  {
    min_length: options.min_length,
    max_length: options.max_length,
    include_invalid: options.include_invalid,
    mode,
    require_canonical: options.require_canonical,
    require_standard_segwit_hrp: options.require_standard_segwit_hrp,
    expected_hrp: options.expected_hrp,
    expected_network: options.expected_network,
    accept_uppercase: options.accept_uppercase,
    accept_mixed_case: options.accept_mixed_case,
    boundary_style: options.boundary_style,
    deduplicate: options.deduplicate,
  }
}

///|
pub fn scan_options_require_canonical(
  options : ScanOptions,
  require_canonical : Bool,
) -> ScanOptions {
  {
    min_length: options.min_length,
    max_length: options.max_length,
    include_invalid: options.include_invalid,
    mode: options.mode,
    require_canonical,
    require_standard_segwit_hrp: options.require_standard_segwit_hrp,
    expected_hrp: options.expected_hrp,
    expected_network: options.expected_network,
    accept_uppercase: options.accept_uppercase,
    accept_mixed_case: options.accept_mixed_case,
    boundary_style: options.boundary_style,
    deduplicate: options.deduplicate,
  }
}

///|
pub fn scan_options_require_standard_segwit_hrp(
  options : ScanOptions,
  require_standard_segwit_hrp : Bool,
) -> ScanOptions {
  {
    min_length: options.min_length,
    max_length: options.max_length,
    include_invalid: options.include_invalid,
    mode: options.mode,
    require_canonical: options.require_canonical,
    require_standard_segwit_hrp,
    expected_hrp: options.expected_hrp,
    expected_network: options.expected_network,
    accept_uppercase: options.accept_uppercase,
    accept_mixed_case: options.accept_mixed_case,
    boundary_style: options.boundary_style,
    deduplicate: options.deduplicate,
  }
}

///|
pub fn scan_options_expected_hrp(
  options : ScanOptions,
  expected_hrp : String,
) -> ScanOptions {
  {
    min_length: options.min_length,
    max_length: options.max_length,
    include_invalid: options.include_invalid,
    mode: options.mode,
    require_canonical: options.require_canonical,
    require_standard_segwit_hrp: options.require_standard_segwit_hrp,
    expected_hrp: expected_hrp.to_lower(),
    expected_network: options.expected_network,
    accept_uppercase: options.accept_uppercase,
    accept_mixed_case: options.accept_mixed_case,
    boundary_style: options.boundary_style,
    deduplicate: options.deduplicate,
  }
}

///|
pub fn scan_options_expected_network(
  options : ScanOptions,
  expected_network : String,
) -> ScanOptions {
  {
    min_length: options.min_length,
    max_length: options.max_length,
    include_invalid: options.include_invalid,
    mode: options.mode,
    require_canonical: options.require_canonical,
    require_standard_segwit_hrp: options.require_standard_segwit_hrp,
    expected_hrp: options.expected_hrp,
    expected_network,
    accept_uppercase: options.accept_uppercase,
    accept_mixed_case: options.accept_mixed_case,
    boundary_style: options.boundary_style,
    deduplicate: options.deduplicate,
  }
}

///|
pub fn scan_options_accept_uppercase(
  options : ScanOptions,
  accept_uppercase : Bool,
) -> ScanOptions {
  {
    min_length: options.min_length,
    max_length: options.max_length,
    include_invalid: options.include_invalid,
    mode: options.mode,
    require_canonical: options.require_canonical,
    require_standard_segwit_hrp: options.require_standard_segwit_hrp,
    expected_hrp: options.expected_hrp,
    expected_network: options.expected_network,
    accept_uppercase,
    accept_mixed_case: options.accept_mixed_case,
    boundary_style: options.boundary_style,
    deduplicate: options.deduplicate,
  }
}

///|
pub fn scan_options_accept_mixed_case(
  options : ScanOptions,
  accept_mixed_case : Bool,
) -> ScanOptions {
  {
    min_length: options.min_length,
    max_length: options.max_length,
    include_invalid: options.include_invalid,
    mode: options.mode,
    require_canonical: options.require_canonical,
    require_standard_segwit_hrp: options.require_standard_segwit_hrp,
    expected_hrp: options.expected_hrp,
    expected_network: options.expected_network,
    accept_uppercase: options.accept_uppercase,
    accept_mixed_case,
    boundary_style: options.boundary_style,
    deduplicate: options.deduplicate,
  }
}

///|
pub fn scan_options_boundary_style(
  options : ScanOptions,
  boundary_style : ScanBoundaryStyle,
) -> ScanOptions {
  {
    min_length: options.min_length,
    max_length: options.max_length,
    include_invalid: options.include_invalid,
    mode: options.mode,
    require_canonical: options.require_canonical,
    require_standard_segwit_hrp: options.require_standard_segwit_hrp,
    expected_hrp: options.expected_hrp,
    expected_network: options.expected_network,
    accept_uppercase: options.accept_uppercase,
    accept_mixed_case: options.accept_mixed_case,
    boundary_style,
    deduplicate: options.deduplicate,
  }
}

///|
pub fn scan_options_deduplicate(
  options : ScanOptions,
  deduplicate : Bool,
) -> ScanOptions {
  {
    min_length: options.min_length,
    max_length: options.max_length,
    include_invalid: options.include_invalid,
    mode: options.mode,
    require_canonical: options.require_canonical,
    require_standard_segwit_hrp: options.require_standard_segwit_hrp,
    expected_hrp: options.expected_hrp,
    expected_network: options.expected_network,
    accept_uppercase: options.accept_uppercase,
    accept_mixed_case: options.accept_mixed_case,
    boundary_style: options.boundary_style,
    deduplicate,
  }
}

///|
pub fn scan_mode_name(mode : ScanMode) -> String {
  match mode {
    ScanGeneric => "generic"
    ScanSegwit => "segwit"
  }
}

///|
pub fn scan_boundary_style_name(style : ScanBoundaryStyle) -> String {
  match style {
    ScanBoundaryLoose => "loose"
    ScanBoundaryToken => "token"
    ScanBoundaryStrict => "strict"
  }
}

///|
pub fn scan_issue_kind_name(kind : ScanIssueKind) -> String {
  match kind {
    ScanIssueInvalidCandidate => "invalid_candidate"
    ScanIssueNonCanonical => "non_canonical"
    ScanIssueNotSegwit => "not_segwit"
    ScanIssueUnexpectedHrp => "unexpected_hrp"
    ScanIssueUnexpectedNetwork => "unexpected_network"
    ScanIssueNonStandardNetwork => "non_standard_network"
    ScanIssueMixedCase => "mixed_case"
    ScanIssueUppercase => "uppercase"
    ScanIssueDuplicate => "duplicate"
  }
}

///|
pub fn scan_text(text : String) -> ScanReport {
  scan_text_with_options(text, default_scan_options())
}

///|
pub fn scan_segwit_text(text : String) -> ScanReport {
  scan_text_with_options(text, segwit_scan_options())
}

///|
pub fn scan_text_with_options(
  text : String,
  options : ScanOptions,
) -> ScanReport {
  let collected = collect_scan_findings(text, 0, 0, options)
  make_scan_report(text, collected)
}

///|
pub fn scan_lines(lines : Array[String]) -> ScanReport {
  scan_lines_with_options(lines, default_scan_options())
}

///|
pub fn scan_segwit_lines(lines : Array[String]) -> ScanReport {
  scan_lines_with_options(lines, segwit_scan_options())
}

///|
pub fn scan_lines_with_options(
  lines : Array[String],
  options : ScanOptions,
) -> ScanReport {
  scan_text_with_options(join_scan_lines(lines), options)
}

///|
pub fn scan_report_is_empty(report : ScanReport) -> Bool {
  report.findings.is_empty()
}

///|
pub fn scan_report_has_valid(report : ScanReport) -> Bool {
  report.valid > 0
}

///|
pub fn scan_report_has_invalid(report : ScanReport) -> Bool {
  report.invalid > 0
}

///|
pub fn scan_report_has_segwit(report : ScanReport) -> Bool {
  report.segwit_valid > 0
}

///|
pub fn scan_report_is_clean(report : ScanReport) -> Bool {
  report.invalid == 0 && report.summary.error == 0
}

///|
pub fn scan_report_all_canonical(report : ScanReport) -> Bool {
  report.accepted == report.canonical
}

///|
pub fn scan_finding_texts(findings : Array[ScanFinding]) -> Array[String] {
  let out = Array::new(capacity=findings.length())
  for finding in findings {
    out.push(finding.span.text)
  }
  out
}

///|
pub fn scan_finding_normalized_texts(
  findings : Array[ScanFinding],
) -> Array[String] {
  let out = Array::new(capacity=findings.length())
  for finding in findings {
    out.push(finding.diagnostic.normalized)
  }
  out
}

///|
pub fn scan_finding_hrps(findings : Array[ScanFinding]) -> Array[String] {
  let out = Array::new()
  for finding in findings {
    if !finding.diagnostic.hrp.is_empty() {
      out.push(finding.diagnostic.hrp)
    }
  }
  out
}

///|
pub fn scan_finding_networks(findings : Array[ScanFinding]) -> Array[String] {
  let out = Array::new()
  for finding in findings {
    if !finding.diagnostic.network.is_empty() {
      out.push(finding.diagnostic.network)
    }
  }
  out
}

///|
pub fn valid_scan_findings(findings : Array[ScanFinding]) -> Array[ScanFinding] {
  let out = Array::new()
  for finding in findings {
    if finding.diagnostic.valid {
      out.push(finding)
    }
  }
  out
}

///|
pub fn invalid_scan_findings(
  findings : Array[ScanFinding],
) -> Array[ScanFinding] {
  let out = Array::new()
  for finding in findings {
    if !finding.diagnostic.valid {
      out.push(finding)
    }
  }
  out
}

///|
pub fn segwit_scan_findings(
  findings : Array[ScanFinding],
) -> Array[ScanFinding] {
  let out = Array::new()
  for finding in findings {
    if finding.diagnostic.segwit_valid {
      out.push(finding)
    }
  }
  out
}

///|
pub fn canonical_scan_findings(
  findings : Array[ScanFinding],
) -> Array[ScanFinding] {
  let out = Array::new()
  for finding in findings {
    if finding.diagnostic.canonical {
      out.push(finding)
    }
  }
  out
}

///|
pub fn non_canonical_scan_findings(
  findings : Array[ScanFinding],
) -> Array[ScanFinding] {
  let out = Array::new()
  for finding in findings {
    if finding.diagnostic.valid && !finding.diagnostic.canonical {
      out.push(finding)
    }
  }
  out
}

///|
pub fn filter_scan_findings_by_hrp(
  findings : Array[ScanFinding],
  hrp : String,
) -> Array[ScanFinding] {
  let expected = hrp.to_lower()
  let out = Array::new()
  for finding in findings {
    if finding.diagnostic.hrp == expected {
      out.push(finding)
    }
  }
  out
}

///|
pub fn filter_scan_findings_by_network(
  findings : Array[ScanFinding],
  network : String,
) -> Array[ScanFinding] {
  let out = Array::new()
  for finding in findings {
    if finding.diagnostic.network == network {
      out.push(finding)
    }
  }
  out
}

///|
pub fn first_valid_scan_finding(report : ScanReport) -> ScanFinding? {
  for finding in report.findings {
    if finding.diagnostic.valid {
      return Some(finding)
    }
  }
  None
}

///|
pub fn first_segwit_scan_finding(report : ScanReport) -> ScanFinding? {
  for finding in report.findings {
    if finding.diagnostic.segwit_valid {
      return Some(finding)
    }
  }
  None
}

///|
pub fn scan_report_diagnostics(report : ScanReport) -> Array[Diagnostic] {
  diagnostics_from_scan_findings(report.findings)
}

///|
pub fn lint_scan_report(
  report : ScanReport,
  options : ScanOptions,
) -> Array[ScanIssue] {
  let issues = Array::new()
  for index, finding in report.findings.iter2() {
    append_finding_issues(issues, index, finding, options)
  }
  issues
}

///|
pub fn scan_issue_count(issues : Array[ScanIssue]) -> Int {
  issues.length()
}

///|
pub fn scan_issue_error_count(issues : Array[ScanIssue]) -> Int {
  let mut count = 0
  for issue in issues {
    if issue.severity == DiagnosticError {
      count += 1
    }
  }
  count
}

///|
pub fn scan_issue_warning_count(issues : Array[ScanIssue]) -> Int {
  let mut count = 0
  for issue in issues {
    if issue.severity == DiagnosticWarning {
      count += 1
    }
  }
  count
}

///|
pub fn scan_issue_info_count(issues : Array[ScanIssue]) -> Int {
  let mut count = 0
  for issue in issues {
    if issue.severity == DiagnosticInfo {
      count += 1
    }
  }
  count
}

///|
pub fn scan_issues_are_clean(issues : Array[ScanIssue]) -> Bool {
  scan_issue_error_count(issues) == 0 && scan_issue_warning_count(issues) == 0
}

///|
pub fn render_scan_finding(finding : ScanFinding) -> String {
  let out = StringBuilder(size_hint=512)
  write_line(out, "scan finding")
  write_kv(out, "text", finding.span.text)
  write_kv(
    out,
    "start_offset",
    int_to_decimal_string(finding.span.start_offset),
  )
  write_kv(out, "end_offset", int_to_decimal_string(finding.span.end_offset))
  write_kv(out, "line", int_to_decimal_string(finding.span.line_number))
  write_kv(out, "column", int_to_decimal_string(finding.span.column_number))
  write_kv(out, "accepted", bool_text(finding.accepted))
  write_kv(out, "reason", finding.reason)
  write_kv(out, "duplicate_of", int_to_decimal_string(finding.duplicate_of))
  write_kv(out, "valid", bool_text(finding.diagnostic.valid))
  write_kv(out, "segwit_valid", bool_text(finding.diagnostic.segwit_valid))
  write_kv(out, "canonical", bool_text(finding.diagnostic.canonical))
  write_kv(out, "variant", finding.diagnostic.variant)
  write_kv(out, "hrp", finding.diagnostic.hrp)
  write_kv(out, "network", finding.diagnostic.network)
  write_kv(out, "error_code", finding.diagnostic.error_code)
  out.to_string()
}

///|
pub fn render_scan_report(report : ScanReport) -> String {
  let out = StringBuilder(size_hint=2048)
  write_line(out, "scan report")
  write_kv(out, "source", report.source)
  write_kv(
    out,
    "total_candidates",
    int_to_decimal_string(report.total_candidates),
  )
  write_kv(out, "accepted", int_to_decimal_string(report.accepted))
  write_kv(out, "rejected", int_to_decimal_string(report.rejected))
  write_kv(out, "valid", int_to_decimal_string(report.valid))
  write_kv(out, "invalid", int_to_decimal_string(report.invalid))
  write_kv(out, "segwit_valid", int_to_decimal_string(report.segwit_valid))
  write_kv(out, "canonical", int_to_decimal_string(report.canonical))
  write_kv(out, "duplicate", int_to_decimal_string(report.duplicate))
  for index, finding in report.findings.iter2() {
    write_line(out, "")
    write_kv(out, "finding_index", int_to_decimal_string(index))
    write_kv(out, "finding_text", finding.span.text)
    write_kv(out, "finding_reason", finding.reason)
  }
  out.to_string()
}

///|
pub fn render_scan_issue(issue : ScanIssue) -> String {
  let out = StringBuilder(size_hint=512)
  write_line(out, "scan issue")
  write_kv(out, "kind", scan_issue_kind_name(issue.kind))
  write_kv(out, "severity", severity_name(issue.severity))
  write_kv(out, "finding_index", int_to_decimal_string(issue.finding_index))
  write_kv(out, "related_index", int_to_decimal_string(issue.related_index))
  write_kv(out, "input", issue.input)
  write_kv(out, "normalized", issue.normalized)
  write_kv(out, "message", issue.message)
  write_kv(out, "recommendation", recommendation_name(issue.recommendation))
  out.to_string()
}

///|
pub fn render_scan_issues(issues : Array[ScanIssue]) -> String {
  let out = StringBuilder(size_hint=1024)
  write_line(out, "scan issues")
  write_kv(out, "count", int_to_decimal_string(issues.length()))
  for index, issue in issues.iter2() {
    write_line(out, "")
    write_kv(out, "issue_index", int_to_decimal_string(index))
    write_kv(out, "kind", scan_issue_kind_name(issue.kind))
    write_kv(out, "severity", severity_name(issue.severity))
    write_kv(out, "message", issue.message)
  }
  out.to_string()
}

///|
fn collect_scan_findings(
  text : String,
  base_offset : Int,
  fixed_line_number : Int,
  options : ScanOptions,
) -> ScanCollectResult {
  let findings = Array::new()
  let mut total_candidates = 0
  let mut rejected = 0
  let mut duplicate = 0
  let mut index = 0
  while index < text.length() {
    if scan_is_token_char(scan_char_at(text, index)) {
      let start = index
      while index < text.length() &&
            scan_is_token_char(scan_char_at(text, index)) {
        index += 1
      }
      let end_offset = index
      let candidate = text[start:end_offset].to_owned()
      if scan_is_probable_candidate(candidate, options) &&
        scan_boundaries_accept(text, start, end_offset, options.boundary_style) {
        total_candidates += 1
        let diagnostic = if options.mode == ScanSegwit {
          diagnose_segwit(candidate)
        } else {
          diagnose(candidate)
        }
        let duplicate_of = duplicate_index(findings, diagnostic.normalized)
        if duplicate_of >= 0 {
          duplicate += 1
        }
        let accepted = scan_accepts_diagnostic(
          diagnostic, duplicate_of, options,
        )
        let reason = scan_acceptance_reason(
          diagnostic, duplicate_of, options, accepted,
        )
        let span = make_candidate_span(
          text, candidate, start, end_offset, base_offset, fixed_line_number,
        )
        let finding = { span, diagnostic, accepted, reason, duplicate_of }
        if accepted {
          findings.push(finding)
        } else {
          rejected += 1
        }
      }
    } else {
      index += 1
    }
  }
  { findings, total_candidates, rejected, duplicate }
}

///|
fn scan_is_probable_candidate(
  candidate : String,
  options : ScanOptions,
) -> Bool {
  let length = candidate.length()
  if length < options.min_length {
    return false
  }
  if options.max_length > 0 && length > options.max_length {
    return false
  }
  let p = profile(candidate)
  if !p.has_separator {
    return false
  }
  if p.hrp_length == 0 {
    return false
  }
  if p.data_part_length < 6 {
    return false
  }
  true
}

///|
fn scan_boundaries_accept(
  text : String,
  start : Int,
  end_offset : Int,
  style : ScanBoundaryStyle,
) -> Bool {
  match style {
    ScanBoundaryLoose => true
    ScanBoundaryToken =>
      scan_side_is_not_token(text, start - 1) &&
      scan_side_is_not_token(text, end_offset)
    ScanBoundaryStrict =>
      scan_side_is_strict_boundary(text, start - 1) &&
      scan_side_is_strict_boundary(text, end_offset)
  }
}

///|
fn scan_side_is_not_token(text : String, offset : Int) -> Bool {
  if offset < 0 || offset >= text.length() {
    return true
  }
  !scan_is_token_char(scan_char_at(text, offset))
}

///|
fn scan_side_is_strict_boundary(text : String, offset : Int) -> Bool {
  if offset < 0 || offset >= text.length() {
    return true
  }
  scan_is_strict_boundary_char(scan_char_at(text, offset))
}

///|
fn scan_accepts_diagnostic(
  diagnostic : Diagnostic,
  duplicate_of : Int,
  options : ScanOptions,
) -> Bool {
  if !options.accept_mixed_case && diagnostic.case_style == CaseMixed {
    return false
  }
  if !options.accept_uppercase && diagnostic.case_style == CaseUpper {
    return false
  }
  if !diagnostic.valid && !options.include_invalid {
    return false
  }
  if options.mode == ScanSegwit &&
    !diagnostic.segwit_valid &&
    !options.include_invalid {
    return false
  }
  if options.require_canonical && diagnostic.valid && !diagnostic.canonical {
    return false
  }
  if !options.expected_hrp.is_empty() && diagnostic.hrp != options.expected_hrp {
    return false
  }
  if options.require_standard_segwit_hrp &&
    !scan_is_standard_network_name(diagnostic.network) {
    return false
  }
  if !options.expected_network.is_empty() &&
    diagnostic.network != options.expected_network {
    return false
  }
  if options.deduplicate && duplicate_of >= 0 {
    return false
  }
  true
}

///|
fn scan_acceptance_reason(
  diagnostic : Diagnostic,
  duplicate_of : Int,
  options : ScanOptions,
  accepted : Bool,
) -> String {
  if accepted {
    if diagnostic.segwit_valid {
      return "accepted_segwit"
    }
    if diagnostic.valid {
      return "accepted_bech32"
    }
    return "accepted_invalid_for_diagnostics"
  }
  if !options.accept_mixed_case && diagnostic.case_style == CaseMixed {
    return "rejected_mixed_case"
  }
  if !options.accept_uppercase && diagnostic.case_style == CaseUpper {
    return "rejected_uppercase"
  }
  if !diagnostic.valid && !options.include_invalid {
    return "rejected_invalid"
  }
  if options.mode == ScanSegwit &&
    !diagnostic.segwit_valid &&
    !options.include_invalid {
    return "rejected_not_segwit"
  }
  if options.require_canonical && diagnostic.valid && !diagnostic.canonical {
    return "rejected_non_canonical"
  }
  if !options.expected_hrp.is_empty() && diagnostic.hrp != options.expected_hrp {
    return "rejected_unexpected_hrp"
  }
  if options.require_standard_segwit_hrp &&
    !scan_is_standard_network_name(diagnostic.network) {
    return "rejected_non_standard_network"
  }
  if !options.expected_network.is_empty() &&
    diagnostic.network != options.expected_network {
    return "rejected_unexpected_network"
  }
  if options.deduplicate && duplicate_of >= 0 {
    return "rejected_duplicate"
  }
  "rejected_by_policy"
}

///|
fn make_candidate_span(
  source : String,
  text : String,
  start : Int,
  end_offset : Int,
  base_offset : Int,
  fixed_line_number : Int,
) -> CandidateSpan {
  let absolute_start = base_offset + start
  let absolute_end = base_offset + end_offset
  let line_number = if fixed_line_number > 0 {
    fixed_line_number
  } else {
    line_number_at_offset(source, start)
  }
  let column_number = if fixed_line_number > 0 {
    start + 1
  } else {
    column_number_at_offset(source, start)
  }
  {
    text,
    start_offset: absolute_start,
    end_offset: absolute_end,
    length: text.length(),
    line_number,
    column_number,
  }
}

///|
fn make_scan_report(
  source : String,
  collected : ScanCollectResult,
) -> ScanReport {
  make_scan_report_from_parts(
    source,
    collected.findings,
    collected.total_candidates,
    collected.rejected,
    collected.duplicate,
  )
}

///|
fn make_scan_report_from_parts(
  source : String,
  findings : Array[ScanFinding],
  total_candidates : Int,
  rejected : Int,
  duplicate : Int,
) -> ScanReport {
  let diagnostics = diagnostics_from_scan_findings(findings)
  let summary = summarize_diagnostics(diagnostics)
  {
    source,
    findings,
    summary,
    total_candidates,
    accepted: findings.length(),
    rejected,
    valid: summary.valid,
    invalid: summary.invalid,
    segwit_valid: summary.segwit_valid,
    canonical: summary.canonical,
    duplicate,
  }
}

///|
fn diagnostics_from_scan_findings(
  findings : Array[ScanFinding],
) -> Array[Diagnostic] {
  let diagnostics = Array::new(capacity=findings.length())
  for finding in findings {
    diagnostics.push(finding.diagnostic)
  }
  diagnostics
}

///|
fn join_scan_lines(lines : Array[String]) -> String {
  let out = StringBuilder(size_hint=scan_lines_size_hint(lines))
  for index, line in lines.iter2() {
    if index > 0 {
      out.write_char('\n')
    }
    out.write_string(line)
  }
  out.to_string()
}

///|
fn scan_lines_size_hint(lines : Array[String]) -> Int {
  let mut size = 0
  for index, line in lines.iter2() {
    size += line.length()
    if index > 0 {
      size += 1
    }
  }
  size
}

///|
fn append_finding_issues(
  issues : Array[ScanIssue],
  index : Int,
  finding : ScanFinding,
  options : ScanOptions,
) -> Unit {
  let diagnostic = finding.diagnostic
  if !diagnostic.valid {
    issues.push(
      make_scan_issue(
        ScanIssueInvalidCandidate,
        DiagnosticError,
        index,
        -1,
        diagnostic,
        "candidate is not a valid Bech32 or Bech32m string",
        diagnostic.recommendation,
      ),
    )
  }
  if diagnostic.valid && !diagnostic.canonical && options.require_canonical {
    issues.push(
      make_scan_issue(
        ScanIssueNonCanonical,
        DiagnosticInfo,
        index,
        -1,
        diagnostic,
        "candidate is valid but not canonical lowercase",
        RecommendNormalizeInput,
      ),
    )
  }
  if options.mode == ScanSegwit && diagnostic.valid && !diagnostic.segwit_valid {
    issues.push(
      make_scan_issue(
        ScanIssueNotSegwit,
        DiagnosticWarning,
        index,
        -1,
        diagnostic,
        "candidate validates as Bech32 but not as SegWit",
        diagnostic.recommendation,
      ),
    )
  }
  if !options.accept_mixed_case && diagnostic.case_style == CaseMixed {
    issues.push(
      make_scan_issue(
        ScanIssueMixedCase,
        DiagnosticError,
        index,
        -1,
        diagnostic,
        "candidate mixes uppercase and lowercase letters",
        RecommendLowercaseInput,
      ),
    )
  }
  if !options.accept_uppercase && diagnostic.case_style == CaseUpper {
    issues.push(
      make_scan_issue(
        ScanIssueUppercase,
        DiagnosticInfo,
        index,
        -1,
        diagnostic,
        "candidate is uppercase and policy expects lowercase",
        RecommendNormalizeInput,
      ),
    )
  }
  if !options.expected_hrp.is_empty() && diagnostic.hrp != options.expected_hrp {
    issues.push(
      make_scan_issue(
        ScanIssueUnexpectedHrp,
        DiagnosticWarning,
        index,
        -1,
        diagnostic,
        "candidate HRP does not match the expected HRP",
        RecommendCheckHrp,
      ),
    )
  }
  if options.require_standard_segwit_hrp &&
    !scan_is_standard_network_name(diagnostic.network) {
    issues.push(
      make_scan_issue(
        ScanIssueNonStandardNetwork,
        DiagnosticWarning,
        index,
        -1,
        diagnostic,
        "candidate network is not a built-in Bitcoin SegWit network",
        RecommendCheckNetwork,
      ),
    )
  }
  if !options.expected_network.is_empty() &&
    diagnostic.network != options.expected_network {
    issues.push(
      make_scan_issue(
        ScanIssueUnexpectedNetwork,
        DiagnosticWarning,
        index,
        -1,
        diagnostic,
        "candidate network does not match the expected network",
        RecommendCheckNetwork,
      ),
    )
  }
  if finding.duplicate_of >= 0 {
    issues.push(
      make_scan_issue(
        ScanIssueDuplicate,
        DiagnosticInfo,
        index,
        finding.duplicate_of,
        diagnostic,
        "candidate duplicates an earlier normalized value",
        RecommendNormalizeInput,
      ),
    )
  }
}

///|
fn make_scan_issue(
  kind : ScanIssueKind,
  severity : DiagnosticSeverity,
  finding_index : Int,
  related_index : Int,
  diagnostic : Diagnostic,
  message : String,
  recommendation : DiagnosticRecommendation,
) -> ScanIssue {
  {
    kind,
    severity,
    finding_index,
    related_index,
    input: diagnostic.input,
    normalized: diagnostic.normalized,
    message,
    recommendation,
  }
}

///|
fn duplicate_index(findings : Array[ScanFinding], normalized : String) -> Int {
  if normalized.is_empty() {
    return -1
  }
  for index, finding in findings.iter2() {
    if finding.diagnostic.normalized == normalized {
      return index
    }
  }
  -1
}

///|
fn scan_is_standard_network_name(network : String) -> Bool {
  network == "bitcoin_mainnet" ||
  network == "bitcoin_testnet" ||
  network == "bitcoin_regtest"
}

///|
fn scan_is_token_char(ch : Char) -> Bool {
  scan_is_ascii_letter(ch) || scan_is_ascii_digit(ch)
}

///|
fn scan_is_ascii_letter(ch : Char) -> Bool {
  let code = ch.to_int()
  (code >= 65 && code <= 90) || (code >= 97 && code <= 122)
}

///|
fn scan_is_ascii_digit(ch : Char) -> Bool {
  let code = ch.to_int()
  code >= 48 && code <= 57
}

///|
fn scan_is_strict_boundary_char(ch : Char) -> Bool {
  let code = ch.to_int()
  code <= 32 ||
  code == 34 ||
  code == 39 ||
  code == 40 ||
  code == 41 ||
  code == 44 ||
  code == 59 ||
  code == 60 ||
  code == 62 ||
  code == 91 ||
  code == 93 ||
  code == 123 ||
  code == 125
}

///|
fn line_number_at_offset(text : String, offset : Int) -> Int {
  let mut line = 1
  let mut index = 0
  while index < offset && index < text.length() {
    if scan_char_at(text, index) == '\n' {
      line += 1
    }
    index += 1
  }
  line
}

///|
fn column_number_at_offset(text : String, offset : Int) -> Int {
  let mut column = 1
  let mut index = 0
  while index < offset && index < text.length() {
    if scan_char_at(text, index) == '\n' {
      column = 1
    } else {
      column += 1
    }
    index += 1
  }
  column
}

///|
fn scan_char_at(input : String, offset : Int) -> Char {
  input.get_char(offset).unwrap()
}