module Mailmate::CLI::Search

def all_message_dirs

def all_message_dirs
  Dir.glob("#{Mailmate.config.imap_root}/*/**/Messages").select { |p| File.directory?(p) }
end

def build_parser(opts)

def build_parser(opts)
  OptionParser.new do |o|
    o.banner = "Usage: mmsearch [search-string] [fields] [options]"
    o.separator ""
    o.separator "Search MailMate's `.eml` files. Output is CSV with column-aligned padding."
    o.separator ""
    o.separator "POSITIONAL ARGS"
    o.separator "  search-string  Quicksearch expression. Default: 'd 1d'. Pass '' to disable."
    o.separator "  fields         Space-separated columns to show (id is always first)."
    o.separator "                 Default: 'flags date time direction party subject'."
    o.separator "                 Prefix with '+' to add to the defaults: '+tags' = defaults + tags."
    o.separator ""
    o.separator "OPTIONS"
    o.on("--mailbox X", "Mailbox to search (default: all)") { |v| opts[:mailbox] = v }
    o.on("--fields F", "Fields list (alt to 2nd positional)") { |v| opts[:fields] = v }
    o.on("--limit N", Integer, "Stop after N matches") { |n| opts[:limit] = n }
    o.on("--headers-only", "Skip body matching") { opts[:headers_only] = true }
    o.on("--no-header", "Suppress column header row") { opts[:header] = false }
    o.on("--no-align", "Plain CSV (no column padding)") { opts[:align] = false }
    o.on("--sort MODE", %w[asc desc none],
         "Sort rows by date+time: asc (default), desc, none") { |v| opts[:sort] = v.to_sym }
    o.separator ""
    o.separator "SEARCH-STRING SYNTAX"
    o.separator "  Mirrors MailMate's toolbar quicksearch. Specs combine with AND."
    o.separator "  Wrap multi-word terms in \"double quotes\". Prefix operand with ! to negate."
    o.separator ""
    o.separator "    <term>    common headers (from/to/cc/subject) OR body contains <term>"
    o.separator "    f <term>  from contains"
    o.separator "    t <term>  to/cc (recipients) contains"
    o.separator "    c <term>  cc contains"
    o.separator "    s <term>  subject contains"
    o.separator "    a <term>  any address header contains"
    o.separator "    b <term>  body contains (slow — disables prefilter)"
    o.separator "    m <term>  common headers OR body (same as bare term)"
    o.separator "    d <date>  received date: Nd|Nw|Nm|Ny (relative), or Y, Y-M, Y-M-D"
    o.separator "    T <tag>   tag / IMAP keyword contains  (K is a synonym)"
    o.separator ""
    o.separator "  Examples:"
    o.separator "    mmsearch 'f medium d 7d'           from Medium in last 7 days"
    o.separator "    mmsearch 's \"rent due\" !draft'     subject has rent due, no 'draft'"
    o.separator "    mmsearch 'd 2026-05'               received in May 2026"
    o.separator ""
    o.separator "FIELDS (for the fields argument / --fields)"
    o.separator "  id          eml-id (always included as first column)"
    o.separator "  path        full path to the .eml file"
    o.separator "  mailbox     account/mailbox path (no /Messages/<id>.eml suffix)"
    o.separator "  from        From header"
    o.separator "  to          To header"
    o.separator "  cc          Cc header"
    o.separator "  bcc         Bcc header"
    o.separator "  reply-to    Reply-To header"
    o.separator "  subject       Subject header"
    o.separator "  message-id    RFC Message-ID header"
    o.separator "  references    RFC References header (space-joined when multiple)"
    o.separator "  in-reply-to   RFC In-Reply-To header"
    o.separator "  date        received date, YYYY-MM-DD (local time)"
    o.separator "  time        received time, HH:MM (local time)"
    o.separator "  direction   '→' outbound, '←' inbound (column header: 'dir')"
    o.separator "  party       counterparty (recipients if outbound, sender if inbound)"
    o.separator "  flags       archive + read combined, e.g. 'AR', 'PU'"
    o.separator "  read        'R' read or 'U' unread (column header: 'r')"
    o.separator "  archive     'A' archived or 'P' present elsewhere (column header: 'a')"
    o.separator "  tags        user tags (IMAP keywords), comma-joined; system flags (\\… , $…) excluded"
    o.separator "  keywords    raw IMAP keyword list (incl. \\Seen, \\Draft, \\Flagged, \$Forwarded, user tags)"
  end
end

def can_prefilter?(specs)

def can_prefilter?(specs)
  specs.any? do |field, term, negate|
    !negate && HEADER_FIELDS.include?(field) && term.bytesize >= 3 && term.ascii_only?
  end
end

def collect_rows(dirs:, specs:, fields:, smart_evaluator:, smart_literals:, filter_only_tier:, load_tier:, opts:)

def collect_rows(dirs:, specs:, fields:, smart_evaluator:, smart_literals:, filter_only_tier:, load_tier:, opts:)
  rows = []
  catch(:done) do
    dirs.each do |dir|
      Dir.each_child(dir) do |fname|
        next unless fname.end_with?(".eml")
        eml_id = fname.sub(".eml", "")
        path = "#{dir}/#{fname}"
        next unless prefilter_pass?(path, specs, smart_literals)
        if filter_only_tier == :index
          if smart_evaluator
            next unless smart_evaluator.matches?(Mailmate::Message.new(nil, eml_id, path))
          end
          if !specs.empty?
            next unless matches?(nil, eml_id, specs, opts[:headers_only])
          end
        end
        mail = nil
        if load_tier != :index
          begin
            mail = load_message(path, load_tier)
          rescue StandardError => e
            warn "[skip] #{path}: #{e.message}"
            next
          end
        end
        if filter_only_tier != :index
          if !specs.empty?
            next unless matches?(mail, eml_id, specs, opts[:headers_only])
          end
          if smart_evaluator
            next unless smart_evaluator.matches?(Mailmate::Message.new(mail, eml_id, path))
          end
        end
        rows << fields.map { |f| extract(f, eml_id, path, mail) }
        throw :done if opts[:limit] && rows.size >= opts[:limit]
      end
    end
  end
  rows
end

def compose_smart_filters(filters)

def compose_smart_filters(filters)
  return "" if filters.empty?
  return filters.first if filters.size == 1
  "(#{filters.map { |f| "(#{f})" }.join(" and ")})"
end

def csv_quote(cell)

def csv_quote(cell)
  cell = cell.to_s.gsub(/[\r\n]+/, " ")
  if cell.include?(",") || cell.include?("\"")
    "\"#{cell.gsub("\"", "\"\"")}\""
  else
    cell
  end
end

def date_matches?(mail, eml_id, term)

def date_matches?(mail, eml_id, term)
  d = nil
  if eml_id
    s = (Mailmate::IndexReader.for("#date").value_for(eml_id.to_i) rescue nil)
    if s && !s.empty?
      d = (Time.parse(s) rescue nil)
    end
  end
  if d.nil? && mail
    raw = mail.date
    d = raw.respond_to?(:to_time) ? raw.to_time : raw
  end
  return false unless d
  if term =~ /\A(\d+)([dwmy])\z/
    n, u = Regexp.last_match(1).to_i, Regexp.last_match(2)
    cutoff = case u
             when "d" then Date.today - n
             when "w" then Date.today - (n * 7)
             when "m" then Date.today << n
             when "y" then Date.today << (n * 12)
             end
    return d.to_date >= cutoff
  end
  norm = term.tr("/.", "-")
  parts = norm.split("-")
  case parts.size
  when 1 then d.year.to_s == parts[0]
  when 2 then d.year.to_s == parts[0] && d.month == parts[1].to_i
  when 3 then d.to_date == Date.new(parts[0].to_i, parts[1].to_i, parts[2].to_i)
  else false
  end
rescue StandardError
  false
end

def emit_output(rows, fields, opts)

def emit_output(rows, fields, opts)
  header_row = fields.map { |f| HEADER_LABELS[f] || f }
  if opts[:align]
    display_rows = rows.map { |r| r.map { |c| csv_quote(c) } }
    display_rows.unshift(header_row) if opts[:header]
    widths = Array.new(fields.size, 0)
    display_rows.each do |r|
      r.each_with_index { |c, i| widths[i] = c.length if c.length > widths[i] }
    end
    display_rows.each do |r|
      padded = r.each_with_index.map do |c, i|
        i == r.size - 1 ? c : c.ljust(widths[i])
      end
      puts padded.join(",")
    end
  else
    puts CSV.generate_line(header_row) if opts[:header]
    rows.each { |r| puts CSV.generate_line(r) }
  end
end

def extract(field, eml_id, path, mail)

def extract(field, eml_id, path, mail)
  case field
  when "id"         then eml_id
  when "path"       then path
  when "mailbox"    then path.sub("#{Mailmate.config.imap_root}/", "").sub(%r{/Messages/[^/]+\.eml\z}, "")
  when "date"
    t = message_time(eml_id, mail)
    Mailmate.localize(t)&.strftime("%Y-%m-%d")
  when "time"
    t = message_time(eml_id, mail)
    Mailmate.localize(t)&.strftime("%H:%M")
  when "read"
    flags = (Mailmate::IndexReader.for("#flags").flags_for(eml_id.to_i) rescue [])
    flags.include?("\\Seen") ? "R" : "U"
  when "archive"
    path.include?("/Archive.mailbox/") ? "A" : "P"
  when "flags"
    archive = path.include?("/Archive.mailbox/") ? "A" : "P"
    seen    = (Mailmate::IndexReader.for("#flags").flags_for(eml_id.to_i) rescue []).include?("\\Seen")
    "#{archive}#{seen ? 'R' : 'U'}"
  when "tags"
    flags = (Mailmate::IndexReader.for("#flags").flags_for(eml_id.to_i) rescue [])
    flags.reject { |f| f.start_with?("\\", "$") }.join(",")
  when "keywords"
    (Mailmate::IndexReader.for("#flags").flags_for(eml_id.to_i) rescue []).join(",")
  when "from"       then mail ? Array(mail.from).join("; ") : nil
  when "to"         then mail ? Array(mail.to).join("; ") : nil
  when "cc"         then mail ? Array(mail.cc).join("; ") : nil
  when "bcc"        then mail ? Array(mail.bcc).join("; ") : nil
  when "reply-to"   then mail ? Array(mail.reply_to).join("; ") : nil
  when "subject"     then mail&.subject.to_s
  when "message-id"  then mail&.message_id.to_s
  when "references"  then mail ? Array(mail.references).join(" ") : nil
  when "in-reply-to" then mail ? Array(mail.in_reply_to).join(" ") : nil
  when "direction"   then mail ? (outbound?(path, mail) ? "→" : "←") : nil
  when "party"      then mail ? party_for(mail, outbound?(path, mail)) : nil
  end.to_s
end

def field_value(mail, field)

def field_value(mail, field)
  case field
  when :from
    [Array(mail.from), mail[:from]&.value.to_s].flatten.join(" ").downcase
  when :recipients
    [Array(mail.to), Array(mail.cc), mail[:to]&.value.to_s, mail[:cc]&.value.to_s]
      .flatten.join(" ").downcase
  when :cc
    [Array(mail.cc), mail[:cc]&.value.to_s].flatten.join(" ").downcase
  when :subject
    mail.subject.to_s.downcase
  when :address_any
    [mail[:from], mail[:to], mail[:cc], mail[:reply_to], mail[:sender]]
      .compact.map { |h| h.value.to_s }.join(" ").downcase
  end
end

def fields_tier(fields)

def fields_tier(fields)
  ts = fields.map { |f| FIELD_TIERS[f] || :header }.uniq
  return :full   if ts.include?(:full)
  return :header if ts.include?(:header)
  :index
end

def header_block(path)

def header_block(path)
  bytes = +""
  File.open(path, "rb") do |f|
    while (chunk = f.read(4096))
      bytes << chunk
      idx = bytes.index("\r\n\r\n") || bytes.index("\n\n")
      return bytes[0..idx].downcase if idx
      break if bytes.bytesize > 65_536
    end
  end
  bytes.downcase
end

def load_message(path, tier)

def load_message(path, tier)
  case tier
  when :index then nil
  when :header
    bytes = +""
    File.open(path, "rb") do |f|
      while (chunk = f.read(4096))
        bytes << chunk
        idx = bytes.index("\r\n\r\n") || bytes.index("\n\n")
        break if idx
        break if bytes.bytesize > 65_536
      end
    end
    Mail.new(bytes)
  when :full
    Mail.read(path)
  end
end

def matches?(mail, eml_id, specs, headers_only)

def matches?(mail, eml_id, specs, headers_only)
  specs.all? do |field, term, negate|
    hit =
      case field
      when :from, :recipients, :cc, :subject, :address_any
        field_value(mail, field).include?(term)
      when :tag, :keyword
        tag_value(eml_id).include?(term)
      when :body
        headers_only ? false : text_body(mail).include?(term)
      when :message_or_body
        common = %i[from recipients subject].any? { |f| field_value(mail, f).include?(term) }
        common || (!headers_only && text_body(mail).include?(term))
      when :date
        date_matches?(mail, eml_id, term)
      when :any
        %i[from recipients subject].any? { |f| field_value(mail, f).include?(term) }
      end
    negate ? !hit : hit
  end
end

def message_time(eml_id, mail)

(cheap, no .eml read). Falls back to the parsed mail's Date header.
Absolute send time for an eml_id, preferring the MailMate `#date` index
def message_time(eml_id, mail)
  s = (Mailmate::IndexReader.for("#date").value_for(eml_id.to_i) rescue nil)
  if s && !s.empty?
    t = (Time.parse(s) rescue nil)
    return t if t
  end
  raw = mail&.date
  return nil unless raw
  raw.respond_to?(:to_time) ? raw.to_time : raw
rescue StandardError
  nil
end

def outbound?(path, mail)

def outbound?(path, mail)
  return true if path.include?("/Sent Mail.mailbox/") ||
                 path.include?("/Sent Messages.mailbox/") ||
                 path.include?("/Drafts.mailbox/")
  from = Array(mail.from).first.to_s.downcase
  Mailmate::Identity.mine?(from)
end

def parse_search(str)

def parse_search(str)
  tokens = tokenize(str)
  specs = []
  i = 0
  while i < tokens.size
    tok = tokens[i]
    field = MODIFIERS[tok]
    if field && i + 1 < tokens.size
      operand = tokens[i + 1]
      negate = operand.start_with?("!")
      operand = operand[1..] if negate
      specs << [field, operand.downcase, negate]
      i += 2
    else
      negate = tok.start_with?("!")
      operand = negate ? tok[1..] : tok
      # Bare terms default to MailMate's "Common" specifier — common
      # headers OR body — matching the UI quicksearch behavior. Pass
      # --headers-only to skip the body scan when speed matters.
      specs << [:message_or_body, operand.downcase, negate]
      i += 1
    end
  end
  specs
end

def party_for(mail, outbound)

def party_for(mail, outbound)
  if outbound
    others = Mailmate::Identity.reject_mine(Array(mail.to) + Array(mail.cc))
    others = Array(mail.to) if others.empty?
    others.join("; ")
  else
    Array(mail.from).join("; ")
  end
end

def prefilter_pass?(path, specs, smart_literals = [])

def prefilter_pass?(path, specs, smart_literals = [])
  return true if !can_prefilter?(specs) && smart_literals.empty?
  hdr = header_block(path)
  specs.each do |field, term, negate|
    next if negate
    next unless HEADER_FIELDS.include?(field)
    next unless term.bytesize >= 3 && term.ascii_only?
    return false unless hdr.include?(term)
  end
  smart_literals.each do |lit|
    return false unless hdr.include?(lit)
  end
  true
rescue StandardError
  true
end

def resolve_account(name)

def resolve_account(name)
  root = Mailmate.config.imap_root
  return name if File.directory?("#{root}/#{name}")
  encoded = name.gsub("@", "%40")
  candidates = Dir.glob("#{root}/#{encoded}@*").map { |p| File.basename(p) }
  case candidates.size
  when 0 then nil
  when 1 then candidates.first
  else
    warn "Ambiguous account '#{name}': #{candidates.join(", ")}"
    nil
  end
end

def resolve_mailbox(arg)

def resolve_mailbox(arg)
  root = Mailmate.config.imap_root
  return [all_message_dirs, []] if arg == "all"
  if arg.include?("/")
    account, rest = arg.split("/", 2)
    if (encoded = resolve_account(account))
      nested = rest.split("/").map { |s| "#{s}.mailbox" }.join("/")
      cand = "#{root}/#{encoded}/#{nested}/Messages"
      return [[cand], []] if File.directory?(cand)
    end
  end
  if (encoded = resolve_account(arg))
    dirs = Dir.glob("#{root}/#{encoded}/**/Messages").select { |p| File.directory?(p) }
    return [dirs, []]
  end
  matches = Dir.glob("#{root}/*/**/#{arg}.mailbox/Messages").select { |p| File.directory?(p) }
  return [matches, []] unless matches.empty?
  # Fall back: try MailMate's smart-mailbox graph.
  graph = Mailmate::MailboxGraph.load
  if (uuid = graph.by_name[arg]) || graph.by_uuid[arg]
    uuid ||= arg
    res = Mailmate::SourceResolver.new(graph).resolve(uuid)
    return [res[:dirs], res[:filters], graph]
  end
  warn "Mailbox not resolved: '#{arg}'."
  [[], []]
end

def resolve_mailbox_with_graph(arg)

def resolve_mailbox_with_graph(arg)
  result = resolve_mailbox(arg)
  result.size == 2 ? [*result, nil] : result
end

def run(argv)

def run(argv)
  opts = {
    mailbox: "all", limit: nil, headers_only: false,
    header: true, align: true, sort: :asc,
  }
  parser = build_parser(opts)
  parser.parse!(argv)
  search_string = argv[0] || DEFAULT_SEARCH
  fields_arg    = (opts[:fields] || argv[1] || DEFAULT_FIELDS).to_s.strip
  # `+...` means "defaults plus these"; bare list replaces defaults.
  fields_arg    = "#{DEFAULT_FIELDS} #{fields_arg[1..]}" if fields_arg.start_with?("+")
  extra_fields  = fields_arg.split(/\s+/).reject(&:empty?)
  fields = (["id"] + extra_fields).uniq
  imap_root = Mailmate.config.imap_root
  unless File.directory?(imap_root)
    warn "MailMate IMAP root not found: #{imap_root}"
    return 1
  end
  unknown = fields - VALID_FIELDS
  unless unknown.empty?
    warn "Unknown field(s): #{unknown.join(", ")}"
    warn "Valid: #{VALID_FIELDS.join(", ")}"
    return 2
  end
  dirs, smart_filters, smart_graph = resolve_mailbox_with_graph(opts[:mailbox])
  if dirs.empty?
    warn "No mailbox directories resolved."
    return 1
  end
  specs = parse_search(search_string)
  # Compose + parse the smart-mailbox filter exactly once. The same AST
  # feeds the evaluator, the tier classifier, and the literals extractor.
  composed_ast = nil
  composed_str = nil
  smart_evaluator =
    if smart_filters.any?
      composed_str = compose_smart_filters(smart_filters)
      begin
        composed_ast = Mailmate.compile_filter(composed_str)
        var_resolver = smart_graph ? Mailmate::VarResolver.new(smart_graph) : nil
        Mailmate::Evaluator.new(composed_ast, var_resolver: var_resolver)
      rescue Mailmate::Lexer::Error, Mailmate::Parser::Error => e
        warn "Smart-mailbox filter parse error: #{e.message}\n  filter: #{composed_str}"
        return 1
      end
    end
  filter_tier      = composed_ast ? Mailmate::FilterClassifier.tier(composed_ast) : :index
  specs_tier =
    if specs.empty?
      :index
    elsif specs.any? { |field, _, _| (field == :body || field == :message_or_body) && !opts[:headers_only] }
      :full
    elsif specs.all? { |field, _, _| %i[date tag keyword].include?(field) }
      :index
    else
      :header
    end
  fields_tier_     = fields_tier(fields)
  filter_only_tier = Mailmate::FilterClassifier.combine_tiers(filter_tier, specs_tier)
  load_tier        = Mailmate::FilterClassifier.combine_tiers(filter_only_tier, fields_tier_)
  smart_literals = composed_ast ? Mailmate::FilterClassifier.header_literals(composed_ast) : []
  rows = collect_rows(
    dirs: dirs, specs: specs, fields: fields,
    smart_evaluator: smart_evaluator, smart_literals: smart_literals,
    filter_only_tier: filter_only_tier, load_tier: load_tier,
    opts: opts,
  )
  sort_rows!(rows, opts[:sort])
  emit_output(rows, fields, opts)
  0
end

def sort_rows!(rows, mode)

without re-reading any .eml.
is always `id` (forced in `run`), which lets us hit the `#date` index
senders in different timezones still order correctly. The first column
Sorts `rows` in place by the message's absolute send instant (UTC), so
def sort_rows!(rows, mode)
  return rows if mode == :none || rows.size < 2
  reader = Mailmate::IndexReader.for("#date") rescue nil
  epoch = Time.at(0)
  rows.sort_by! do |r|
    s = reader && (reader.value_for(r[0].to_i) rescue nil)
    (s && !s.empty? && (Time.parse(s) rescue nil)) || epoch
  end
  rows.reverse! if mode == :desc
  rows
end

def tag_value(eml_id)

(Thunderbird/Apple) system flags so substring matches only hit user tags.
go through the index, not the parsed mail. Strips `\…` (RFC) and `$…`
as `X-Keywords`/`Keywords` headers in the .eml — so tag matching has to
MailMate stores user tags as IMAP keywords in the `#flags` index — not
def tag_value(eml_id)
  return "" unless eml_id
  flags = (Mailmate::IndexReader.for("#flags").flags_for(eml_id.to_i) rescue [])
  flags.reject { |f| f.start_with?("\\", "$") }.join(" ").downcase
end

def text_body(mail)

def text_body(mail)
  (mail.text_part&.decoded || mail.body.decoded).to_s.force_encoding("UTF-8").scrub.downcase
rescue StandardError
  ""
end

def tokenize(str)

def tokenize(str)
  tokens = []
  i = 0
  while i < str.length
    c = str[i]
    if c == " " || c == "\t"
      i += 1
    elsif c == "\""
      j = str.index("\"", i + 1) || str.length
      tokens << str[(i + 1)...j]
      i = j + 1
    else
      j = i
      j += 1 while j < str.length && str[j] != " "
      tokens << str[i...j]
      i = j
    end
  end
  tokens
end