///|
/// A filter on concepts. Text comparison ignores letter case.
pub(all) enum Filter {
  /// `type` equals the concept type.
  TypeIs(ConceptType)
  /// The effective status equals the status.
  StatusIs(Status)
  /// `tags` contains the tag.
  HasTag(String)
  /// The bundle label equals the label; a bare label also matches the bundle name.
  InBundle(BundleLabel)
  /// The frontmatter key has the value (as text, or as one item of a list).
  FieldIs(FieldKey, String)
} derive(Eq, Debug)

///|
/// True when the concept matches the filter.
pub fn Filter::matches(self : Filter, bundle : Bundle, doc : Document) -> Bool {
  let meta = doc.metadata()
  let eq = (a : String, b : String) => a.to_lower() == b.to_lower()
  match self {
    TypeIs(t) => meta.concept_type() == Some(t)
    StatusIs(s) =>
      match meta.effective_status(options=bundle.options()) {
        Some(v) => eq(v.to_string(), s.to_string())
        None => false
      }
    HasTag(tag) => meta.tags().any(v => eq(v, tag))
    InBundle(label) =>
      eq(bundle.label().to_string(), label.to_string()) ||
      (label.is_bare() && eq(bundle.name(), label.name()))
    FieldIs(key, value) => meta.texts(key.to_string()).any(v => eq(v, value))
  }
}

///|
/// The concepts that match every filter.
pub fn Knowledge::filter(
  self : Knowledge,
  filters : ArrayView[Filter],
) -> Array[(Bundle, Document)] {
  self
  .concepts()
  .iter()
  .filter(pair => filters.iter().all(f => f.matches(pair.0, pair.1)))
  .collect()
}

///|
/// What a facet counts.
pub(all) enum Facet {
  /// The concept types.
  Type
  /// The effective statuses.
  StatusFacet
  /// The tags.
  Tags
  /// The bundle labels.
  Bundles
  /// The values of a frontmatter key.
  Field(FieldKey)
} derive(Eq, Debug)

///|
/// The values of a facet over the concepts that match `filters`, with their
/// counts, most frequent first.
pub fn Knowledge::facet(
  self : Knowledge,
  facet : Facet,
  filters~ : ArrayView[Filter] = [],
) -> Array[(String, Int32)] {
  let counts : Map[String, Int32] = Map([])
  for pair in self.filter(filters) {
    let (bundle, doc) = pair
    let meta = doc.metadata()
    let values = match facet {
      Type =>
        match meta.concept_type() {
          Some(t) => [t.to_string()]
          None => []
        }
      StatusFacet =>
        match meta.effective_status(options=bundle.options()) {
          Some(s) => [s.to_string()]
          None => []
        }
      Tags => meta.tags()
      Bundles => [bundle.label().to_string()]
      Field(key) => meta.texts(key.to_string())
    }
    for v in values {
      counts[v] = counts.get(v).unwrap_or(0) + 1
    }
  }
  let out = counts.iter().collect()
  out.sort_by((a, b) => {
    if a.1 != b.1 {
      b.1.compare(a.1)
    } else {
      lexical_compare(a.0, b.0)
    }
  })
  out
}

///|
/// A field that a text scan can match.
pub(all) enum ScanField {
  TitleField
  DescriptionField
  TypeField
  TagsField
  PathField
} derive(Eq, Debug)

///|
/// One result of a text scan.
pub struct ScanHit {
  key : DocKey
  /// The fields that contain the text.
  fields : ReadOnlyArray[ScanField]
  /// The body lines that contain the text, as (file line, line text).
  lines : ReadOnlyArray[(Int32, String)]
} derive(Eq, Debug)

///|
/// Finds the concepts whose title, description, type, tags or path contain
/// `text`, ignoring letter case. With `bodies=true`, body lines count too.
pub fn Knowledge::scan(
  self : Knowledge,
  text : String,
  bodies~ : Bool = false,
  filters~ : ArrayView[Filter] = [],
) -> Array[ScanHit] {
  let needle = text.to_lower()
  let out = []
  for pair in self.filter(filters) {
    let (bundle, doc) = pair
    let meta = doc.metadata()
    let fields : Array[ScanField] = []
    if doc.display_title().to_lower().contains(needle) {
      fields.push(TitleField)
    }
    if meta.description().unwrap_or("").to_lower().contains(needle) {
      fields.push(DescriptionField)
    }
    match meta.concept_type() {
      Some(t) if t.to_string().to_lower().contains(needle) =>
        fields.push(TypeField)
      _ => ()
    }
    if meta.tags().any(t => t.to_lower().contains(needle)) {
      fields.push(TagsField)
    }
    if doc.path().to_string().to_lower().contains(needle) {
      fields.push(PathField)
    }
    let lines = []
    if bodies {
      for i, line in split_lines(doc.body()) {
        if line.text.to_lower().contains(needle) {
          lines.push((doc.body_line() + i, line.text))
        }
      }
    }
    if !fields.is_empty() || !lines.is_empty() {
      out.push({
        key: DocKey::of(bundle, doc),
        fields: ReadOnlyArray::from_array(fields),
        lines: ReadOnlyArray::from_array(lines),
      })
    }
  }
  out
}

///|
/// Finds a document by bundle and path. Without an exact match, the documents
/// whose path ends with `path` match. `bundle` narrows the search to one bundle.
pub fn Knowledge::find_document(
  self : Knowledge,
  path : String,
  bundle? : BundleLabel,
) -> Lookup[(Bundle, Document)] {
  let bundles = match bundle {
    Some(label) =>
      match self.bundle(label) {
        Found(b) => [b]
        Ambiguous(list) => list.iter().collect()
        NotFound => []
      }
    None => self.bundles.iter().collect()
  }
  let exact = []
  try BundlePath(path) catch {
    _ => ()
  } noraise {
    p =>
      for b in bundles {
        match b.document(p) {
          Some(d) => exact.push((b, d))
          None => ()
        }
      }
  }
  if !exact.is_empty() {
    return lookup_of(exact)
  }
  let suffix = []
  for b in bundles {
    for d in b.documents() {
      if d.path().ends_with(path) {
        suffix.push((b, d))
      }
    }
  }
  lookup_of(suffix)
}

///|
/// One heading of a document outline.
pub struct Heading {
  level : @mdc.HeadingLevel
  text : String
  slug : String
  line : Int32
} derive(Eq, Debug)

///|
/// A GitHub-style slug: lower case, letters, digits, `-` and `_`, with
/// spaces as `-`.
pub fn slugify(text : String) -> String {
  let out = StringBuilder()
  for c in text.to_lower() {
    if (c >= 'a' && c <= 'z') ||
      (c >= '0' && c <= '9') ||
      c == '-' ||
      c == '_' ||
      c.to_int() > 127 {
      out.write_char(c)
    } else if c == ' ' {
      out.write_char('-')
    }
  }
  out.to_string()
}

///|
/// The headings of the body, with levels, slugs and file lines. Lines in code
/// that start with `#` are not headings. Repeated slugs get `-1`, `-2` and so on.
pub fn Document::outline(self : Document) -> Array[Heading] {
  guard self.markdown is Some(md) else { return [] }
  let out = []
  let seen : Map[String, Int32] = Map([])
  @mdc.walk(md.root(), node => {
    match node {
      Block(view) =>
        match view.kind() {
          Heading(level~) => {
            let text = match view.to_cmark() {
              Heading(h) => plain_text(h.v.inline)
              _ => ""
            }
            let base = slugify(text)
            let slug = match seen.get(base) {
              Some(n) => {
                seen[base] = n + 1
                "\{base}-\{n}"
              }
              None => {
                seen[base] = 1
                base
              }
            }
            out.push({ level, text, slug, line: self.line_of(view.location()), })
            return SkipChildren
          }
          CodeBlock => return SkipChildren
          _ => ()
        }
      _ => ()
    }
    Continue
  })
  out
}

///|
/// One fenced or indented code block.
pub(all) struct CodeBlock {
  language : String?
  code : String
  line : Int32
} derive(Eq, Debug)

///|
/// The code blocks of the body, in order.
pub fn Document::code_blocks(self : Document) -> Array[CodeBlock] {
  guard self.markdown is Some(md) else { return [] }
  let out = []
  @mdc.walk(md.root(), node => {
    match node {
      Block(view) if view.kind() is CodeBlock =>
        match view.to_cmark() {
          CodeBlock(node) => {
            let language = match node.v.info_string {
              Some(info) =>
                match @cmark.CodeBlock::language_of_info_string(info.v) {
                  Some((lang, _)) => Some(lang)
                  None => None
                }
              None => None
            }
            let code = node.v.code.fold(init=[], (acc : Array[String], l) => {
              acc.push(l.v)
              acc
            })
            out.push({
              language,
              code: code.join("\n"),
              line: self.line_of(view.location()),
            })
          }
          _ => ()
        }
      _ => ()
    }
    Continue
  })
  out
}

///|
/// The content under one heading, up to the next heading of the same or a higher level.
pub struct SectionContent {
  heading : Heading
  /// The raw Markdown lines of the section, without the heading line.
  text : String
  code_blocks : ReadOnlyArray[CodeBlock]
} derive(Eq, Debug)

///|
/// The section under the first heading with this text, ignoring letter case.
pub fn Document::section(self : Document, heading : String) -> SectionContent? {
  let outline = self.outline()
  guard outline.search_by(h => h.text.to_lower() == heading.to_lower())
    is Some(i) else {
    return None
  }
  let h = outline[i]
  let mut end_line = 2147483647
  for j in (i + 1).. h.line && number < end_line {
      lines.push(line.text)
    }
  }
  let code_blocks = self
    .code_blocks()
    .iter()
    .filter(c => c.line > h.line && c.line < end_line)
    .collect()
  Some({
    heading: h,
    text: lines.join("\n"),
    code_blocks: ReadOnlyArray::from_array(code_blocks),
  })
}

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

///|
pub extend Filter with @debug.Debug::{to_repr}

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

///|
pub extend ScanHit with @debug.Debug::{to_repr}

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

///|
pub extend Heading with @debug.Debug::{to_repr}

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

///|
pub extend CodeBlock with @debug.Debug::{to_repr}

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

///|
pub extend SectionContent with @debug.Debug::{to_repr}

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

///|
pub extend Facet with @debug.Debug::{to_repr}

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

///|
pub extend ScanField with @debug.Debug::{to_repr}