///|
/// MoonCrawl CLI:礼貌地抓取站点。
///
/// 两种输出模式:
/// - 页面模式(默认):每页一条元数据(URL/状态/深度/标题/链接数)。
/// - 采集模式:给出 `--container` 与 `--fields` 后,按 CSS 选择器从每页
///   的列表容器里提取字段,每个条目输出一条记录。
///
/// 用法:
///   moon run cmd/main -- https://example.com/ --max-pages 20 --depth 1
///   moon run cmd/main -- https://books.toscrape.com/ \
///     --container "article.product_pod" \
///     --fields "title=h3 a@title;price=.price_color;link=h3 a@href" \
///     --format csv

///|
fn is_ws(c : Char) -> Bool {
  c == ' ' || c == '\t' || c == '\n' || c == '\r'
}

///|
fn trim_str(s : String) -> String {
  let cs : Array[Char] = []
  for c in s {
    cs.push(c)
  }
  let n = cs.length()
  let mut a = 0
  let mut b = n
  while a < b && is_ws(cs[a]) {
    a = a + 1
  }
  while b > a && is_ws(cs[b - 1]) {
    b = b - 1
  }
  let buf = StringBuilder()
  let mut i = a
  while i < b {
    buf.write_char(cs[i])
    i = i + 1
  }
  buf.to_string()
}

///|
fn split_by(s : String, sep : Char) -> Array[String] {
  let cs : Array[Char] = []
  for c in s {
    cs.push(c)
  }
  let out : Array[String] = []
  let n = cs.length()
  let buf = StringBuilder()
  let mut i = 0
  while i < n {
    if cs[i] == sep {
      out.push(buf.to_string())
      buf.reset()
    } else {
      buf.write_char(cs[i])
    }
    i = i + 1
  }
  out.push(buf.to_string())
  out
}

///|
fn index_of(s : String, ch : Char) -> Int32? {
  let cs : Array[Char] = []
  for c in s {
    cs.push(c)
  }
  let mut i = 0
  while i < cs.length() {
    if cs[i] == ch {
      return Some(i)
    }
    i = i + 1
  }
  None
}

///|
fn last_index_of(s : String, ch : Char) -> Int32? {
  let cs : Array[Char] = []
  for c in s {
    cs.push(c)
  }
  let mut i = cs.length() - 1
  while i >= 0 {
    if cs[i] == ch {
      return Some(i)
    }
    i = i - 1
  }
  None
}

///|
fn slice_str(s : String, a : Int32, b : Int32) -> String {
  let cs : Array[Char] = []
  for c in s {
    cs.push(c)
  }
  let buf = StringBuilder()
  let mut i = a
  while i < b && i < cs.length() {
    buf.write_char(cs[i])
    i = i + 1
  }
  buf.to_string()
}

///|
/// 解析字段规格:`name=selector` 或 `name=selector@attr`,多项以 `;` 分隔。
/// 形如 `title=h3 a@title;price=.price_color`。
fn parse_field_specs(s : String) -> Array[@mooncrawl.FieldSpec] {
  let out : Array[@mooncrawl.FieldSpec] = []
  for raw in split_by(s, ';') {
    let item = trim_str(raw)
    if item == "" {
      continue
    }
    match index_of(item, '=') {
      Some(eq) => {
        let name = trim_str(slice_str(item, 0, eq))
        let rest = trim_str(slice_str(item, eq + 1, item.length()))
        if name == "" || rest == "" {
          continue
        }
        match last_index_of(rest, '@') {
          Some(at) => {
            let sel = trim_str(slice_str(rest, 0, at))
            let attr = trim_str(slice_str(rest, at + 1, rest.length()))
            if sel != "" && attr != "" {
              out.push(@mooncrawl.FieldSpec::attr(name, sel, attr))
            }
          }
          None => out.push(@mooncrawl.FieldSpec::text(name, rest))
        }
      }
      None => ()
    }
  }
  out
}

///|
fn parse_int_or(s : String, fallback : Int32) -> Int32 {
  let mut v : Int32 = 0
  let mut seen_digit : Bool = false
  for c in s {
    if c >= '0' && c <= '9' {
      v = v * 10 + (c.to_int() - '0'.to_int())
      seen_digit = true
    } else {
      return fallback
    }
  }
  if seen_digit {
    v
  } else {
    fallback
  }
}

///|
fn option_or(
  values : Map[String, Array[String]],
  key : String,
  fallback : String,
) -> String {
  match values.get(key) {
    Some(arr) => if arr.length() > 0 { arr[0] } else { fallback }
    None => fallback
  }
}

///|
/// 把以 `/`、`./`、`../` 开头的相对值展开为绝对 URL,其余值原样返回。
/// 只对「长得像站内相对路径」的值生效,纯文本字段(价格、标题)不受影响。
fn absolutize(base : @mooncrawl.Url, value : String) -> String {
  let cs : Array[Char] = []
  for c in value {
    cs.push(c)
  }
  let n = cs.length()
  if n == 0 {
    return value
  }
  let looks_relative = if cs[0] == '/' {
    // "/x" 与 "//host/x"(scheme 相对)都要按 base 展开
    true
  } else if cs[0] == '.' {
    (n > 1 && cs[1] == '/') || (n > 2 && cs[1] == '.' && cs[2] == '/')
  } else {
    false
  }
  if !looks_relative {
    return value
  }
  match @mooncrawl.resolve_url(base, value) {
    Some(u) => @mooncrawl.normalize_url(u)
    None => value
  }
}

///|
async fn main {
  let matches = @argparse.Command(
    "mooncrawl",
    about="Polite web crawler for MoonBit — page metadata or CSS-selected records as JSONL/CSV.",
    options=[
      OptionArg(
        "max-pages",
        long="max-pages",
        about="maximum number of pages to fetch (default 50)",
      ),
      OptionArg(
        "depth",
        long="depth",
        about="maximum link depth from the seed (default 2)",
      ),
      OptionArg(
        "delay-ms",
        long="delay-ms",
        about="minimum delay between requests to the same origin (default 1000)",
      ),
      OptionArg(
        "concurrency",
        long="concurrency",
        about="parallel requests across origins; same origin stays serial (default 4)",
      ),
      OptionArg(
        "container",
        long="container",
        about="CSS selector for repeating item containers (enables harvesting)",
      ),
      OptionArg(
        "fields",
        long="fields",
        about="field specs: name=selector[@attr];name=selector;...",
      ),
      OptionArg(
        "format",
        long="format",
        about="output format: jsonl or csv (default jsonl)",
      ),
    ],
    positionals=[
      PositionArg(
        "seed",
        about="seed URL (http/https)",
        num_args=@argparse.ValueRange::single(),
      ),
    ],
  ).parse()
  guard matches.values is { "seed": [seed], .. } else {
    fail("seed URL is required")
  }
  let max_pages = parse_int_or(option_or(matches.values, "max-pages", "50"), 50)
  let depth = parse_int_or(option_or(matches.values, "depth", "2"), 2)
  let delay_ms = parse_int_or(
    option_or(matches.values, "delay-ms", "1000"),
    1000,
  )
  let concurrency = parse_int_or(
    option_or(matches.values, "concurrency", "4"),
    4,
  )
  let container = option_or(matches.values, "container", "")
  let fields = parse_field_specs(option_or(matches.values, "fields", ""))
  let format = option_or(matches.values, "format", "jsonl")
  let harvest = container != "" && fields.length() > 0
  let options : @mooncrawl.CrawlOptions = {
    ..@mooncrawl.CrawlOptions::default(),
    max_pages,
    max_depth: depth,
    delay_ms,
    max_concurrency: concurrency,
    keep_body: harvest,
  }
  let is_csv = format == "csv"
  if harvest && is_csv {
    let names : Array[String] = []
    for f in fields {
      names.push(f.name)
    }
    println(@mooncrawl.csv_row(names).trim())
  }
  let stats = @mooncrawl.crawl(seed, options, @mooncrawl.http_fetch, fn(p) {
    if harvest {
      match p.body {
        Some(html) => {
          let base = @mooncrawl.parse_url(p.url)
          for row in @mooncrawl.extract_records(html, container, fields) {
            let cells : Array[(String, String)] = []
            for kv in row {
              let (k, v) = kv
              let resolved = match base {
                Some(b) => absolutize(b, v)
                None => v
              }
              cells.push((k, resolved))
            }
            if is_csv {
              println(@mooncrawl.csv_record(cells).trim())
            } else {
              println(@mooncrawl.json_record(cells))
            }
          }
        }
        None => ()
      }
    } else if is_csv {
      println(@mooncrawl.csv_page(p).trim())
    } else {
      println(@mooncrawl.jsonl_page(p))
    }
  })
  println(
    "# pages_fetched=" +
    stats.pages_fetched.to_string() +
    " ok=" +
    stats.pages_ok.to_string() +
    " failed=" +
    stats.pages_failed.to_string() +
    " robots_skipped=" +
    stats.pages_skipped_robots.to_string() +
    " robots_unreachable=" +
    stats.robots_unreachable.to_string() +
    " links=" +
    stats.links_discovered.to_string(),
  )
}