///|
// Flatten inline presentation only. Destinations, raw HTML and image alt text
// cannot introduce requirement words. Code spans in a sentence remain text.
fn paragraph_text(node : @mdc.Node) -> String {
  let out = StringBuilder()
  @mdc.walk(node, node => {
    if node is Inline(view) {
      match view.kind() {
        Text(text~) => out.write_string(text)
        CodeSpan(code~) => out.write_string(code)
        Break => out.write_string(" ")
        Autolink =>
          if view.to_cmark() is Autolink({ v, .. }) {
            out.write_string(v.link.v)
          }
        ExtMathSpan(tex~) => out.write_string(tex)
        Image | RawHtml => return SkipChildren
        _ => ()
      }
    }
    Continue
  })
  out.to_string()
}

///|
fn is_candidate(text : String) -> Bool {
  for keyword in ["where", "while", "when", "if"] {
    if starts_word(text, keyword) {
      return true
    }
  }
  starts_word(text, "the") &&
  text
  .split(" ")
  .any(word => {
    let word = word.to_owned().to_lower()
    word == "shall" || word == "should" || word == "must"
  })
}

///|
/// Extract one sentence per paragraph, including paragraphs in lists and
/// blockquotes. Wrapped lines and inline formatting form a single sentence.
/// Skip headings, code, HTML, tables, footnotes and frontmatter. Non-EARS prose
/// is ignored; malformed candidates become diagnostics. No reparsing occurs.
pub fn from_mdc(document : @mdc.Document) -> Specification {
  let requirements = []
  let diagnostics = []
  @mdc.walk_cursor(document.root(), cursor => {
    if cursor.node() is Block(view) {
      match view.kind() {
        Paragraph => {
          let text = normalize(paragraph_text(cursor.node()))
          if is_candidate(text) {
            let loc = view.location()
            let span = if loc.is_none() {
              None
            } else {
              Some(SourceSpan::{
                start: document.body_offset() + loc.first_ccode,
                end: document.body_offset() + loc.last_ccode + 1,
                line: document.body_line() + loc.first_line.0 - 1,
                column: loc.first_ccode -
                loc.first_line.1 +
                1 +
                (if document.body_line() == 1 && loc.first_line.0 == 1 {
                  document.body_offset()
                } else {
                  0
                }),
              })
            }
            collect_candidate(
              text,
              Markdown(path=cursor.path(), span~),
              requirements,
              diagnostics,
            )
          }
          return SkipChildren
        }
        Heading(_) | CodeBlock | HtmlBlock | ExtTable | ExtFootnoteDefinition =>
          return SkipChildren
        _ => ()
      }
    }
    Continue
  })
  {
    requirements: ReadOnlyArray::from_array(requirements[:]),
    diagnostics: ReadOnlyArray::from_array(diagnostics[:]),
    document: Some(document),
  }
}

///|
/// Parse Markdown with optional YAML frontmatter, then extract EARS sentences.
/// Invalid frontmatter raises MDC's ParseError. EARS errors are diagnostics.
pub fn parse_markdown(
  source : String,
  strict~ : Bool = true,
  file~ : String = "",
) -> Specification raise @mdc.ParseError {
  from_mdc(@mdc.Document::Document(source, strict~, file~))
}