#!/usr/bin/env ruby
# frozen_string_literal: true

# ==============================================================================
# Unified LaTeX Builder (latex_it / l)
#
# Robust, high-performance LaTeX compilation manager with:
# - Automatic main file detection (.mainfile or directory scan)
# - Isolated junk directory output (`junk/`)
# - Multi-engine support (xelatex [default], lualatex, pdflatex)
# - BibTeX and Biber bibliography generation
# - Single-pass compilation mode (-u)
# - Target PDF update guard mode (--update-if-changed)
# - Detailed warning and error log analysis
# - Clean pre-builds and timing diagnostics
# ==============================================================================

require 'digest'
require 'fileutils'
require 'io/console'
require 'json'
require 'open3'
require 'optparse'
require 'pathname'
require 'shellwords'
require 'tmpdir'
require 'uri'

require_relative 'lib/latex_it/version'
require_relative 'lib/latex_it/color'
require_relative 'lib/latex_it/compatibility'
require_relative 'lib/latex_it/config'
require_relative 'lib/latex_it/utils'
require_relative 'lib/latex_it/help_manual'
require_relative 'lib/latex_it/brace_checker'
require_relative 'lib/latex_it/flattener'
require_relative 'lib/latex_it/meta_extractor'
require_relative 'lib/latex_it/bib_locator'
require_relative 'lib/latex_it/macro_harvester'
require_relative 'lib/latex_it/diagnostic_record'
require_relative 'lib/latex_it/error_catalog'
require_relative 'lib/latex_it/compile_format'
require_relative 'lib/latex_it/presenters'
require_relative 'lib/latex_it/diagnostics'
require_relative 'lib/latex_it/builder'
require_relative 'lib/latex_it/packager'
require_relative 'lib/latex_it/arxiv'
require_relative 'lib/latex_it/bib_extractor'

module LatexCLI
  def self.run(argv = ARGV, prog_name = File.basename($PROGRAM_NAME))
    $stdout.sync = true
    $stderr.sync = true
    at_exit { puts '' unless @interrupted || $stdout.closed? || @suppress_at_exit_newline rescue nil }
    LaTeXUtils.initial_pwd = Dir.pwd

    begin
      return if early_personality_dispatch(argv, prog_name)

      extra_cli_files = extract_extra_cli_files(argv)
      cfg = LaTeXConfig.load_merged_config
      options = build_default_options(cfg, prog_name, extra_cli_files)

      normalize_argv!(argv)
      parser = build_option_parser(options)
      parse_options!(parser, argv, prog_name)
      validate_output_mode!(options, prog_name)
      @suppress_at_exit_newline = true if options[:compile]
      handle_help_and_examples(parser, options, prog_name, argv)

      if options[:config_save]
        LaTeXConfig.save_cli_options!(options)
        exit 0 if argv.empty? && LaTeXUtils.candidate_tex_files('.', options[:exclude_main_tex]).empty?
      end

      configure_environment(options)
      execute_targets(argv, options)
    rescue SignalException => e
      handle_interrupt(options, e)
    end
  end

  def self.handle_interrupt(options = nil, exception = nil)
    @interrupted = true
    LaTeXIndicator.stop(clear: true)
    $stderr.print "\e[0m"
    signo = exception.respond_to?(:signo) ? exception.signo : 2
    exit_code = 128 + (signo || 2)

    if options && options[:compile]
      $stderr.puts "\nBuild interrupted."
    else
      msg = signo == 15 ? '==> Build terminated by signal (SIGTERM).' : '==> Build cancelled by user (Ctrl-C).'
      $stderr.puts "\n#{Rainbow(msg).yellow.bright}"
    end
    exit exit_code
  end

  def self.validate_output_mode!(options, prog_name)
    if options[:compile] && options[:emacs_explicit]
      warn "#{prog_name}: error: --compile and --emacs cannot be used together"
      warn "  Use --compile for GNU standard compiler diagnostics (file:line:col: severity: msg)"
      warn "  Use --emacs for AUCTeX integration (TeX parenthesized file stack & l.<line> anchors)"
      exit 2
    end
    options[:emacs] = false if options[:compile]
  end

  # Every script under tools/ handles this; the binary end users actually run
  # was the only one that answered a mistyped flag with a Ruby backtrace.
  def self.parse_options!(parser, argv, prog_name)
    parser.parse!(argv)
  rescue OptionParser::ParseError => e
    warn "#{prog_name}: #{e.message}"
    warn parser.banner
    warn "Try '#{prog_name} -h' for condensed help, or '#{prog_name} --help-all' for all options."
    exit 2
  end

  def self.early_personality_dispatch(argv, prog_name)
    if prog_name == 'latex_file_in_dir'
      dir = argv[0] || '.'
      puts LaTeXUtils.find_main_latex_file(dir)
      exit 0
    elsif %w[latex_clean clean_latex latex-clean].include?(prog_name)
      dispatch_clean_personality(argv, prog_name)
      exit 0
    end
    false
  end

  def self.dispatch_clean_personality(argv, prog_name)
    if argv.include?('-h') || argv.include?('--help')
      puts "Usage: #{prog_name} [directory...]\nClean all LaTeX auxiliary and junk files in the specified directory (default: current directory)."
      return
    end
    (argv.empty? ? [nil] : argv).each { |d| LaTeXUtils.clean_directory(directory_argument_or_abort(d, prog_name)) }
  end

  # Resolves a CLI directory argument: a directory is itself, a file is its
  # containing directory, and nil means the current directory. Returns nil when
  # the argument names nothing that exists. Without that last case an unchecked
  # File.dirname('typo') yielded '.', so a mistyped argument made -C and
  # latex_clean sweep the current directory instead of reporting the mistake.
  def self.directory_argument(path)
    return '.' if path.nil? || path.to_s.empty?
    return path if File.directory?(path)

    File.file?(path) ? File.dirname(path) : nil
  end

  def self.directory_argument_or_abort(path, prog_name)
    resolved = directory_argument(path)
    return resolved if resolved

    warn "#{prog_name}: no such file or directory: #{path}"
    exit 1
  end

  # Flags that make `-- <files>` mean "extra files to bundle". Everywhere else
  # `--` keeps its POSIX meaning of "end of options".
  PACKAGING_LONG_FLAGS = %w[--zip --zip-flat --verify --arxiv].freeze

  def self.packaging_requested?(argv)
    argv.any? do |arg|
      next true if PACKAGING_LONG_FLAGS.include?(arg)
      next true if arg.start_with?('--zip-name', '--arxiv-name')
      next false unless arg.start_with?('-') && !arg.start_with?('--')

      arg[1..].chars.any? { |c| c == 'z' || c == 'Z' || c == 't' }
    end
  end

  # Claims the files after `--` as bundle payload, but only when a packaging
  # mode is actually active. Unconditionally slicing there meant `l -- odd.tex`
  # left argv empty, so execute_targets auto-detected some other document and
  # compiled the wrong file -- and with no packaging flag the harvested files
  # were then discarded unread, since options[:extra_files] has one consumer.
  def self.extract_extra_cli_files(argv)
    return [] unless (sep_idx = argv.index('--'))

    unless packaging_requested?(argv)
      argv.delete_at(sep_idx)
      return []
    end

    extra = argv[(sep_idx + 1)..].flat_map { |f| Dir.glob(f).empty? ? [f] : Dir.glob(f) }
    argv.slice!(sep_idx..)
    extra
  end

  def self.resolve_symlink_engine(prog_name)
    if %w[ll llua lualatex_l].include?(prog_name)
      'lualatex'
    elsif %w[lp pdflatex_l pdflatex].include?(prog_name)
      'pdflatex'
    end
  end

  def self.build_default_options(cfg, prog_name, extra_cli_files)
    symlink_engine = resolve_symlink_engine(prog_name)
    is_lw = (prog_name == 'lw')
    is_env_free = %w[pdflatex_env_free latex_env_free bibtex_env_free].include?(prog_name)

    {
      # :engine holds only an explicit choice -- a symlink personality or
      # --engine. The configured default is kept separately so that a
      # "% !TEX program =" magic comment can outrank it; folding the two
      # together made cfg['engine'] non-nil on every run and short-circuited
      # the whole detection chain in resolve_engine.
      engine: symlink_engine,
      config_engine: cfg['engine'],
      engine_explicit: !symlink_engine.nil?,
      clean: false, force: false, single_pass: false, show_examples: false,
      show_help: false, show_help_all: false, show_main: false, clean_only: false,
      fast: is_lw || cfg['fast'] == true,
      update_on_diff: cfg['update_on_diff'] == true,
      bib: cfg['bib'],
      passes: (cfg['passes'] || 3).to_i.clamp(1, 3),
      timeout: cfg['timeout'],
      time: cfg['time'] == true,
      lock: cfg.key?('lock') ? (cfg['lock'] == true) : true,
      score: false, color: cfg['color'], theme: cfg['theme'], theme_arg: nil,
      help_style: cfg['help_style'] || (cfg['help_lines'] == true ? 'lines' : 'plain'),
      list_themes: false, show_config: false, no_env: is_env_free, verbose: false,
      trace: cfg['trace'] == true, emacs: cfg['emacs'] == true, werror: cfg['werror'] == true,
      revtex4: cfg['revtex4'].is_a?(Hash) ? cfg['revtex4'] : {},
      alert_overfull_pt: (cfg['alert_overfull_pt'] || 24.0).to_f,
      whatever_overfull_pt: (cfg['whatever_overfull_pt'] || 2.5).to_f,
      suppress_whatevers: cfg.key?('suppress_whatevers') ? (cfg['suppress_whatevers'] == true) : true,
      suppress_warnings: cfg['suppress_warnings'] == true,
      suppress_alerts: cfg['suppress_alerts'] == true,
      all: false, explain: false, deps: false,
      context_lines: cfg['context_lines'] || 0,
      exclude_main_tex: cfg['exclude_main_tex'] || LaTeXUtils::DEFAULT_EXCLUDE_MAIN_PATTERNS,
      exclude_source_tex: cfg['exclude_source_tex'] || LaTeXUtils::DEFAULT_EXCLUDE_SOURCE_PATTERNS,
      bib_dirs: cfg['bib_dirs'] || LaTeXUtils::DEFAULT_BIB_DIRS,
      auto_mirror_subdirs: cfg.key?('auto_mirror_subdirs') ? (cfg['auto_mirror_subdirs'] == true) : true,
      junk_subdirs: cfg['junk_subdirs'] || LaTeXUtils::DEFAULT_JUNK_SUBDIRS,
      junk_dir: nil,
      config_junk_dir: cfg['junk_dir'],
      explicit_junk_dir: false,
      bib_extract: false, bib_extract_name: nil,
      compile: cfg['compile'] == true, llm: cfg['llm'] == true, json: cfg['json'] == true,
      vscode_lw: false,
      raw: cfg['raw'] == true, link: cfg['links'],
      index: cfg['index'] == true, explicit_index: false, config_save: false, config_scope: nil,
      explicit_passes: false, explicit_update_on_diff: false, explicit_time: false,
      explicit_raw: false, explicit_trace: false, explicit_werror: false,
      explicit_emacs: false, explicit_help_style: false, explicit_styles_inject: false
    }.merge(build_zip_options(cfg, extra_cli_files))
     .merge(build_arxiv_options(cfg))
  end

  def self.build_zip_options(cfg, extra_cli_files)
    {
      zip: false, zip_flat: cfg.dig('zip', 'flat') == true, verify: false, zip_name: nil,
      inject_styles: (cfg.dig('zip', 'styles_inject') || cfg.dig('zip', 'inject_styles')) == true,
      fig_sources: cfg.dig('zip', 'fig_sources') || ['.fig', '.ipe', '.svg', '.asy', '.gp', '.gnuplot', '.py', '.R'],
      extra_files: (Array(cfg.dig('zip', 'include')) + extra_cli_files).uniq
    }
  end

  def self.build_arxiv_options(cfg)
    {
      arxiv: false, arxiv_name: nil, meta_only: false,
      arxiv_verify: cfg.dig('arxiv', 'verify') != false,
      arxiv_visual_verify: cfg.dig('arxiv', 'visual_verify') != false,
      biblatex_shield: cfg.dig('arxiv', 'bundle_biblatex') != false,
      comments: cfg.dig('arxiv', 'comments'),
      strip_comments: cfg.dig('arxiv', 'strip_comments') != false,
      strip_host_patterns: cfg.dig('arxiv', 'strip_host_patterns') || LaTeXUtils::DEFAULT_STRIP_HOST_PATTERNS
    }
  end

  def self.normalize_argv!(argv)
    normalize_bib_extract_args!(argv)
    argv.map! do |arg|
      case arg
      when '-cc' then '--compile'
      when '-llm' then '--llm'
      when /\A-([2-9]|\d{2,})\z/ then "--context=#{$1}"
      else arg
      end
    end
  end

  def self.normalize_bib_extract_args!(argv)
    i = 0
    while i < argv.size
      arg = argv[i]
      if arg =~ /^--bib-extract=(.+)$/
        val = Regexp.last_match(1)
        argv[i] = '--bib-extract'
        argv.insert(i + 1, '--bib-name', val)
        i += 2
      elsif (arg == '-B' || arg == '--bib-extract') &&
            i + 1 < argv.size && argv[i + 1].end_with?('.bib')
        val = argv[i + 1]
        argv[i] = '--bib-extract'
        argv[i + 1] = '--bib-name'
        argv.insert(i + 2, val)
        i += 3
      else
        i += 1
      end
    end
  end

  def self.build_option_parser(options)
    OptionParser.new(nil, 28, '  ') do |opts|
      opts.banner = 'Usage: l [options] [document.tex]'
      opts.separator ''
      opts.separator 'Compilation Options:'
      add_compilation_options_pass(opts, options)
      add_compilation_options_target(opts, options)
      add_compilation_options_diag(opts, options)
      add_compilation_options_bundle(opts, options)
      add_compilation_options_env(opts, options)
      opts.separator ''
      opts.separator 'arXiv Preparation Options:'
      add_arxiv_options(opts, options)
      add_general_options(opts, options)
    end
  end

  def self.add_compilation_options_pass(opts, options)
    opts.separator ''
    opts.separator '  Pass Control & Compilation:'
    opts.on('-f', '--force', 'Force an initial compilation run unconditionally (bypasses build cache)') { options[:force] = true }
    opts.on('-u', '--single-pass', 'Execute a single LaTeX pass only (no BibTeX/Biber or convergence passes)') do
      options[:single_pass] = true
      options[:passes] = 1
      options[:bib] = false
    end
    opts.on('-n', '--passes NUM', Integer, 'Maximum number of compilation passes (1-3, default: 3)') do |v|
      options[:passes] = v.clamp(1, 3)
      options[:explicit_passes] = true
    end
    opts.on('-b', '--[no-]bib', 'Explicitly enable or skip bibliography pass (default: auto-detect)') { |v| options[:bib] = v }
    opts.on('-I', '--[no-]index', 'Run makeindex pass when .idx changes (default: false or config)') do |v|
      options[:index] = v
      options[:explicit_index] = true
    end
    opts.on('-i') { raise OptionParser::InvalidOption }
    opts.on('-e', '--engine ENGINE', 'Compiler engine: x (xelatex), l (lualatex), or p (pdflatex)') do |v|
      options[:engine] = LaTeXUtils.normalize_engine(v)
      options[:engine_explicit] = true
    end
    opts.on('--timeout SECONDS', Integer, 'Maximum execution timeout allowed per pass in seconds') { |v| options[:timeout] = v }
    opts.on('-T', '--time', 'Show execution wall-clock time breakdown per compilation pass') do
      options[:time] = true
      options[:explicit_time] = true
    end
  end

  def self.add_compilation_options_target(opts, options)
    opts.separator ''
    opts.separator '  Output Management & Cleaning:'
    opts.on('-c', '--clean', 'Clean temporary build files in junk/ (.aux, .bbl, .log) before compilation') { options[:clean] = true }
    opts.on('-C', '--clean-only', 'Clean auxiliary files in directory and exit without building') { options[:clean_only] = true }
    opts.on('--junk-dir DIR', 'Directory for temporary build artifacts (default: junk or .junk)') do |v|
      options[:junk_dir] = v
      options[:explicit_junk_dir] = true
    end
    opts.on('--update-if-changed', 'Only replace target PDF if extracted text changed (prevents viewer reloads)') do
      options[:update_on_diff] = true
      options[:explicit_update_on_diff] = true
    end
  end

  def self.add_compilation_options_diag(opts, options)
    opts.separator ''
    opts.separator '  Diagnostics & Error Filtering:'
    opts.on('-r', '--raw', 'Print raw compiler stdout/stderr during compilation passes') do
      options[:raw] = true
      options[:explicit_raw] = true
    end
    opts.on('-x', '--explain', 'Display boxed plain-English explanations and fixes for diagnostics on first occurrence') { options[:explain] = true }
    opts.on('-a', '--all', 'Show all diagnostics across all tiers (including Whatevers and Warnings)') do
      options[:all] = true
      options[:suppress_whatevers] = false
      options[:suppress_warnings] = false
      options[:suppress_alerts] = false
    end
    opts.on('--[no-]badges', 'Display visual severity badges/emojis for all diagnostics (default: on)') do |v|
      options[:badges] = v
    end
    opts.on('-W', '--werror', 'Treat compilation warnings as fatal errors and exit with non-zero status') do
      options[:werror] = true
      options[:explicit_werror] = true
    end
    opts.on('-s', '--score', 'Quiet mode: suppress stdout and print only numeric error/alert/warning counts') { options[:score] = true }
    opts.on('-v', '--verbose', 'Verbose output mode (show raw overfull hbox snippet text and resolution steps)') { options[:verbose] = true }
    opts.on('--trace', 'Print exact external subprocess commands, working directory, and environment overrides') do
      options[:trace] = true
      options[:explicit_trace] = true
    end
    opts.on('--alert-hbox PT', Float, 'Overfull hbox threshold in points to classify as Alert (default: 24.0)') { |v| options[:alert_overfull_pt] = v.to_f }
    opts.on('--whatever-pt PT', Float, 'Overfull hbox threshold in points to classify as Whatever (default: 2.5)') { |v| options[:whatever_overfull_pt] = v.to_f }
    opts.on('--context NUM', Integer, 'Show NUM lines of surrounding source context for diagnostics') do |v|
      options[:context_lines] = v.to_i
      options[:explicit_context_lines] = true
    end
  end

  def self.add_compilation_options_bundle(opts, options)
    opts.separator ''
    opts.separator '  Paper Bundling, Bibliography & Portability:'
    opts.on('-z', '--zip', 'Create a self-contained portable zip archive of paper, styles, and figures') { options[:zip] = true }
    opts.on('-Z', '--zip-flat', 'Create a self-contained portable zip archive with inlined/flattened .tex') do
      options[:zip] = true
      options[:zip_flat] = true
    end
    opts.on('-t', '--verify', 'Verify self-contained portability by testing compilation in /tmp sandbox') { options[:verify] = true; options[:zip] = true }
    opts.on('--zip-name NAME', 'Specify custom output filename for generated zip archive') { |v| options[:zip] = true; options[:zip_name] = v }
    opts.on('-B', '--bib-extract', 'Extract cited bibliography entries into local .bib file (default: <doc>.bib)') { options[:bib_extract] = true }
    opts.on('--bib-name NAME', 'Specify custom output filename for extracted bibliography') do |v|
      options[:bib_extract] = true
      options[:bib_extract_name] = v
    end
    opts.on('--[no-]styles-inject', 'Enable or disable isolating harvested styles into styles/ and injecting \input@path') do |v|
      options[:inject_styles] = v
      options[:explicit_styles_inject] = true
    end
  end

  def self.add_compilation_options_env(opts, options)
    opts.separator ''
    opts.separator '  Environment, Theming & Configuration:'
    opts.on('--[no-]color', 'Enable or disable colored terminal output (default: auto)') { |v| options[:color] = v }
    opts.on('--[no-]link', 'Enable or disable clickable terminal hyperlinks (default: auto-detect)') do |v|
      options[:link] = v
    end
    opts.on('--theme THEME', 'Set diagnostic color theme (e.g. blush, catppuccin, or "+1" to cycle)') { |v| options[:theme_arg] = v }
    opts.on('--theme-list', 'List available diagnostic color themes with previews and exit') { options[:list_themes] = true }
    opts.on('--[no-]lock', 'Enable or disable lockfile concurrency protection (default: enabled)') { |v| options[:lock] = v }
    opts.on('--no-env', 'Reset environment variables used by LaTeX/BibTeX/Biber') { options[:no_env] = true }
    opts.on('--emacs', 'Format warnings/errors for Emacs AUCTeX integration (suppress line prefixes)') do
      options[:emacs] = true
      options[:emacs_explicit] = true
      options[:explicit_emacs] = true
    end
    sw = OptionParser::Switch::NoArgument.new(
      nil,
      proc { options[:compile] = true },
      ['-cc'],
      ['--compile'],
      nil,
      ['Format diagnostics in GNU standard compiler format (file:line:col: severity: message)']
    )
    opts.top.append(sw, [], ['compile'])
    opts.on('--llm', '--agent', 'Format diagnostics for LLMs and AI agents (plaintext, folded warnings, silent success)') do
      options[:llm] = true
      options[:compile] = true
      options[:color] = false
      options[:link] = false
    end
    opts.on('--json', 'Output structured compilation and diagnostic results as JSON') do
      options[:json] = true
      options[:compile] = true
      options[:color] = false
      options[:link] = false
    end
    opts.on('--config-init', 'Create a local .l.jsonc configuration template in the current directory and exit') do
      LaTeXConfig.create_local_template!('.')
      exit 0
    end
    opts.on('--config-show', 'Show active configuration sources and resolved settings and exit') { options[:show_config] = true }
    opts.on('--config-save', 'Save specified CLI option choices to configuration file (default: local .l.jsonc)') do
      options[:config_save] = true
    end
    opts.on('--global', 'Target global configuration (~/.config/latex_it/config.jsonc) for config operations') do
      options[:config_scope] = :global
    end
    opts.on('--local', 'Target local configuration (./.l.jsonc) for config operations (default)') do
      options[:config_scope] = :local
    end
    opts.on('--vscode-init', 'Create VS Code tasks.json and settings.json in .vscode/ and exit') do
      LaTeXConfig.create_vscode_template!('.')
      exit 0
    end
    opts.on('--vscode-lw', 'Format diagnostics for VS Code LaTeX Workshop extension') do
      options[:compile] = true
      options[:vscode_lw] = true
      options[:color] = false
    end
    opts.on('--gitignore-init', 'Create or add standard LaTeX and junk/ ignore rules to .gitignore and exit') do
      LaTeXConfig.init_gitignore!('.')
      exit 0
    end
  end

  def self.add_arxiv_options(opts, options)
    opts.on('--arxiv', 'Prepare sanitized, flattened, submission-ready arXiv zip package') { options[:arxiv] = true }
    opts.on('--arxiv-name NAME', 'Specify custom output name for arXiv zip package') { |v| options[:arxiv] = true; options[:arxiv_name] = v }
    opts.on('--meta', 'Extract and display sanitized paper metadata and write arxiv_<file>_meta.txt') { options[:meta_only] = true }
    opts.on('--[no-]arxiv-verify', 'Enable/disable sandbox compile plus exact PDF text verification (default: true)') { |v| options[:arxiv_verify] = v }
    opts.on('--[no-]arxiv-visual-verify', 'Enable/disable rendered PDF page verification (default: true; requires pdftoppm)') { |v| options[:arxiv_visual_verify] = v }
    opts.on('--[no-]biblatex-shield', 'Enable or disable bundling local biblatex distribution files (default: true)') { |v| options[:biblatex_shield] = v }
  end

  def self.add_general_options(opts, options)
    opts.separator ''
    opts.separator 'General Options:'
    opts.on('-m', '--main', 'Print the detected main LaTeX root file and exit without compiling') { options[:show_main] = true }
    opts.on('-M', '--deps', 'Print Makefile dependency rule for the document and exit') { options[:deps] = true }
    opts.on('-V', '--version', 'Show version and exit') do
      puts "l #{LatexIt::VERSION}"
      exit 0
    end
    opts.on('-E', '--examples', 'Show detailed usage examples and common workflows') { options[:show_examples] = true }
    opts.on('-h', '--help', 'Show condensed help summary (see -H for all options)') { options[:show_help] = true }
    opts.on('-H', '--help-all', 'Show comprehensive manual of all options with detailed descriptions and examples') { options[:show_help_all] = true }
    opts.on('--help-style STYLE', %w[plain lines], 'Help display style: plain (default) or lines (subdued dividers)') { |v| options[:help_style] = v }
  end

  def self.condensed_help(_prog_name = 'l')
    [
      'Usage: l [options] [document.tex]',
      '',
      'Common Options:',
      '  -f, --force          Force compilation run (bypasses build cache)',
      '  -u, --single-pass    Single pass only (fast draft, skip bib/extra passes)',
      '  -r, --raw            Print raw compiler output (debug mode)',
      '  -c, --clean          Clean temporary build files (junk/, .aux, .bbl)',
      '  -x, --explain        Display plain-English explanations for diagnostics',
      '  -B, --bib-extract    Extract cited bib entries into local .bib file',
      '  -e, --engine ENGINE  Compiler engine: x (xelatex), l (lualatex), p (pdflatex)',
      '',
      'Help & Discovery:',
      '  -H, --help-all       Show all available options with full descriptions',
      '  -E, --examples       Show common usage examples and workflows',
      '  -h, --help           Show condensed help summary (this message)'
    ].join("\n")
  end

  def self.terminal_columns
    cols = ENV['COLUMNS']&.to_i
    cols = (cols && cols > 0) ? cols : (IO.console&.winsize&.last rescue nil)
    cols = 80 if cols.nil? || cols <= 0
    cols.clamp(40, 120)
  end

  def self.wrap_words(words, width)
    lines = []
    curr = []
    len = 0
    words.each do |w|
      if curr.empty?
        curr << w
        len = w.length
      elsif len + 1 + w.length <= width
        curr << w
        len += 1 + w.length
      else
        lines << curr.join(' ')
        curr = [w]
        len = w.length
      end
    end
    lines << curr.join(' ') unless curr.empty?
    lines
  end

  def self.wrap_help_line(line, width)
    return [line] if line.length <= width

    if (m = line.match(/^(\s{2,}\S.*?\s{2,})(\S.*)$/))
      leader = m[1]
      desc = m[2]
      avail = width - leader.length
      if avail >= 16
        desc_lines = wrap_words(desc.split, avail)
        indent = ' ' * leader.length
        return [leader + desc_lines[0]] + desc_lines[1..].map { |l| indent + l }
      end
    end

    indent = line[/^\s*/] || ''
    avail = [width - indent.length, 20].max
    wrap_words(line.strip.split, avail).map { |l| indent + l }
  end

  def self.colorize_flags(str)
    str.gsub(/\b[A-Z]{2,}\b/) { |arg| Rainbow(arg).yellow }
       .gsub(/(-[a-zA-Z0-9]+|--(?:\[no-\])?[a-zA-Z0-9\-_]+)/) { |flag| Rainbow(flag).green.bold }
  end

  def self.colorize_help_line(line)
    return line unless Rainbow.enabled

    if (m = line.match(/^(\s*)(Usage:)(\s+)(\S+)(.*)$/))
      lead, usage, sp, prog, rest = m[1], m[2], m[3], m[4], m[5]
      colored_rest = rest.gsub(/(\[[^\]]+\])/) { |b| Rainbow(b).cyan }
      "#{lead}#{Rainbow(usage).cyan.bold}#{sp}#{Rainbow(prog).bold}#{colored_rest}"
    elsif (m = line.match(/^(\s*)([A-Za-z0-9\s&,\/\-\.]+:)$/))
      "#{m[1]}#{Rainbow(m[2]).cyan.bold}"
    elsif (m = line.match(/^(\s{2,})((?:-[a-zA-Z0-9]+,?\s*|--(?:\[no-\])?[a-zA-Z0-9\-_]+(?:\s+[A-Z]+)?(?:,\s*)?)+)( {2,})(\S.*)$/))
      lead, flags, sp, desc = m[1], m[2], m[3], m[4]
      "#{lead}#{colorize_flags(flags)}#{sp}#{desc}"
    elsif (m = line.match(/^(\s{2,})(-[a-zA-Z0-9\-_].*)$/))
      lead, flags = m[1], m[2]
      "#{lead}#{colorize_flags(flags)}"
    elsif (m = line.match(/(run:\s+)(\S+\s+-E)/))
      line.gsub(m[2], Rainbow(m[2]).bold.cyan)
    else
      line
    end
  end

  def self.divider_character
    (ENV['TERM'] == 'dumb' || ENV['LC_ALL'] == 'C' || ENV['LANG'] == 'C') ? '-' : '─'
  end

  def self.build_help_divider(width)
    char = divider_character
    raw = '  ' + (char * [width - 4, 10].max)
    Rainbow.enabled ? Rainbow(raw).faint.to_s : raw
  end

  def self.insert_help_dividers(lines, width)
    divider = build_help_divider(width)
    result = []
    in_group = false
    seen_option = false

    lines.each do |line|
      plain = line.gsub(/\e\[[0-9;]*m/, '')
      if plain =~ /^(\s*)([A-Za-z0-9\s&,\/\-\.]+:)$/
        in_group = true
        seen_option = false
        result << line
      elsif plain =~ /^[A-Z]/ && !plain.start_with?('  ')
        in_group = false
        seen_option = false
        result << line
      elsif in_group && plain =~ /^\s{2,}(-[a-zA-Z0-9]+|--(?:\[no-\])?[a-zA-Z0-9\-_]+)/
        result << divider if seen_option
        seen_option = true
        result << line
      else
        result << line
      end
    end
    result
  end

  def self.wrap_help(text, width = terminal_columns, help_style = 'plain')
    wrapped_lines = text.to_s.lines.map(&:chomp)
                        .flat_map { |line| wrap_help_line(line, width) }
                        .map { |line| colorize_help_line(line) }

    lines = (help_style == 'lines') ? insert_help_dividers(wrapped_lines, width) : wrapped_lines
    lines.join("\n")
  end

  def self.handle_help_and_examples(parser, options, prog_name, argv = [])
    options[:color] = determine_color_enabled(options) if options[:color].nil?
    Rainbow.enabled = options[:color]

    if options[:list_themes]
      base_theme = ENV['LATEX_IT_THEME'] || ENV['L_THEME'] || ENV['COLOR_THEME'] ||
                   ENV['BASE16_THEME'] || options[:theme] || LatexColor.default_theme
      LatexColor.active_theme = base_theme
      puts LatexColor.format_theme_list
      exit 0
    end

    if options[:show_config]
      puts LaTeXConfig.format_active_config('.', scope: options[:config_scope])
      exit 0
    end

    # Help is checked first so that it wins over every action, destructive ones
    # included: `l -C -h` used to clean the directory and exit 0 without ever
    # printing the usage the user asked for.
    print_help_or_examples(parser, options, prog_name)

    if options[:show_main]
      puts LaTeXUtils.find_main_latex_file(directory_argument_or_abort(argv[0], prog_name), options[:exclude_main_tex])
      exit 0
    elsif options[:clean_only]
      LaTeXUtils.clean_directory(directory_argument_or_abort(argv[0], prog_name))
      exit 0
    end
  end

  def self.help_output_text(parser, options, prog_name)
    manual = options[:show_help_all] ? LaTeXHelpManual.render(prog_name) : condensed_help(prog_name)
    if (options[:show_help_all] || options[:show_help]) && options[:show_examples]
      "#{manual}\n\n#{LaTeXUtils.detailed_examples}"
    elsif options[:show_help_all] || options[:show_help]
      manual
    elsif options[:show_examples]
      LaTeXUtils.detailed_examples(parser.banner)
    end
  end

  def self.print_help_or_examples(parser, options, prog_name)
    output = help_output_text(parser, options, prog_name)
    return unless output

    puts wrap_help(output, terminal_columns, options[:help_style])
    exit 0
  end

  def self.configure_environment(options)
    options[:color] = determine_color_enabled(options) if options[:color].nil?
    Rainbow.enabled = options[:color]
    options[:link] = determine_links_enabled(options) if options[:link].nil?

    apply_theme_configuration(options)
    LaTeXUtils.reset_latex_environment! if options[:no_env]
  end

  def self.determine_color_enabled(options)
    return options[:color] unless options[:color].nil?
    return false if options[:emacs]
    return false if ENV['NO_COLOR'] && !ENV['NO_COLOR'].empty?

    term = ENV['TERM'].to_s.strip
    return false if term == 'dumb' || term == 'unknown' || term.empty?
    return false if ENV['INSIDE_EMACS'] && (ENV['INSIDE_EMACS'].include?('compile') || ENV['INSIDE_EMACS'].include?('comint'))
    return false unless $stdout.tty?

    true
  end

  def self.determine_links_enabled(options)
    return options[:link] unless options[:link].nil?
    return false if options[:emacs] || ENV['INSIDE_EMACS']
    return false if ENV['TERM'] == 'dumb' || ENV['TERM'] == 'unknown'
    return false if ENV['NO_COLOR'] && !ENV['NO_COLOR'].empty?
    return false unless $stdout.tty? && $stderr.tty?

    osc8_capable_terminal?
  end

  def self.osc8_capable_terminal?
    return true if ENV['KITTY_WINDOW_ID'] || ENV['KITTY_PID'] || ENV['TERM'] == 'xterm-kitty'
    return true if ENV['TERMINFO'].to_s.include?('kitty') || ENV['WT_SESSION']
    return true if %w[iTerm.app vscode WezTerm ghostty foot warp].include?(ENV['TERM_PROGRAM'])

    ENV['VTE_VERSION'].to_i >= 5000
  end

  def self.apply_theme_configuration(options)
    base_theme = ENV['LATEX_IT_THEME'] || ENV['L_THEME'] || ENV['COLOR_THEME'] ||
                 ENV['BASE16_THEME'] || options[:theme] || LatexColor.default_theme

    if options[:theme_arg] =~ /^[+-]\d+$/
      step = options[:theme_arg].to_i
      new_theme = LatexColor.cycle_theme(base_theme, step)
      LaTeXConfig.save_global_theme!(new_theme)
      LatexColor.active_theme = new_theme
      puts Rainbow(" -- Cycled theme to: #{new_theme} (#{LatexColor.theme_description(new_theme)})").cyan unless options[:score]
    elsif options[:theme_arg]
      LatexColor.active_theme = options[:theme_arg]
    else
      LatexColor.active_theme = base_theme
    end
  end

  def self.execute_targets(argv, options)
    targets = argv.empty? ? [LaTeXUtils.find_main_latex_file('.', options[:exclude_main_tex])] : argv
    targets.each do |target|
      if options[:meta_only]
        process_meta_target(target, options)
      elsif options[:arxiv]
        process_arxiv_target(target, options)
      elsif options[:bib_extract]
        process_bib_extract_target(target, options)
      else
        process_standard_target(target, options)
      end
    end
  end

  def self.process_bib_extract_target(target, options)
    builder = LatexBuilder.new(target, options)
    exit 1 unless LaTeXBibExtractor.extract!(builder, options[:bib_extract_name])
  end

  # Every other target handler reaches a Dir.chdir into the document's
  # directory (builder.rb, packager.rb, arxiv.rb all do it); this one did not,
  # so `l --meta sub/paper.tex` probed junk/paper.fls and junk/paper.log in the
  # *caller's* directory -- silently returning default page and figure counts,
  # or worse, counts from an unrelated document that happened to be there --
  # and wrote arxiv_paper_meta.txt next to the caller instead of the paper.
  def self.process_meta_target(target, options)
    Dir.chdir(File.expand_path(File.dirname(target))) do
      write_meta_for(File.basename(target), options)
    end
  end

  def self.write_meta_for(target, options)
    bname = File.basename(target, '.tex')
    pdf_path = File.file?("#{bname}.pdf") ? "#{bname}.pdf" : "junk/#{bname}.pdf"
    fls_path = "junk/#{bname}.fls"
    log_path = "junk/#{bname}.log"

    meta = LaTeXMetaExtractor.extract(target, '.', pdf_path, fls_path, log_path, options[:comments])
    meta_txt = LaTeXMetaExtractor.format_meta_txt(meta)
    meta_filename = "arxiv_#{bname}_meta.txt"
    LaTeXMetaExtractor.write_meta_file(meta_filename, meta_txt)

    puts Rainbow("\n========================= Paper Metadata =========================").cyan.bright
    puts meta_txt
    puts Rainbow('==================================================================').cyan.bright
    puts Rainbow("==> Wrote paper metadata to: #{meta_filename}\n").green.bright
  end

  # A submission package must never be assembled from a cached build. All three
  # staging scanners read junk/<base>.fls and the reference PDF is taken from
  # junk/ as well, so an up-to-date cache silently omits any figure or style
  # added since the last real compile and compares the rebuild against a
  # possibly days-old reference -- which \today alone is enough to make differ.
  def self.arxiv_build_options(options)
    options.merge(force: true)
  end

  def self.process_arxiv_target(target, options)
    builder = LatexBuilder.new(target, arxiv_build_options(options))
    exit 1 unless LatexArxivPackager.new(builder).package!
  end

  def self.process_standard_target(target, options)
    builder = LatexBuilder.new(target, options)
    exit 1 unless builder.run!

    if options[:zip] || options[:verify]
      packager = LatexPackager.new(builder)
      exit 1 unless packager.package!
    end
  end
end

if __FILE__ == $PROGRAM_NAME || (File.exist?($PROGRAM_NAME) && File.identical?(__FILE__, $PROGRAM_NAME))
  LatexCLI.run
end
