#!/usr/bin/env python3
# -*- coding: utf-8 -*-

'''
Oligopool Calculator command-line interface.

This module provides the `oligopool` and `op` entry points (see `pyproject.toml`).

Behavior notes:
  - `op` prints a compact command menu.
  - `op COMMAND` prints command-specific options (argparse errors show help).
  - `op complete` prints/installs argcomplete setup (banner-free; safe for `eval`/sourcing).
  - Banners are suppressed when `--stats-json` is used so stdout can be parsed as JSON.

Implementation notes:
  - `argcomplete.autocomplete(parser)` must run before any output is printed.
  - Exit codes: 0 (success), 1 (runtime error / failed stats), 404 (argparse error).
'''

import ast
import argparse
import datetime as dt
import difflib
import importlib
import json
import os
from pathlib import Path
import subprocess
import re
import sys
import textwrap

import argcomplete

from . import __version__
from .descriptions import CLI_DESCRIPTIONS as _CLI_DESC
from .base.config_loader import (
    load_config,
    get_command_config,
    get_pipeline_steps,
    get_pipeline_name,
    validate_config_structure,
    convert_config_keys_to_args,
    is_parallel_pipeline,
    parse_parallel_steps,
    build_execution_levels,
    validate_parallel_pipeline,
)


# Printed by `_print_header()`.
BANNER_TEXT = f'oligopool v{__version__}\nby ah'

MAIN_MENU_DESCRIPTION = (
    'Oligopool Calculator is a suite of algorithms for\n'
    'automated design and analysis of oligo pool libraries.'
)

_DOC_SECTION_HEADERS = {
    'Required Parameters:',
    'Optional Parameters:',
    'Returns:',
    'Notes:',
}

COMMAND_TO_API = {
    'background': ('background', 'background'),
    'barcode': ('barcode', 'barcode'),
    'primer': ('primer', 'primer'),
    'motif': ('motif', 'motif'),
    'spacer': ('spacer', 'spacer'),
    'split': ('split', 'split'),
    'pad': ('pad', 'pad'),
    'merge': ('merge', 'merge'),
    'revcomp': ('revcomp', 'revcomp'),
    'join': ('join', 'join'),
    'verify': ('verify', 'verify'),
    'lenstat': ('lenstat', 'lenstat'),
    'inspect': ('inspect', 'inspect'),
    'final': ('final', 'final'),
    'compress': ('compress', 'compress'),
    'expand': ('expand', 'expand'),
    'index': ('index', 'index'),
    'pack': ('pack', 'pack'),
    'acount': ('acount', 'acount'),
    'xcount': ('xcount', 'xcount'),
}

_MODULE_AST_CACHE: dict[str, ast.AST] = {}
_FUNC_DOC_CACHE: dict[tuple[str, str], str] = {}
_ANSI_RE = re.compile(r'\x1B\[[0-?]*[ -/]*[@-~]')

# In argcomplete mode, the CLI is executed on every tab press; avoid any work
# that is irrelevant to completion quality (e.g., docstring parsing for epilogs).
_ARGCOMPLETE_MODE = bool(
    os.environ.get('_ARGCOMPLETE')
    or ('COMP_LINE' in os.environ and 'COMP_POINT' in os.environ)
)


def _strip_ansi(text):
    return _ANSI_RE.sub('', text)

def _load_api_func(command: str):
    module_name, func_name = COMMAND_TO_API[command]
    module = importlib.import_module(f'.{module_name}', package=__package__)
    return getattr(module, func_name)

def _get_cached_module_ast(module_name: str) -> ast.AST:
    tree = _MODULE_AST_CACHE.get(module_name)
    if tree is not None:
        return tree
    module_path = Path(__file__).resolve().parent / f'{module_name}.py'
    source = module_path.read_text(encoding='utf-8')
    tree = ast.parse(source)
    _MODULE_AST_CACHE[module_name] = tree
    return tree

def _get_func_docstring(module_name: str, func_name: str) -> str:
    cached = _FUNC_DOC_CACHE.get((module_name, func_name))
    if cached is not None:
        return cached

    tree = _get_cached_module_ast(module_name)
    doc = ''
    for node in tree.body:
        if isinstance(node, ast.FunctionDef) and node.name == func_name:
            doc = ast.get_docstring(node, clean=True) or ''
            break
    _FUNC_DOC_CACHE[(module_name, func_name)] = doc
    return doc

def _extract_doc_notes(doc: str):
    if not doc:
        return []
    lines = doc.splitlines()

    start_idx = None
    for i, line in enumerate(lines):
        if line.strip() == 'Notes:':
            start_idx = i + 1
            break
    if start_idx is None:
        return []

    section_lines = []
    for line in lines[start_idx:]:
        if line.strip() in _DOC_SECTION_HEADERS:
            break
        section_lines.append(line.rstrip())

    bullets = []
    current = None
    for raw in section_lines:
        text = raw.strip()
        if not text:
            continue

        if text.startswith('- '):
            if current:
                bullets.append(current.strip())
            current = text
            continue

        if text.startswith('* '):
            sub = text[2:].strip()
            if current:
                current += f' {sub}'
            else:
                current = f'- {sub}'
            continue

        if current:
            current += f' {text}'
        else:
            current = text

    if current:
        bullets.append(current.strip())

    normalized = []
    for bullet in bullets:
        bullet = bullet.strip()
        # Some docstrings contain nested bullets like "- - foo"; strip all leading markers.
        bullet = re.sub(r'^(?:[*-]\s*)+', '', bullet).strip()
        bullet = re.sub(r'\s+', ' ', bullet).strip()
        bullet = re.sub(
            r'`([a-z][a-z0-9_]*)=([^`]+)`',
            lambda m: f"--{m.group(1).replace('_', '-')} {m.group(2).strip()}",
            bullet,
        )
        bullet = re.sub(
            r'`([a-z][a-z0-9_]*)`',
            lambda m: (
                f'--{m.group(1).replace("_", "-")}'
                if '_' in m.group(1)
                else m.group(0)
            ),
            bullet,
        )
        normalized.append(f'- {bullet}')
    return normalized

def _notes_epilog(command: str):
    if _ARGCOMPLETE_MODE:
        return None
    module_name, func_name = COMMAND_TO_API[command]
    notes = _extract_doc_notes(_get_func_docstring(module_name, func_name))
    if not notes:
        return None
    formatted = ['Command Notes:']
    for bullet in notes:
        text = bullet.strip()
        if text.startswith('- '):
            text = text[2:].strip()
        wrapped = textwrap.fill(
            text,
            width=120,
            initial_indent='  • ',
            subsequent_indent='      ',
        )
        formatted.append(wrapped)
    # Prefix with ">>" so OligopoolFormatter preserves explicit newlines.
    return '>>' + '\n'.join(formatted)


CLI_MANUAL = '''
Oligopool CLI Manual

Usage:
  oligopool COMMAND --argument=<value> ...
  op COMMAND --argument=<value> ...
  oligopool manual [TOPIC]
  op manual [TOPIC]

Examples:
  op manual barcode
  op manual primer
  op manual topic
  op manual topics

Notes:
  - Topics include all CLI commands plus a few meta topics (run: op manual topics or op manual topic).
  - Design/transform commands require --output-file in CLI mode.
  - Run "oligopool COMMAND" to see command-specific options.
  - Use "oligopool manual topics" (or "oligopool manual topic") to list manual topics.
  - Use "oligopool cite" to print citation information.
  - Use "op complete --print-instructions" to enable tab-completion.
'''

CLI_PIPELINE_MANUAL = '''
Pipeline Execution from YAML Config

The pipeline command executes multi-step workflows from a single YAML config file.
Supports sequential, parallel (DAG), and mixed string+dict step lists.

Usage:
  op pipeline --config <config.yaml>
  op pipeline --config <config.yaml> --dry-run

Arguments:
  --config       Path to YAML pipeline config file (required)
  --dry-run      Validate config without executing steps
  --quiet        Suppress verbose output

Sequential Pipeline Format:
  pipeline:
    name: "My Pipeline"
    steps:
      - barcode
      - spacer
      - final

  barcode:
    input_data: "input.csv"
    output_file: "step1"
    # ... parameters

Parallel Pipeline Format (DAG):
  pipeline:
    name: "Parallel Pipeline"
    steps:
      - name: fwd_primer         # Step identifier
        command: primer          # Command to run
      - name: rev_primer
        command: primer          # Runs in parallel with fwd_primer
      - name: add_barcode
        command: barcode
        after: [fwd_primer]      # Depends on fwd_primer
      - name: finalize
        command: final
        after: [add_barcode, rev_primer]  # Waits for both

Mixed Step List (also valid):
  pipeline:
    name: "Mixed Pipeline"
    steps:
      - barcode
      - name: add_spacer
        command: spacer
        after: [barcode]

  fwd_primer:                    # Config section matches step name
    input_data: "variants.csv"
    output_file: "fwd"
    primer_type: forward
    # ...

  rev_primer:
    input_data: "variants.csv"
    output_file: "rev"
    primer_type: reverse
    # ...

Step Fields:
  name      Step identifier (required for dict-style steps)
  command   Oligopool command to run (defaults to name)
  after     List of step names to wait for (optional)
  config    Config section name (defaults to name)

Execution:
  - Steps with no 'after' dependencies run first (level 1)
  - Steps at the same level execute in parallel
  - Subsequent levels wait for their dependencies
  - --dry-run shows execution levels and parallelism

Notes:
  - Mixed step lists are valid: if any step is dict-style, DAG parsing is used.
  - String steps in DAG parsing are normalized as name=command with after=[]
  - Use snake_case for parameter names (e.g., barcode_length)
  - You can use explicit paths OR basename chaining for pipeline inputs
  - Basename chaining resolves prior declared outputs (e.g., input_data: step1)
  - Explicit existing paths are always honored as-is
  - Ambiguous basename aliases are config errors (rename colliding outputs)
  - Output files are auto-suffixed (e.g., .oligopool.barcode.csv)
  - Suffixes are append-if-missing:
      output_file: my_library -> my_library.oligopool.<module>.csv
      output_file: my_library.csv -> my_library.csv.oligopool.<module>.csv
      output_file: my_library.oligopool.<module>.csv -> unchanged
  - Cycles in dependencies are detected and rejected

Single Command Config:
  You can also use --config with individual commands:
    op barcode --config barcode.yaml
    op barcode --config barcode.yaml --barcode-length 20  # CLI overrides

See also:
  - docs/docs.md#config-files for full documentation
  - op manual <command> for command parameters
'''

CLI_COMPLETE_MANUAL = '''
Enable tab-completion (argcomplete)

One-time install (recommended):
  auto: op complete --install
  bash: op complete --install bash
   zsh: op complete --install zsh
  fish: op complete --install fish

Print a snippet instead:
  bash: eval "$(op complete --shell bash)"
   zsh: eval "$(op complete --shell zsh)"
  fish: op complete --shell fish | source
'''

CITATION_TEXT = '''
If you use Oligopool Calculator in your research, please cite:

Hossain A, Cetnar DP, LaFleur TL, McLellan JR, Salis HM.
Automated Design of Oligopools and Rapid Analysis of Massively Parallel Barcoded Measurements.
ACS Synth Biol. 2024;13(12):4218-4232. doi:10.1021/acssynbio.4c00661

Paper: https://pubs.acs.org/doi/10.1021/acssynbio.4c00661
'''

MANUAL_COMMAND_TOPICS = tuple(sorted(COMMAND_TO_API))

MANUAL_META_TOPICS = (
    'topics',
    'topic',
    'list',
    'cli',
    'manual',
    'library',
    'package',
    'complete',
    'completion',
    'pipeline',
)

MANUAL_TOPIC_CHOICES = tuple(sorted(set(MANUAL_COMMAND_TOPICS) | set(MANUAL_META_TOPICS)))

# Banner toggles to prevent duplicate prints within a single process.
_HEADER_PRINTED = False
_FOOTER_PRINTED = False


# === Argument parsing ===

class OligopoolParser(argparse.ArgumentParser):
    '''
    Custom Parser to show help messages on
    error. Internal use only.
    '''
    @staticmethod
    def _suggest(value, choices, limit=3):
        if not value or not choices:
            return None
        matches = difflib.get_close_matches(value, choices, n=limit, cutoff=0.6)
        return matches if matches else None

    def _get_subcommands(self):
        for action in self._actions:
            if isinstance(action, argparse._SubParsersAction):
                return sorted(action.choices.keys())
        return []

    def _get_option_strings(self):
        return sorted(self._option_string_actions.keys())

    def error(self, message):
        if message:
            message = message.strip()
            if not message.endswith(':'):
                print(f'error:\n{message}\n')

            if 'invalid choice:' in message:
                try:
                    bad = message.split('invalid choice:', 1)[1].strip()
                    bad = bad.split('(', 1)[0].strip().strip("'\"")
                except Exception:
                    bad = None
                suggestions = self._suggest(bad, self._get_subcommands())
                if suggestions:
                    print('did you mean: {}\n'.format(', '.join(suggestions)))

            if 'unrecognized arguments:' in message:
                try:
                    bad = message.split('unrecognized arguments:', 1)[1].strip()
                    bad = bad.split()[0].strip().strip("'\"")
                except Exception:
                    bad = None
                suggestions = self._suggest(bad, self._get_option_strings())
                if suggestions:
                    print('did you mean: {}\n'.format(', '.join(suggestions)))
        try:
            self.print_help()
            print()
        except Exception:
            pass
        sys.exit(404)

    def parse_known_args(self, args=None, namespace=None):
        '''Treat unknown args as a hard error for subparsers.'''
        args, argv = super().parse_known_args(args, namespace)
        if argv:
            argv_string = ' '.join(argv)
            if argv_string:
                self.error(f'unrecognized arguments: {argv_string}')
        return args, argv

    def format_help(self):
        '''Format help output with grouped command listing.'''
        text = super().format_help()
        lines = text.splitlines(True)
        plain = [_strip_ansi(line) for line in lines]
        try:
            header_idx = next(i for i, line in enumerate(plain) if line.startswith('COMMANDS Available:'))
        except StopIteration:
            return text

        first_cmd_idx = header_idx + 1
        while first_cmd_idx < len(lines) and plain[first_cmd_idx].strip() == '':
            first_cmd_idx += 1
        if first_cmd_idx >= len(lines) or not plain[first_cmd_idx].startswith('    '):
            return text

        cmd_idx = first_cmd_idx
        command_lines = []
        while cmd_idx < len(lines) and plain[cmd_idx].startswith('    '):
            if plain[cmd_idx].strip():
                command_lines.append(lines[cmd_idx])
            cmd_idx += 1
        while cmd_idx < len(lines) and plain[cmd_idx].strip() == '':
            cmd_idx += 1

        manual_line = next((line for line in command_lines if _strip_ansi(line).lstrip().startswith('manual')), None)
        cite_line = next((line for line in command_lines if _strip_ansi(line).lstrip().startswith('cite')), None)
        pipeline_line = next((line for line in command_lines if _strip_ansi(line).lstrip().startswith('pipeline')), None)
        inspect_line = next((line for line in command_lines if _strip_ansi(line).lstrip().startswith('inspect')), None)
        complete_line = next((line for line in command_lines if _strip_ansi(line).lstrip().startswith('complete')), None)
        if manual_line is None or cite_line is None or pipeline_line is None or inspect_line is None or complete_line is None:
            return text

        special_lines = {manual_line, cite_line, pipeline_line, inspect_line, complete_line}
        middle_lines = [line for line in command_lines if line not in special_lines]
        gap_after = {'spacer', 'background', 'pad', 'join', 'verify', 'final', 'expand'}
        middle_out = []
        for line in middle_lines:
            middle_out.append(line)
            try:
                cmd = _strip_ansi(line).strip().split()[0]
            except Exception:
                cmd = None
            if cmd in gap_after:
                middle_out.append('\n')

        out = []
        out.extend(lines[:header_idx + 1])
        out.append('\n')
        out.append(manual_line)
        out.append(cite_line)
        out.append('\n')
        out.append(pipeline_line)
        out.append('\n')
        out.extend(middle_out)
        out.append('\n')
        out.append(inspect_line)
        out.append('\n')
        out.append(complete_line)
        out.append('\n')
        out.extend(lines[cmd_idx:])
        return ''.join(out)

class OligopoolFormatter(argparse.RawTextHelpFormatter):
    '''
    Custom HelpFormatter for OligopoolParser.
    Internal use only.
    '''
    def __init__(self, prog, indent_increment=2, max_help_position=34, width=None):
        super().__init__(
            prog=prog,
            indent_increment=indent_increment,
            max_help_position=max_help_position,
            width=width)

    def _split_lines(self, text, width):
        # Allow explicit line breaks in help text using a ">>" sentinel.
        if text.startswith('>>'):
            return [line.strip() for line in text[2:].splitlines()]
        return super()._split_lines(text, width)

    def _format_action_invocation(self, action):
        # Strip hidden metavars so help alignment stays consistent.
        text = super()._format_action_invocation(action)
        text = text.replace('\x08', '')
        # Argparse often renders an extra " [ ...]" token for nargs on option flags,
        # which is visually noisy. Remove it for flags so the invocation stays compact.
        if getattr(action, 'option_strings', None):
            text = re.sub(r'\s*\[\s*\.\.\.\s*\]\s*$', '', text)
            # Also strip the default nargs+ rendering: "X [X ...]" / "[X ...]".
            # This keeps our ">>[optional ...]" help blocks as the source of truth.
            text = re.sub(r'\s*\[[^\]]*\.\.\.[^\]]*\]\s*$', '', text)
        text = re.sub(r'\s+', ' ', text).rstrip()
        return text

    def _fill_text(self, text, width, indent):
        # Allow explicit line breaks in epilog/description using a ">>" sentinel.
        if text.startswith('>>'):
            return '\n'.join(line.rstrip() for line in text[2:].splitlines())
        # Wrap paragraphs while keeping output free of control characters
        # (important for piping help text and for machine parsing).
        paragraphs = text.split('\n\n')
        filled = []
        for para in paragraphs:
            para = para.strip()
            if not para:
                filled.append('')
                continue
            filled.append(textwrap.fill(
                para,
                width=width,
                initial_indent=indent,
                subsequent_indent=indent))
        return '\n\n'.join(filled)


# === Banner helpers ===

def _print_header():
    '''Print the CLI header banner once per process.'''
    global _HEADER_PRINTED
    if not _HEADER_PRINTED:
        print()
        print(BANNER_TEXT)
        print()
        _HEADER_PRINTED = True

def _print_footer():
    '''Print the CLI footer banner once per process.'''
    global _FOOTER_PRINTED
    if not _FOOTER_PRINTED:
        # Use a fixed EST offset to keep the label stable (EST = UTC-05:00).
        now = dt.datetime.now(dt.timezone(dt.timedelta(hours=-5)))
        timestamp = now.strftime('%Y-%m-%d %I:%M:%S %p')
        print(f'\nEST {timestamp}\n')
        _FOOTER_PRINTED = True


# === Common parsing helpers ===

_SEQ_CONSTRAINT_MAX_LEN = 100000

def _eval_py_string_expr(expr, *, max_len=_SEQ_CONSTRAINT_MAX_LEN):
    '''Safely evaluate a small subset of Python string expressions.

    Allowed:
      - String literals: 'NNNN', "ATG"
      - Repetition: 'N'*20, 20*'N'
      - Concatenation: 'A'*10 + 'C'*10
      - Parentheses for grouping

    Disallowed: names, function calls, indexing, attributes, f-strings.
    '''
    try:
        tree = ast.parse(expr, mode='eval')
    except SyntaxError as exc:
        raise argparse.ArgumentTypeError(
            f'Invalid Python-style string expression: {expr!r}'
        ) from exc

    def _eval_node(node):
        if isinstance(node, ast.Constant):
            if isinstance(node.value, (str, int)):
                return node.value
            raise argparse.ArgumentTypeError(
                f'Unsupported constant in expression: {expr!r}'
            )

        if isinstance(node, ast.BinOp):
            left = _eval_node(node.left)
            right = _eval_node(node.right)

            if isinstance(node.op, ast.Add):
                if not (isinstance(left, str) and isinstance(right, str)):
                    raise argparse.ArgumentTypeError(
                        f'Only string + string is supported: {expr!r}'
                    )
                combined_len = len(left) + len(right)
                if combined_len > max_len:
                    raise argparse.ArgumentTypeError(
                        f'Expanded sequence constraint is too long ({combined_len} > {max_len}).'
                    )
                return left + right

            if isinstance(node.op, ast.Mult):
                if isinstance(left, str) and isinstance(right, int):
                    seq, repeat = left, right
                elif isinstance(left, int) and isinstance(right, str):
                    seq, repeat = right, left
                else:
                    raise argparse.ArgumentTypeError(
                        f'Only string * int is supported: {expr!r}'
                    )
                if repeat < 0:
                    raise argparse.ArgumentTypeError(
                        f'Repetition count must be >= 0: {expr!r}'
                    )
                expanded_len = len(seq) * repeat
                if expanded_len > max_len:
                    raise argparse.ArgumentTypeError(
                        f'Expanded sequence constraint is too long ({expanded_len} > {max_len}).'
                    )
                return seq * repeat

            raise argparse.ArgumentTypeError(
                f'Unsupported operator in expression: {expr!r}'
            )

        raise argparse.ArgumentTypeError(
            f'Unsupported Python syntax in expression: {expr!r}'
        )

    out = _eval_node(tree.body)
    if not isinstance(out, str):
        raise argparse.ArgumentTypeError(
            f'Expression must evaluate to a string: {expr!r}'
        )
    if len(out) > max_len:
        raise argparse.ArgumentTypeError(
            f'Expanded sequence constraint is too long ({len(out)} > {max_len}).'
        )
    return out

def _parse_sequence_constraint(value):
    '''Parse an IUPAC constraint, expanding simple repeat/py-string forms.

    Examples (quote to avoid shell globbing):
      - N*20
      - GCC+N*20+CCG
      - 'N'*20
      - 'A'*10 + 'C'*10
    '''
    if value is None:
        return None
    value = value.strip()
    if not value:
        return value

    match = re.fullmatch(r'([A-Za-z]+)\s*\*\s*(\d+)', value)
    if match:
        seq, repeat_str = match.groups()
        repeat = int(repeat_str)
        if repeat < 0:
            raise argparse.ArgumentTypeError('Repetition count must be >= 0.')
        expanded_len = len(seq) * repeat
        if expanded_len > _SEQ_CONSTRAINT_MAX_LEN:
            raise argparse.ArgumentTypeError(
                f'Expanded sequence constraint is too long ({expanded_len} > {_SEQ_CONSTRAINT_MAX_LEN}).'
            )
        return seq * repeat

    # Shorthand concatenation form: GCC+N*20+CCG
    # (This is not Python syntax; it's a CLI convenience.)
    if ('+' in value) and ("'" not in value) and ('"' not in value):
        parts = [part.strip() for part in value.split('+')]
        if not parts or any(not part for part in parts):
            raise argparse.ArgumentTypeError(
                f'Invalid sequence constraint expression: {value!r}'
            )
        out = []
        total_len = 0
        for part in parts:
            part_match = re.fullmatch(r'([A-Za-z]+)\s*\*\s*(\d+)', part)
            if part_match:
                seq, repeat_str = part_match.groups()
                repeat = int(repeat_str)
                if repeat < 0:
                    raise argparse.ArgumentTypeError(
                        'Repetition count must be >= 0.'
                    )
                expanded = seq * repeat
            elif re.fullmatch(r'[A-Za-z]+', part):
                expanded = part
            else:
                raise argparse.ArgumentTypeError(
                    f'Invalid sequence constraint expression: {value!r}'
                )
            total_len += len(expanded)
            if total_len > _SEQ_CONSTRAINT_MAX_LEN:
                raise argparse.ArgumentTypeError(
                    f'Expanded sequence constraint is too long ({total_len} > {_SEQ_CONSTRAINT_MAX_LEN}).'
                )
            out.append(expanded)
        return ''.join(out)

    if ("'" in value) or ('"' in value):
        return _eval_py_string_expr(value)

    return value

def _parse_list_str(value):
    '''Parse a comma-delimited string into a list of strings.'''
    if value is None:
        return None
    if isinstance(value, list):
        out = []
        for item in value:
            if item is None:
                continue
            if not isinstance(item, str):
                out.append(item)
                continue
            if ',' in item:
                out.extend([v.strip() for v in item.split(',') if v.strip()])
            else:
                out.append(item.strip())
        out = [v for v in out if v]
        return out if out else None
    if ',' in value:
        items = [v.strip() for v in value.split(',') if v.strip()]
        return items if items else None
    return value.strip()

def _parse_excluded_motifs_arg(value):
    '''Parse --excluded-motifs CLI arg into API-compatible form.

    With nargs='+', argparse gives None (unset) or a list of strings.
    Each string is a set source: a file path, a comma-separated motif
    string, or a NAME=SOURCE pair.

    Returns None, a list of set sources, or a dict (if NAME=SOURCE
    form is used). Does NOT flatten comma-strings into individual
    motifs - that is handled by get_parsed_exmotifs in the API layer.
    '''

    if value is None:
        return None

    # Single string (shouldn't happen with nargs='+', but defensive)
    if isinstance(value, str):
        return value

    # List from argparse
    if not isinstance(value, list) or not value:
        return None

    # Check for NAME=SOURCE form
    has_named = any('=' in s for s in value)
    if has_named:
        result = {}
        for s in value:
            if '=' in s:
                name, source = s.split('=', 1)
                result[name.strip()] = source.strip()
            else:
                # Unnamed source in a mixed list; use source as key
                result[s.strip()] = s.strip()
        return result

    # Single-item list -> pass as-is (let API disambiguate)
    if len(value) == 1:
        return value[0]

    # Multi-item list -> pass as list (each is a set source)
    return value

def _parse_list_int(value):
    '''Parse comma-delimited ints or a scalar int; pass through non-numeric strings.'''
    if value is None:
        return None
    if isinstance(value, list):
        return value
    lowered = value.strip().lower()
    if lowered in ('none', 'null'):
        return None
    if ',' in value:
        items = [v.strip() for v in value.split(',') if v.strip()]
        if all(item.lstrip('-').isdigit() for item in items):
            return [int(item) for item in items]
        return value
    if value.lstrip('-').isdigit():
        return int(value)
    return value

def _parse_type_param(value):
    '''Parse type parameter accepting int or string.'''
    if value is None:
        return None
    value = str(value).strip()
    if value.lstrip('-').isdigit():
        return int(value)
    return value

def _looks_like_path(value):
    '''Return True if a CLI argument looks like a filesystem path.'''
    if value is None:
        return False
    value = str(value).strip()
    if not value:
        return False
    lowered = value.lower()
    if lowered.startswith(('./', '../', '~/', '~\\')):
        return True
    if any(sep in value for sep in ('/', '\\')):
        return True
    if lowered.endswith(('.csv', '.tsv')):
        return True
    return os.path.exists(value)

def _dump_stats(stats, args):
    '''Emit stats as JSON to stdout or to a file if requested.'''
    if stats is None:
        return
    if args.stats_json:
        print(json.dumps(stats, indent=2, sort_keys=True, default=str))
    if args.stats_file:
        with open(args.stats_file, 'w', encoding='utf-8') as handle:
            json.dump(stats, handle, indent=2, sort_keys=True, default=str)

def _handle_result(result, args):
    '''Extract stats from a module result and return a process exit code.'''
    # Most module calls return (df, stats); some return (..., stats) (e.g., compress).
    stats = None
    if isinstance(result, dict):
        stats = result
    elif isinstance(result, tuple):
        if result and isinstance(result[-1], dict):
            stats = result[-1]
        else:
            for item in reversed(result):
                if isinstance(item, dict):
                    stats = item
                    break
    _dump_stats(stats, args)
    if isinstance(stats, dict) and 'status' in stats:
        return 0 if stats['status'] else 1
    return 0

def _inject_config_args(arg_list, command, parser=None):
    '''Pre-parse config file and inject values into arg list.

    This allows config values to satisfy argparse 'required' checks.
    CLI args in the original list take precedence (they come later).

    Returns a new arg list with config values prepended.
    '''
    # Optionally restrict injections to flags that exist for this subcommand.
    valid_flags = None
    if parser is not None:
        try:
            subparsers = next(
                act for act in parser._actions
                if isinstance(act, argparse._SubParsersAction)
            )
            subparser = subparsers.choices.get(command)
            if subparser is not None:
                valid_flags = set(subparser._option_string_actions.keys())
        except Exception:
            valid_flags = None

    # Find --config in arg list
    config_path = None
    for i, arg in enumerate(arg_list):
        if arg == '--config' and i + 1 < len(arg_list):
            config_path = arg_list[i + 1]
            break
        if arg.startswith('--config='):
            config_path = arg.split('=', 1)[1]
            break

    if config_path is None:
        return arg_list

    # Load config
    try:
        config = load_config(config_path)
    except Exception:
        # Let normal error handling deal with it later
        return arg_list

    # Get command-specific config
    cmd_config = get_command_config(config, command)
    if not cmd_config:
        return arg_list

    # Convert keys to CLI format and build arg list
    injected = []
    for key, value in cmd_config.items():
        # Convert snake_case to kebab-case
        cli_key = f'--{key.replace("_", "-")}'
        if valid_flags is not None and cli_key not in valid_flags:
            continue

        # Check if this arg is already in the original list (CLI takes precedence)
        if cli_key in arg_list:
            continue
        # Check for --key=value form
        if any(a.startswith(f'{cli_key}=') for a in arg_list):
            continue

        # Convert value to string(s) for CLI
        if value is None:
            continue
        if isinstance(value, bool):
            if value:
                injected.append(cli_key)
            # False booleans are just omitted
        elif isinstance(value, list):
            # Join list items with commas
            injected.extend([cli_key, ','.join(str(v) for v in value)])
        else:
            injected.extend([cli_key, str(value)])

    # Insert injected args after the command (so original CLI args come later and win)
    # arg_list[0] is the command, so insert after it
    if arg_list:
        return [arg_list[0]] + injected + arg_list[1:]
    return injected + arg_list

def _apply_step_type_conversions(step_config):
    '''Apply type conversions to step config values.'''
    if 'excluded_motifs' in step_config:
        val = step_config['excluded_motifs']
        # Pass through list/dict from YAML; only parse strings
        if isinstance(val, str):
            step_config['excluded_motifs'] = _parse_excluded_motifs_arg([val])
        elif isinstance(val, (list, dict)):
            step_config['excluded_motifs'] = val
        else:
            step_config['excluded_motifs'] = val
    if 'background_directory' in step_config:
        step_config['background_directory'] = _parse_list_str(step_config['background_directory'])
    if 'spacer_length' in step_config:
        step_config['spacer_length'] = _parse_list_int(step_config['spacer_length'])
    if 'barcode_type' in step_config:
        step_config['barcode_type'] = _parse_type_param(step_config['barcode_type'])
    if 'primer_type' in step_config:
        step_config['primer_type'] = _parse_type_param(step_config['primer_type'])
    if 'motif_type' in step_config:
        step_config['motif_type'] = _parse_type_param(step_config['motif_type'])
    if 'join_policy' in step_config:
        step_config['join_policy'] = _parse_type_param(step_config['join_policy'])
    if 'cross_barcode_columns' in step_config:
        step_config['cross_barcode_columns'] = _parse_list_str(step_config['cross_barcode_columns'])
    if 'index_files' in step_config:
        step_config['index_files'] = _parse_list_str(step_config['index_files'])
    return step_config

def _get_pipeline_step_defs(config):
    '''
    Normalize pipeline step definitions for helper routines.
    Internal use only.
    '''
    if is_parallel_pipeline(config):
        return parse_parallel_steps(config)
    return [{
        'name': step,
        'command': step,
        'after': [],
        'config_key': step,
    } for step in get_pipeline_steps(config)]

def _append_suffix_if_missing(path, suffix):
    '''
    Append artifact suffix if not already present.
    Internal use only.
    '''
    if not isinstance(path, str):
        return path
    return path if path.endswith(suffix) else '{}{}'.format(path, suffix)

def _iter_output_targets_for_command(command, step_config):
    '''
    Yield (raw_value, resolved_value) for declared pipeline outputs.
    Internal use only.
    '''
    output_fields = {
        'barcode': (('output_file', '.oligopool.barcode.csv'),),
        'primer': (('output_file', '.oligopool.primer.csv'),),
        'motif': (('output_file', '.oligopool.motif.csv'),),
        'spacer': (('output_file', '.oligopool.spacer.csv'),),
        'split': (('output_file', '.oligopool.split.csv'),),
        'pad': (('output_file', '.oligopool.pad.csv'),),
        'merge': (('output_file', '.oligopool.merge.csv'),),
        'join': (('output_file', '.oligopool.join.csv'),),
        'revcomp': (('output_file', '.oligopool.revcomp.csv'),),
        'verify': (('output_file', '.oligopool.verify.csv'),),
        'final': (('output_file', '.oligopool.final.csv'),),
        'expand': (('output_file', '.oligopool.expand.csv'),),
        'index': (('index_file', '.oligopool.index'),),
        'pack': (('pack_file', '.oligopool.pack'),),
        'acount': (
            ('count_file', '.oligopool.acount.csv'),
            ('failed_reads_file', '.oligopool.acount.failedreads.csv')),
        'xcount': (
            ('count_file', '.oligopool.xcount.csv'),
            ('failed_reads_file', '.oligopool.xcount.failedreads.csv')),
        'compress': (
            ('mapping_file', '.oligopool.compress.mapping.csv'),
            ('synthesis_file', '.oligopool.compress.synthesis.csv')),
    }

    for field, suffix in output_fields.get(command, ()):
        value = step_config.get(field)
        if isinstance(value, str) and value:
            yield value, _append_suffix_if_missing(value, suffix)

def _iter_output_aliases(path):
    '''
    Yield alias spellings that may refer to a pipeline output.
    Internal use only.
    '''
    aliases = set()
    if not isinstance(path, str):
        return aliases

    basename = os.path.basename(path)
    for candidate in (path, basename):
        if not candidate:
            continue
        aliases.add(candidate)
        stem, _ = os.path.splitext(candidate)
        if stem:
            aliases.add(stem)
    return aliases

def _build_pipeline_output_alias_info(config):
    '''
    Build pipeline output alias metadata.
    Internal use only.

    Returns:
      (resolved_alias_map, ambiguous_aliases)
      - resolved_alias_map: alias -> single resolved path
      - ambiguous_aliases: alias -> sorted list of conflicting targets
    '''
    alias_targets = {}
    for step in _get_pipeline_step_defs(config):
        step_config = get_command_config(config, step['config_key'])
        step_config = convert_config_keys_to_args(step_config)
        for raw, resolved in _iter_output_targets_for_command(
            command=step['command'],
            step_config=step_config):
            for alias in _iter_output_aliases(raw):
                targets = alias_targets.setdefault(alias, set())
                targets.add(resolved)

    resolved_map = {}
    ambiguous = {}
    for alias, targets in alias_targets.items():
        if len(targets) == 1:
            resolved_map[alias] = next(iter(targets))
        else:
            ambiguous[alias] = sorted(targets)
    return (resolved_map, ambiguous)

def _report_pipeline_alias_ambiguity(ambiguous_aliases):
    '''
    Print human-readable pipeline alias ambiguity errors.
    Internal use only.
    '''
    if not ambiguous_aliases:
        return

    print(
        'config error: Ambiguous output basename aliases detected in pipeline config.',
        file=sys.stderr)
    print(
        'Each alias must map to exactly one output. Rename colliding output basenames.',
        file=sys.stderr)

    # Keep output compact but actionable.
    shown = 0
    for alias, targets in sorted(ambiguous_aliases.items()):
        shown += 1
        if shown > 20:
            remaining = len(ambiguous_aliases) - 20
            print(
                '  ... and {} more ambiguous alias(es).'.format(remaining),
                file=sys.stderr)
            break
        print(
            '  - alias "{}" maps to: {}'.format(
                alias,
                ', '.join(targets)),
            file=sys.stderr)

def _get_pipeline_resolvable_input_keys(command):
    '''
    Return input keys eligible for pipeline magic path resolution.
    Internal use only.
    '''
    keys = {'input_data'}
    if command == 'join':
        keys.add('other_data')
    if command == 'index':
        keys.update({'barcode_data', 'associate_data'})
    if command == 'expand':
        keys.add('mapping_file')
    return keys

def _resolve_pipeline_magic_paths(command, step_config, output_alias_map):
    '''
    Resolve basename-like pipeline references to declared outputs.
    Honors explicit paths if they already exist.
    Internal use only.
    '''
    if not output_alias_map:
        return step_config

    resolvable_input_keys = _get_pipeline_resolvable_input_keys(command)

    for key in resolvable_input_keys:
        value = step_config.get(key)
        if not isinstance(value, str) or not value:
            continue

        # Explicit, existing path always wins.
        if os.path.exists(value):
            continue

        candidates = [value, os.path.basename(value)]
        stem, _ = os.path.splitext(value)
        if stem:
            candidates.append(stem)
            stem_base = os.path.basename(stem)
            if stem_base:
                candidates.append(stem_base)

        for candidate in candidates:
            resolved = output_alias_map.get(candidate)
            if resolved:
                step_config[key] = resolved
                break

    return step_config

def _execute_step(
    step_name,
    command,
    config_key,
    config,
    output_alias_map=None):
    '''Execute a single pipeline step.

    Returns:
        tuple: (step_name, success: bool, result_or_error, stats_summary)
    '''
    try:
        step_config = get_command_config(config, config_key)
        step_config = convert_config_keys_to_args(step_config)
        step_config = _apply_step_type_conversions(step_config)
        step_config = _resolve_pipeline_magic_paths(
            command=command,
            step_config=step_config,
            output_alias_map=output_alias_map)
        step_config['verbose'] = False  # Suppress step output in pipeline mode

        func = _load_api_func(command)
        result = func(**step_config)

        # Check result status and extract summary info
        stats = None
        if isinstance(result, dict):
            stats = result
        elif isinstance(result, tuple):
            if result and isinstance(result[-1], dict):
                stats = result[-1]
            else:
                for item in reversed(result):
                    if isinstance(item, dict):
                        stats = item
                        break
        summary = None
        if isinstance(stats, dict):
            if 'status' in stats and not stats['status']:
                return (step_name, False, f'Step "{step_name}" failed.', None)
            # Extract useful summary info
            summary = {}
            if 'basis' in stats:
                summary['basis'] = stats['basis']
            if 'time' in stats:
                summary['time'] = stats['time']

        return (step_name, True, result, summary)

    except Exception as exc:
        return (step_name, False, f'Step "{step_name}" raised exception: {exc}', None)

def _build_cli_argv(command, step_config):
    '''Build a CLI argv list for invoking a command with given config.

    Pipeline parallel mode uses subprocess execution (one interpreter per step) so
    multiprocessing-heavy commands can safely run concurrently.
    '''
    argv = [sys.executable, '-m', 'oligopool.cli', command]

    # Keep step output quiet; pipeline output is handled by the parent.
    argv.append('--quiet')
    # Suppress banners and emit a machine-parseable stats dict (captured by the parent).
    argv.append('--stats-json')

    for key, value in (step_config or {}).items():
        if key in ('verbose', 'stats_json', 'stats_file'):
            continue
        if value is None:
            continue

        flag = f'--{key.replace("_", "-")}'
        if isinstance(value, bool):
            if value:
                argv.append(flag)
            continue

        argv.append(flag)
        if isinstance(value, (list, tuple)):
            argv.extend([str(v) for v in value])
        else:
            argv.append(str(value))

    return argv

def _run_pipeline_sequential(config, steps, dry_run, verbose):
    '''Execute a sequential (non-parallel) pipeline.'''
    pipeline_name = get_pipeline_name(config)

    if verbose:
        print(f'Pipeline: {pipeline_name}')
        print(f'Steps: {len(steps)}')
        print()

    output_alias_map, ambiguous_aliases = _build_pipeline_output_alias_info(config)
    if ambiguous_aliases:
        _report_pipeline_alias_ambiguity(ambiguous_aliases)
        return 1

    if dry_run:
        print('Dry run: config validation passed.')
        for i, step in enumerate(steps, 1):
            step_config = get_command_config(config, step)
            step_config = convert_config_keys_to_args(step_config)
            print(f'  Step {i}: {step}')
            if step_config:
                for key, value in step_config.items():
                    if key not in ('verbose', 'stats_json', 'stats_file'):
                        print(f'    --{key.replace("_", "-")}: {value}')
        return 0

    # Execute pipeline steps sequentially
    for i, step in enumerate(steps, 1):
        if verbose:
            print(f'  [{i}/{len(steps)}] {step} ... ', end='', flush=True)

        step_name, success, result_or_error, summary = _execute_step(
            step_name=step,
            command=step,
            config_key=step,
            config=config,
            output_alias_map=output_alias_map,
        )

        if not success:
            if verbose:
                print('FAILED')
            print(f'error: {result_or_error}', file=sys.stderr)
            return 1

        if verbose:
            print('done')

    if verbose:
        print()
        print(f'Pipeline completed successfully.')

    return 0

def _run_pipeline_parallel(config, dry_run, verbose):
    '''Execute a parallel/DAG pipeline.'''

    pipeline_name = get_pipeline_name(config)

    # Validate parallel pipeline config
    warnings = validate_parallel_pipeline(config)
    if warnings:
        for warning in warnings:
            print(f'config error: {warning}', file=sys.stderr)
        return 1

    # Parse steps and build execution levels
    steps = parse_parallel_steps(config)
    levels = build_execution_levels(steps)

    # Validate all commands are known
    for step in steps:
        if step['command'] not in COMMAND_TO_API:
            print(f'error: Unknown command "{step["command"]}" in step "{step["name"]}".', file=sys.stderr)
            close_matches = difflib.get_close_matches(step['command'], COMMAND_TO_API.keys(), n=3, cutoff=0.6)
            if close_matches:
                print(f'did you mean: {", ".join(close_matches)}', file=sys.stderr)
            return 1

    total_steps = len(steps)
    output_alias_map, ambiguous_aliases = _build_pipeline_output_alias_info(config)
    if ambiguous_aliases:
        _report_pipeline_alias_ambiguity(ambiguous_aliases)
        return 1

    if verbose:
        print(f'Pipeline: {pipeline_name}')
        print(f'Steps: {total_steps} across {len(levels)} levels')
        print()

    if dry_run:
        print('Dry run: config validation passed.')
        print()
        for level_idx, level in enumerate(levels):
            parallel_marker = ' (parallel)' if len(level) > 1 else ''
            print(f'  Level {level_idx + 1}{parallel_marker}:')
            for step in level:
                step_config = get_command_config(config, step['config_key'])
                step_config = convert_config_keys_to_args(step_config)
                deps = f' (after: {", ".join(step["after"])})' if step['after'] else ''
                print(f'    {step["name"]}: {step["command"]}{deps}')
        return 0

    for level_idx, level in enumerate(levels):
        is_parallel = len(level) > 1
        parallel_marker = ' (parallel)' if is_parallel else ''

        if verbose:
            print(f'  Level {level_idx + 1}{parallel_marker}:')
            for step in level:
                deps = f' (after: {", ".join(step["after"])})' if step['after'] else ''
                print(f'    {step["name"]}: {step["command"]}{deps}')
            # Keep the "progress line" visually tied to the step list (matches dry-run layout).
            print('    ... ', end='', flush=True)

        # Run each step in its own subprocess so multiprocessing-heavy commands
        # can run concurrently without thread-level interference.
        procs = {}
        for step in level:
            step_config = get_command_config(config, step['config_key'])
            step_config = convert_config_keys_to_args(step_config)
            step_config = _apply_step_type_conversions(step_config)
            step_config = _resolve_pipeline_magic_paths(
                command=step['command'],
                step_config=step_config,
                output_alias_map=output_alias_map)
            argv = _build_cli_argv(step['command'], step_config)
            procs[step['name']] = subprocess.Popen(
                argv,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
            )

        failed = None
        failed_detail = None
        for step_name, proc in procs.items():
            out, err = proc.communicate()
            if proc.returncode != 0 and failed is None:
                failed = step_name
                failed_detail = (err or out or '').strip()
                if len(failed_detail) > 2000:
                    failed_detail = failed_detail[:2000].rstrip() + ' ...'

        if failed is not None:
            # Best-effort terminate remaining processes.
            for name, proc in procs.items():
                if name == failed:
                    continue
                if proc.poll() is None:
                    try:
                        proc.terminate()
                    except Exception:
                        pass
            for name, proc in procs.items():
                if name == failed:
                    continue
                try:
                    proc.wait(timeout=5)
                except Exception:
                    pass

            if verbose:
                print('FAILED')
            if failed_detail:
                print(f'error: Step "{failed}" failed: {failed_detail}', file=sys.stderr)
            else:
                print(f'error: Step "{failed}" failed.', file=sys.stderr)
            return 1

        if verbose:
            print('done')

    if verbose:
        print()
        print(f'Pipeline completed successfully.')

    return 0

def _run_pipeline(config, dry_run, verbose):
    '''Execute a multi-step pipeline from config.

    Supports both sequential and parallel (DAG) pipelines.

    Parameters:
        config (dict): Loaded pipeline config.
        dry_run (bool): If True, validate without executing.
        verbose (bool): Whether to print verbose output.

    Returns:
        int: Exit code (0 for success, 1 for failure).
    '''
    # Validate basic config structure
    warnings = validate_config_structure(config)
    if warnings:
        for warning in warnings:
            print(f'config warning: {warning}', file=sys.stderr)

    # Get pipeline steps
    steps = get_pipeline_steps(config)
    if not steps:
        print('error: No pipeline steps defined in config.', file=sys.stderr)
        print('Expected format (sequential):', file=sys.stderr)
        print('  pipeline:', file=sys.stderr)
        print('    steps:', file=sys.stderr)
        print('      - command1', file=sys.stderr)
        print('      - command2', file=sys.stderr)
        print('Or (parallel):', file=sys.stderr)
        print('  pipeline:', file=sys.stderr)
        print('    steps:', file=sys.stderr)
        print('      - name: step1', file=sys.stderr)
        print('        command: barcode', file=sys.stderr)
        print('      - name: step2', file=sys.stderr)
        print('        command: primer', file=sys.stderr)
        print('        after: [step1]', file=sys.stderr)
        return 1

    # Check if parallel or sequential pipeline
    if is_parallel_pipeline(config):
        return _run_pipeline_parallel(config, dry_run, verbose)
    else:
        # Sequential pipeline - validate commands
        for step in steps:
            if step not in COMMAND_TO_API:
                print(f'error: Unknown pipeline step "{step}".', file=sys.stderr)
                close_matches = difflib.get_close_matches(step, COMMAND_TO_API.keys(), n=3, cutoff=0.6)
                if close_matches:
                    print(f'did you mean: {", ".join(close_matches)}', file=sys.stderr)
                return 1

        return _run_pipeline_sequential(config, steps, dry_run, verbose)


# === Subcommand parsing ===

def _add_common_options(parser, opt_group=None, include_config=True):
    '''Register common CLI flags on the target parser/group.'''
    target = opt_group if opt_group is not None else parser
    if include_config:
        target.add_argument(
            '--config',
            type=str,
            default=None,
            metavar='\b',
            help='''>>[optional string]
Path to YAML config file. CLI args override config values.''')
    target.add_argument(
        '--stats-json',
        action='store_true',
        help='''>>[optional switch]
Print the stats dictionary as JSON to stdout.''')
    target.add_argument(
        '--stats-file',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Write the stats dictionary as JSON to this file path.''')
    target.add_argument(
        '--quiet',
        dest='verbose',
        action='store_false',
        help='''>>[optional switch]
Suppress verbose module output (sets verbose=False).''')
    parser.set_defaults(verbose=True)

def _add_manual(cmdpar):
    '''Register the manual subcommand parser.'''
    parser = cmdpar.add_parser(
        'manual',
        help=_CLI_DESC['manual'],
        description='Show module documentation from the Python API (like help(...)).',
        usage=argparse.SUPPRESS,
        formatter_class=OligopoolFormatter,
        add_help=False)
    topic = parser.add_argument(
        'topic',
        nargs='?',
        default=None,
        metavar='\b',
        help='''>>[optional string]
Module name for docs (e.g., barcode, primer). Use "topics" to list.''')
    topic.completer = argcomplete.completers.ChoicesCompleter(MANUAL_TOPIC_CHOICES)
    return parser

def _add_cite(cmdpar):
    '''Register the cite subcommand parser.'''
    parser = cmdpar.add_parser(
        'cite',
        help=_CLI_DESC['cite'],
        description='Print citation information and the paper link.',
        usage=argparse.SUPPRESS,
        formatter_class=OligopoolFormatter,
        add_help=False)
    return parser

def _add_complete(cmdpar):
    '''Register the complete subcommand parser.'''
    parser = cmdpar.add_parser(
        'complete',
        help=_CLI_DESC['complete'],
        description='Print or install shell tab-completion setup (argcomplete).',
        usage=argparse.SUPPRESS,
        formatter_class=OligopoolFormatter,
        add_help=False)
    parser.add_argument(
        '--shell',
        choices=('bash', 'zsh', 'fish'),
        default=None,
        metavar='\b',
        help='''>>[optional string]
Target shell for completion output (bash, zsh, fish).''')
    parser.add_argument(
        '--install',
        nargs='?',
        const='auto',
        choices=('auto', 'bash', 'zsh', 'fish'),
        default=None,
        metavar='\b',
        help='''>>[optional string]
Install completion into your shell config (idempotent).
If omitted, auto-detect shell.''')
    parser.add_argument(
        '--print-instructions',
        action='store_true',
        help='''>>[optional switch]
Print short instructions instead of shell code.''')
    return parser

def _detect_shell():
    shell = os.environ.get('SHELL') or ''
    shell = os.path.basename(shell)
    if shell in ('bash', 'zsh', 'fish'):
        return shell
    return 'bash'

def _completion_snippet(shell, include_hint=False, lazy=False):
    hint_lines = []
    if include_hint:
        hint_lines = [
            '# Oligopool CLI tab-completion (argcomplete)',
            '# One-time install (recommended): op complete --install  (restart shell)',
            f'# Or: op complete --install {shell}',
            '# Show more help: op complete --print-instructions',
            '',
        ]
    if shell == 'bash':
        if lazy:
            return '\n'.join(hint_lines + [
                '__oligopool_argcomplete_init() {',
                '  [[ -n "${_OLIGOPOOL_ARGCOMPLETE_INSTALLED:-}" ]] && return 0',
                '  if command -v register-python-argcomplete >/dev/null 2>&1; then',
                '    eval "$(register-python-argcomplete op)"',
                '    eval "$(register-python-argcomplete oligopool)"',
                '    _OLIGOPOOL_ARGCOMPLETE_INSTALLED=1',
                '  fi',
                '}',
                '# Initialize now (if available), and retry on each prompt (e.g., after conda activate).',
                '__oligopool_argcomplete_init',
                'if [[ -n "${PROMPT_COMMAND:-}" ]]; then',
                '  PROMPT_COMMAND="__oligopool_argcomplete_init; ${PROMPT_COMMAND}"',
                'else',
                '  PROMPT_COMMAND="__oligopool_argcomplete_init"',
                'fi',
            ])
        return '\n'.join(hint_lines + [
            'if command -v register-python-argcomplete >/dev/null 2>&1; then',
            '  eval "$(register-python-argcomplete op)"',
            '  eval "$(register-python-argcomplete oligopool)"',
            'fi',
        ])
    if shell == 'zsh':
        if lazy:
            return '\n'.join(hint_lines + [
                'autoload -U bashcompinit && bashcompinit',
                '__oligopool_argcomplete_init() {',
                '  [[ -n "${_OLIGOPOOL_ARGCOMPLETE_INSTALLED:-}" ]] && return 0',
                '  if command -v register-python-argcomplete >/dev/null 2>&1; then',
                '    eval "$(register-python-argcomplete op)"',
                '    eval "$(register-python-argcomplete oligopool)"',
                '    _OLIGOPOOL_ARGCOMPLETE_INSTALLED=1',
                '  fi',
                '}',
                '# Initialize now (if available), and retry on each prompt (e.g., after conda activate).',
                '__oligopool_argcomplete_init',
                'precmd_functions+=(__oligopool_argcomplete_init)',
            ])
        return '\n'.join(hint_lines + [
            'autoload -U bashcompinit && bashcompinit',
            'if command -v register-python-argcomplete >/dev/null 2>&1; then',
            '  eval "$(register-python-argcomplete op)"',
            '  eval "$(register-python-argcomplete oligopool)"',
            'fi',
        ])
    if shell == 'fish':
        if lazy:
            return '\n'.join(hint_lines + [
                'function __oligopool_argcomplete_init --on-event fish_prompt',
                '  if set -q _OLIGOPOOL_ARGCOMPLETE_INSTALLED',
                '    return',
                '  end',
                '  if type -q register-python-argcomplete',
                '    register-python-argcomplete --shell fish op | source',
                '    register-python-argcomplete --shell fish oligopool | source',
                '    set -g _OLIGOPOOL_ARGCOMPLETE_INSTALLED 1',
                '    functions -e __oligopool_argcomplete_init',
                '  end',
                'end',
            ])
        return '\n'.join(hint_lines + [
            'register-python-argcomplete --shell fish op | source',
            'register-python-argcomplete --shell fish oligopool | source',
        ])
    raise ValueError(f'unsupported shell: {shell}')

def _upsert_rc_block(rc_path, shell):
    begin = '# >>> oligopool argcomplete >>>'
    end = '# <<< oligopool argcomplete <<<'
    snippet = _completion_snippet(shell, include_hint=False, lazy=True)
    block = f'{begin}\n{snippet}\n{end}\n'

    rc_path.parent.mkdir(parents=True, exist_ok=True)
    existing = rc_path.read_text(encoding='utf-8') if rc_path.exists() else ''
    if begin in existing and end in existing:
        pre = existing.split(begin, 1)[0]
        post = existing.split(end, 1)[1]
        updated = pre.rstrip('\n') + '\n\n' + block + '\n' + post.lstrip('\n')
    else:
        updated = existing.rstrip('\n') + '\n\n' + block
    rc_path.write_text(updated, encoding='utf-8')

def _install_completion(shell):
    home = Path.home()
    if shell == 'bash':
        _upsert_rc_block(home / '.bashrc', 'bash')
        return 0
    if shell == 'zsh':
        _upsert_rc_block(home / '.zshrc', 'zsh')
        return 0
    if shell == 'fish':
        comp_dir = home / '.config' / 'fish' / 'completions'
        comp_dir.mkdir(parents=True, exist_ok=True)
        snippet = _completion_snippet('fish', include_hint=False, lazy=True) + '\n'
        (comp_dir / 'op.fish').write_text(snippet, encoding='utf-8')
        (comp_dir / 'oligopool.fish').write_text(snippet, encoding='utf-8')
        return 0
    raise ValueError(f'unsupported shell: {shell}')

def _run_complete(shell, install, print_instructions):
    include_hint = shell is None
    shell = shell or _detect_shell()
    if print_instructions:
        print(CLI_COMPLETE_MANUAL.strip())
        return 0
    if install is not None:
        install_shell = shell if install == 'auto' else install
        _install_completion(install_shell)
        print(f'Installed completion for {install_shell}. Restart your shell.')
        return 0
    print(_completion_snippet(shell, include_hint=include_hint))
    return 0

def _print_manual(topic):
    '''Print module or package documentation for manual command.'''
    import oligopool as op

    manual_aliases = {
        'list', 'topic', 'topics',
        'cli', 'manual',
        'complete', 'completion',
        'pipeline',
        'library', 'package',
    }
    suggestion_pool = sorted(set(MANUAL_COMMAND_TOPICS) | manual_aliases)

    def _strip_header(doc):
        if not doc:
            return doc
        lines = doc.strip().splitlines()
        if len(lines) >= 2 and lines[0].startswith('oligopool v') and lines[1].startswith('by '):
            lines = lines[2:]
            while lines and not lines[0].strip():
                lines = lines[1:]
        return '\n'.join(lines).strip()

    if topic is None:
        print(CLI_MANUAL.strip())
        return 0

    key = str(topic).strip().lower()
    if key in ('list', 'topic', 'topics'):
        command_topics = ', '.join(MANUAL_COMMAND_TOPICS)
        meta_topics = 'topic/topics/list, cli/manual, library/package, complete/completion, pipeline'
        print('Available command topics:')
        print(textwrap.fill(command_topics, width=79, initial_indent='  ', subsequent_indent='  '))
        print()
        print('Available meta topics:')
        print(textwrap.fill(meta_topics, width=79, initial_indent='  ', subsequent_indent='  '))
        return 0
    if key in ('cli', 'manual'):
        print(CLI_MANUAL.strip())
        return 0
    if key == 'cite':
        print(CITATION_TEXT.strip())
        return 0
    if key in ('complete', 'completion'):
        print(CLI_COMPLETE_MANUAL.strip())
        return 0
    if key == 'pipeline':
        print(CLI_PIPELINE_MANUAL.strip())
        return 0
    if key in ('library', 'package'):
        doc = _strip_header(op.__doc__)
        if doc:
            print(doc)
            return 0
        print('No manual is available.')
        return 1

    if key not in MANUAL_COMMAND_TOPICS:
        suggestions = difflib.get_close_matches(key, suggestion_pool, n=3, cutoff=0.6)
        print(f'No documentation available for "{topic}".')
        if suggestions:
            print('Did you mean: {}?'.format(', '.join(suggestions)))
        return 1

    try:
        target = getattr(op, key)
    except Exception:
        print(f'No documentation available for "{topic}".')
        return 1

    doc = getattr(target, '__doc__', None)
    if doc:
        print(doc.strip())
        return 0

    print(f'No documentation available for "{topic}".')
    return 1

def _print_cite():
    '''Print citation information for the project.'''
    print(CITATION_TEXT.strip())
    return 0


# === Pipeline subcommand ===

def _add_pipeline(cmdpar):
    '''Register the pipeline subcommand parser.'''
    parser = cmdpar.add_parser(
        'pipeline',
        help=_CLI_DESC['pipeline'],
        description='Execute a multi-step pipeline from a YAML config file.',
        usage=argparse.SUPPRESS,
        formatter_class=OligopoolFormatter,
        add_help=False)
    req = parser.add_argument_group('Required Arguments')
    opt = parser.add_argument_group('Optional Arguments')
    req.add_argument(
        '--config',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Path to YAML pipeline config file.''')
    opt.add_argument(
        '--dry-run',
        action='store_true',
        help='''>>[optional switch]
Validate config without executing steps.''')
    _add_common_options(parser, opt, include_config=False)
    return parser


# === Module subcommands ===

def _add_background(cmdpar):
    '''Register the background subcommand parser.'''
    parser = cmdpar.add_parser(
        'background',
        help=_CLI_DESC['background'],
        description='Build a background k-mer database for repeat exclusion.',
        epilog=_notes_epilog('background'),
        usage=argparse.SUPPRESS,
        formatter_class=OligopoolFormatter,
        add_help=False)
    req = parser.add_argument_group('Required Arguments')
    opt = parser.add_argument_group('Optional Arguments')
    req.add_argument(
        '--input-data',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Path to input CSV with background sequences.
Required column: Sequence (ID is allowed but not required).''')
    req.add_argument(
        '--maximum-repeat-length',
        required=True,
        type=int,
        metavar='\b',
        help='''>>[required integer]
Maximum repeat length allowed between primers and background.
(6 to 20).''')
    req.add_argument(
        '--output-directory',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Output directory for the background k-mer database.
A ".oligopool.background" suffix is added if missing.''')
    _add_common_options(parser, opt)
    return parser

def _add_barcode(cmdpar):
    '''Register the barcode subcommand parser.'''
    parser = cmdpar.add_parser(
        'barcode',
        help=_CLI_DESC['barcode'],
        description='Design constrained barcodes and write an output CSV.',
        epilog=_notes_epilog('barcode'),
        usage=argparse.SUPPRESS,
        formatter_class=OligopoolFormatter,
        add_help=False)
    req = parser.add_argument_group('Required Arguments')
    opt = parser.add_argument_group('Optional Arguments')
    req.add_argument(
        '--input-data',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Path to input CSV with an ID column and DNA sequence columns.''')
    req.add_argument(
        '--oligo-length-limit',
        required=True,
        type=int,
        metavar='\b',
        help='''>>[required integer]
Maximum allowed oligo length for the design (>= 4).''')
    req.add_argument(
        '--barcode-length',
        required=True,
        type=int,
        metavar='\b',
        help='''>>[required integer]
Length of the barcode to design (>= 4).''')
    req.add_argument(
        '--minimum-hamming-distance',
        required=True,
        type=int,
        metavar='\b',
        help='''>>[required integer]
Minimum pairwise Hamming distance between barcodes (>= 1).''')
    req.add_argument(
        '--maximum-repeat-length',
        required=True,
        type=int,
        metavar='\b',
        help='''>>[required integer]
Maximum repeat length shared with existing oligos (>= 4).''')
    req.add_argument(
        '--barcode-column',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Column name to store barcodes (overwrites if present).''')
    req.add_argument(
        '--output-file',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Output CSV filename. A ".oligopool.barcode.csv" suffix is added if missing.''')
    opt.add_argument(
        '--barcode-type',
        type=_parse_type_param,
        default=0,
        metavar='\b',
        help='''>>[optional int/string]
Barcode design mode: 0 or 'terminus' = terminus optimized (fast),
1 or 'spectrum' = spectrum optimized (slow). Aliases: 'term', 'spec'.
(default: 0).''')
    opt.add_argument(
        '--left-context-column',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Column containing left flanking context for motif exclusion.
At least one of --left-context-column or --right-context-column is required.''')
    opt.add_argument(
        '--right-context-column',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Column containing right flanking context for motif exclusion.
At least one of --left-context-column or --right-context-column is required.''')
    opt.add_argument(
        '--patch-mode',
        dest='patch_mode',
        action='store_true',
        help='''>>[optional switch]
Fill missing values in an existing barcode column instead of creating a new column.''')
    opt.add_argument(
        '--incremental',
        dest='patch_mode',
        action='store_true',
        help=argparse.SUPPRESS)
    opt.add_argument(
        '--cross-barcode-columns',
        type=str,
        default=None,
        nargs='+',
        metavar='\b',
        help='''>>[optional string]
Comma-separated or space-separated column names whose barcodes must remain
distinct from the newly designed set. Must be used with --minimum-cross-distance.''')
    opt.add_argument(
        '--minimum-cross-distance',
        type=int,
        default=None,
        metavar='\b',
        help='''>>[optional integer]
Minimum pairwise Hamming distance between the new barcodes and the cross set.
Must be used with --cross-barcode-columns.''')
    opt.add_argument(
        '--excluded-motifs',
        type=str,
        nargs='+',
        default=None,
        metavar='\b',
        help='''>>[optional string(s)]
Comma-separated motifs or path(s) to CSV (Exmotif column) / FASTA.''')
    opt.add_argument(
        '--background-directory',
        type=str,
        nargs='+',
        default=None,
        metavar='\b',
        help='''>>[optional string(s)]
Path(s) to background k-mer database(s) from the background command.''')
    opt.add_argument(
        '--random-seed',
        type=int,
        default=None,
        metavar='\b',
        help='''>>[optional integer]
Random seed for reproducible barcode design.''')
    _add_common_options(parser, opt)
    return parser

def _add_primer(cmdpar):
    '''Register the primer subcommand parser.'''
    parser = cmdpar.add_parser(
        'primer',
        help=_CLI_DESC['primer'],
        description='Design constrained primers and write an output CSV.',
        epilog=_notes_epilog('primer'),
        usage=argparse.SUPPRESS,
        formatter_class=OligopoolFormatter,
        add_help=False)
    req = parser.add_argument_group('Required Arguments')
    opt = parser.add_argument_group('Optional Arguments')
    req.add_argument(
        '--input-data',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Path to input CSV with an ID column and DNA sequence columns.''')
    req.add_argument(
        '--oligo-length-limit',
        required=True,
        type=int,
        metavar='\b',
        help='''>>[required integer]
Maximum allowed oligo length for the design (>= 4).''')
    req.add_argument(
        '--primer-sequence-constraint',
        required=True,
        type=_parse_sequence_constraint,
        metavar='\b',
        help='''>>[required string]
IUPAC degenerate sequence constraint for the primer.
Accepts Python-style expressions (quote as one argument), e.g. "'N'*20", or shorthand
concatenation like "GCC+N*20+CCG".''')
    req.add_argument(
        '--primer-type',
        required=True,
        type=_parse_type_param,
        metavar='\b',
        help='''>>[required int/string]
Primer direction: 0 or 'forward' for forward, 1 or 'reverse' for reverse.
Aliases: 'fwd', 'f', 'rev', 'r'.''')
    req.add_argument(
        '--minimum-melting-temperature',
        required=True,
        type=float,
        metavar='\b',
        help='''>>[required float]
Minimum primer melting temperature in °C (>= 25).''')
    req.add_argument(
        '--maximum-melting-temperature',
        required=True,
        type=float,
        metavar='\b',
        help='''>>[required float]
Maximum primer melting temperature in °C (<= 95).''')
    req.add_argument(
        '--maximum-repeat-length',
        required=True,
        type=int,
        metavar='\b',
        help='''>>[required integer]
Maximum repeat length shared with input oligos (>= 6).''')
    req.add_argument(
        '--primer-column',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Column name to store primers (overwrites if present).''')
    req.add_argument(
        '--output-file',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Output CSV filename. A ".oligopool.primer.csv" suffix is added if missing.''')
    opt.add_argument(
        '--left-context-column',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Column containing left flanking context for motif exclusion.
At least one of --left-context-column or --right-context-column is required.''')
    opt.add_argument(
        '--right-context-column',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Column containing right flanking context for motif exclusion.
At least one of --left-context-column or --right-context-column is required.''')
    opt.add_argument(
        '--patch-mode',
        dest='patch_mode',
        action='store_true',
        help='''>>[optional switch]
Fill missing values in an existing primer column instead of creating a new column.''')
    opt.add_argument(
        '--incremental',
        dest='patch_mode',
        action='store_true',
        help=argparse.SUPPRESS)
    opt.add_argument(
        '--oligo-set',
        type=str,
        default=None,
        nargs='+',
        metavar='\b',
        help='''>>[optional string]
Comma-separated or space-separated per-oligo set labels (one per input row), or a CSV path
with ID and OligoSet columns.
Primers are designed per set and checked for cross-set compatibility.''')
    opt.add_argument(
        '--paired-primer-column',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Column containing the paired primer to match Tm against.''')
    opt.add_argument(
        '--excluded-motifs',
        type=str,
        nargs='+',
        default=None,
        metavar='\b',
        help='''>>[optional string(s)]
Comma-separated motifs or path(s) to CSV (Exmotif column) / FASTA.''')
    opt.add_argument(
        '--background-directory',
        type=str,
        nargs='+',
        default=None,
        metavar='\b',
        help='''>>[optional string(s)]
Path(s) to background k-mer database(s) from the background command.''')
    opt.add_argument(
        '--random-seed',
        type=int,
        default=None,
        metavar='\b',
        help='''>>[optional integer]
Random seed for reproducible primer design.''')
    _add_common_options(parser, opt)
    return parser

def _add_motif(cmdpar):
    '''Register the motif subcommand parser.'''
    parser = cmdpar.add_parser(
        'motif',
        help=_CLI_DESC['motif'],
        description='Design or add motifs/anchors and write an output CSV.',
        epilog=_notes_epilog('motif'),
        usage=argparse.SUPPRESS,
        formatter_class=OligopoolFormatter,
        add_help=False)
    req = parser.add_argument_group('Required Arguments')
    opt = parser.add_argument_group('Optional Arguments')
    req.add_argument(
        '--input-data',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Path to input CSV with an ID column and DNA sequence columns.''')
    req.add_argument(
        '--oligo-length-limit',
        required=True,
        type=int,
        metavar='\b',
        help='''>>[required integer]
Maximum allowed oligo length for the design (>= 4).''')
    req.add_argument(
        '--motif-sequence-constraint',
        required=True,
        type=_parse_sequence_constraint,
        metavar='\b',
        help='''>>[required string]
IUPAC degenerate sequence constraint or constant motif.
Accepts Python-style expressions (quote as one argument), e.g. "'N'*20", or shorthand
concatenation like "GCC+N*20+CCG".''')
    req.add_argument(
        '--maximum-repeat-length',
        required=True,
        type=int,
        metavar='\b',
        help='''>>[required integer]
Maximum repeat length shared with input oligos (>= 4).''')
    req.add_argument(
        '--motif-column',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Column name to store motifs (overwrites if present).''')
    req.add_argument(
        '--output-file',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Output CSV filename. A ".oligopool.motif.csv" suffix is added if missing.''')
    opt.add_argument(
        '--motif-type',
        type=_parse_type_param,
        default=0,
        metavar='\b',
        help='''>>[optional int/string]
Motif type: 0 or 'per-variant' = non-constant, 1 or 'constant' = constant.
Aliases: 'var', 'non-constant', 'const', 'anchor', 'fixed'. (default: 0).''')
    opt.add_argument(
        '--left-context-column',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Column containing left flanking context for motif exclusion.
At least one of --left-context-column or --right-context-column is required.''')
    opt.add_argument(
        '--right-context-column',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Column containing right flanking context for motif exclusion.
At least one of --left-context-column or --right-context-column is required.''')
    opt.add_argument(
        '--patch-mode',
        dest='patch_mode',
        action='store_true',
        help='''>>[optional switch]
Fill missing values in an existing motif column instead of creating a new column.''')
    opt.add_argument(
        '--incremental',
        dest='patch_mode',
        action='store_true',
        help=argparse.SUPPRESS)
    opt.add_argument(
        '--excluded-motifs',
        type=str,
        nargs='+',
        default=None,
        metavar='\b',
        help='''>>[optional string(s)]
Comma-separated motifs or path(s) to CSV (Exmotif column) / FASTA.''')
    opt.add_argument(
        '--background-directory',
        type=str,
        nargs='+',
        default=None,
        metavar='\b',
        help='''>>[optional string(s)]
Path(s) to background k-mer database(s) from the background command.''')
    opt.add_argument(
        '--random-seed',
        type=int,
        default=None,
        metavar='\b',
        help='''>>[optional integer]
Random seed for reproducible motif design.''')
    _add_common_options(parser, opt)
    return parser

def _add_spacer(cmdpar):
    '''Register the spacer subcommand parser.'''
    parser = cmdpar.add_parser(
        'spacer',
        help=_CLI_DESC['spacer'],
        description='Design or insert spacers and write an output CSV.',
        epilog=_notes_epilog('spacer'),
        usage=argparse.SUPPRESS,
        formatter_class=OligopoolFormatter,
        add_help=False)
    req = parser.add_argument_group('Required Arguments')
    opt = parser.add_argument_group('Optional Arguments')
    req.add_argument(
        '--input-data',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Path to input CSV with an ID column and DNA sequence columns.''')
    req.add_argument(
        '--oligo-length-limit',
        required=True,
        type=int,
        metavar='\b',
        help='''>>[required integer]
Maximum allowed oligo length for the design (>= 4).''')
    req.add_argument(
        '--maximum-repeat-length',
        required=True,
        type=int,
        metavar='\b',
        help='''>>[required integer]
Maximum repeat length shared with input oligos (>= 4).''')
    req.add_argument(
        '--spacer-column',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Column name to store spacers (overwrites if present).''')
    req.add_argument(
        '--output-file',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Output CSV filename. A ".oligopool.spacer.csv" suffix is added if missing.''')
    opt.add_argument(
        '--spacer-length',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Spacer length as an integer, a comma-separated list, or a CSV path
with ID and SpacerLength columns. If omitted, spacer length is auto-set
to reach the oligo length limit.''')
    opt.add_argument(
        '--left-context-column',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Column containing left flanking context for motif exclusion.
At least one of --left-context-column or --right-context-column is required.''')
    opt.add_argument(
        '--right-context-column',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Column containing right flanking context for motif exclusion.
At least one of --left-context-column or --right-context-column is required.''')
    opt.add_argument(
        '--patch-mode',
        dest='patch_mode',
        action='store_true',
        help='''>>[optional switch]
Fill missing values in an existing spacer column instead of creating a new column.''')
    opt.add_argument(
        '--incremental',
        dest='patch_mode',
        action='store_true',
        help=argparse.SUPPRESS)
    opt.add_argument(
        '--excluded-motifs',
        type=str,
        nargs='+',
        default=None,
        metavar='\b',
        help='''>>[optional string(s)]
Comma-separated motifs or path(s) to CSV (Exmotif column) / FASTA.''')
    opt.add_argument(
        '--background-directory',
        type=str,
        nargs='+',
        default=None,
        metavar='\b',
        help='''>>[optional string(s)]
Path(s) to background k-mer database(s) from the background command.''')
    opt.add_argument(
        '--random-seed',
        type=int,
        default=None,
        metavar='\b',
        help='''>>[optional integer]
Random seed for reproducible spacer design.''')
    _add_common_options(parser, opt)
    return parser

def _add_split(cmdpar):
    '''Register the split subcommand parser.'''
    parser = cmdpar.add_parser(
        'split',
        help=_CLI_DESC['split'],
        description='Split long oligos into shorter ones and write an output CSV.',
        epilog=_notes_epilog('split'),
        usage=argparse.SUPPRESS,
        formatter_class=OligopoolFormatter,
        add_help=False)
    req = parser.add_argument_group('Required Arguments')
    opt = parser.add_argument_group('Optional Arguments')
    req.add_argument(
        '--input-data',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Path to input CSV with an ID column and DNA sequence columns.''')
    req.add_argument(
        '--split-length-limit',
        required=True,
        type=int,
        metavar='\b',
        help='''>>[required integer]
Maximum allowed length for split fragments (>= 4).''')
    req.add_argument(
        '--minimum-melting-temperature',
        required=True,
        type=float,
        metavar='\b',
        help='''>>[required float]
Minimum overlap melting temperature in °C (>= 4).''')
    req.add_argument(
        '--minimum-hamming-distance',
        required=True,
        type=int,
        metavar='\b',
        help='''>>[required integer]
Minimum pairwise Hamming distance for overlaps (>= 1).''')
    req.add_argument(
        '--minimum-overlap-length',
        required=True,
        type=int,
        metavar='\b',
        help='''>>[required integer]
Minimum overlap length in bp (>= 15).''')
    req.add_argument(
        '--maximum-overlap-length',
        required=True,
        type=int,
        metavar='\b',
        help='''>>[required integer]
Maximum overlap length in bp (>= 15).''')
    req.add_argument(
        '--output-file',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Output CSV filename. A ".oligopool.split.csv" suffix is added if missing.''')
    opt.add_argument(
        '--separate-outputs',
        action='store_true',
        default=True,
        help='''>>[optional switch]
Write separate CSV files per split fragment (e.g., output.Split1.oligopool.split.csv).
Enabled by default in CLI mode. Use --no-separate-outputs to write a single combined file.''')
    opt.add_argument(
        '--no-separate-outputs',
        action='store_false',
        dest='separate_outputs',
        help='''>>[optional switch]
Write a single combined CSV with all Split columns instead of separate files.''')
    opt.add_argument(
        '--random-seed',
        type=int,
        default=None,
        metavar='\b',
        help='''>>[optional integer]
Random seed for reproducible split decisions.''')
    _add_common_options(parser, opt)
    return parser

def _add_pad(cmdpar):
    '''Register the pad subcommand parser.'''
    parser = cmdpar.add_parser(
        'pad',
        help=_CLI_DESC['pad'],
        description='Add excisable primer pads to split fragments for scarless overlap-based assembly.',
        epilog=_notes_epilog('pad'),
        usage=argparse.SUPPRESS,
        formatter_class=OligopoolFormatter,
        add_help=False)
    req = parser.add_argument_group('Required Arguments')
    opt = parser.add_argument_group('Optional Arguments')
    req.add_argument(
        '--input-data',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Path to input CSV with an ID column and DNA sequence columns.''')
    req.add_argument(
        '--oligo-length-limit',
        required=True,
        type=int,
        metavar='\b',
        help='''>>[required integer]
Maximum allowed padded oligo length (>= 60).''')
    req.add_argument(
        '--split-column',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Column name containing split fragments to pad.''')
    req.add_argument(
        '--typeiis-system',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Type IIS enzyme name for pad excision (see pad() docs for options).''')
    req.add_argument(
        '--minimum-melting-temperature',
        required=True,
        type=float,
        metavar='\b',
        help='''>>[required float]
Minimum padding primer melting temperature in °C (>= 25).''')
    req.add_argument(
        '--maximum-melting-temperature',
        required=True,
        type=float,
        metavar='\b',
        help='''>>[required float]
Maximum padding primer melting temperature in °C (<= 95).''')
    req.add_argument(
        '--maximum-repeat-length',
        required=True,
        type=int,
        metavar='\b',
        help='''>>[required integer]
Maximum repeat length shared with input oligos (6 to 20).''')
    req.add_argument(
        '--output-file',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Output CSV filename. A ".oligopool.pad.csv" suffix is added if missing.''')
    opt.add_argument(
        '--random-seed',
        type=int,
        default=None,
        metavar='\b',
        help='''>>[optional integer]
Random seed for reproducible padding.''')
    _add_common_options(parser, opt)
    return parser

def _add_merge(cmdpar):
    '''Register the merge subcommand parser.'''
    parser = cmdpar.add_parser(
        'merge',
        help=_CLI_DESC['merge'],
        description='Merge oligo elements into one column and write an output CSV.',
        epilog=_notes_epilog('merge'),
        usage=argparse.SUPPRESS,
        formatter_class=OligopoolFormatter,
        add_help=False)
    req = parser.add_argument_group('Required Arguments')
    opt = parser.add_argument_group('Optional Arguments')
    req.add_argument(
        '--input-data',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Path to input CSV with an ID column and DNA sequence columns.''')
    req.add_argument(
        '--merge-column',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Column name to store merged DNA (overwrites if present).''')
    req.add_argument(
        '--output-file',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Output CSV filename. A ".oligopool.merge.csv" suffix is added if missing.''')
    opt.add_argument(
        '--left-context-column',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Left boundary column for merging; defaults to first column if omitted.''')
    opt.add_argument(
        '--right-context-column',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Right boundary column for merging; defaults to last column if omitted.''')
    _add_common_options(parser, opt)
    return parser

def _add_join(cmdpar):
    '''Register the join subcommand parser.'''
    parser = cmdpar.add_parser(
        'join',
        help=_CLI_DESC['join'],
        description='Join two oligo tables by ID and write an output CSV.',
        epilog=_notes_epilog('join'),
        usage=argparse.SUPPRESS,
        formatter_class=OligopoolFormatter,
        add_help=False)
    req = parser.add_argument_group('Required Arguments')
    opt = parser.add_argument_group('Optional Arguments')
    req.add_argument(
        '--input-data',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Path to backbone input CSV with an ID column and DNA sequence columns.''')
    req.add_argument(
        '--other-data',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Path to second input CSV with the same IDs as --input-data.''')
    req.add_argument(
        '--oligo-length-limit',
        required=True,
        type=int,
        metavar='\b',
        help='''>>[required integer]
Maximum allowed oligo length for the joined design (>= 4).''')
    req.add_argument(
        '--join-policy',
        required=True,
        type=_parse_type_param,
        metavar='\b',
        help='''>>[required int/string]
Ambiguity resolution policy for intelligent column insertion:
0 or 'left' = left-biased, 1 or 'right' = right-biased.
Aliases: 'l', 'r'.''')
    req.add_argument(
        '--output-file',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Output CSV filename. A ".oligopool.join.csv" suffix is added if missing.''')
    _add_common_options(parser, opt)
    return parser

def _add_revcomp(cmdpar):
    '''Register the revcomp subcommand parser.'''
    parser = cmdpar.add_parser(
        'revcomp',
        help=_CLI_DESC['revcomp'],
        description='Reverse complement spanning elements and write an output CSV.',
        epilog=_notes_epilog('revcomp'),
        usage=argparse.SUPPRESS,
        formatter_class=OligopoolFormatter,
        add_help=False)
    req = parser.add_argument_group('Required Arguments')
    opt = parser.add_argument_group('Optional Arguments')
    req.add_argument(
        '--input-data',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Path to input CSV with an ID column and DNA sequence columns.''')
    req.add_argument(
        '--output-file',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Output CSV filename. A ".oligopool.revcomp.csv" suffix is added if missing.''')
    opt.add_argument(
        '--left-context-column',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Left boundary column to reverse-complement; defaults to first column.''')
    opt.add_argument(
        '--right-context-column',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Right boundary column to reverse-complement; defaults to last column.''')
    _add_common_options(parser, opt)
    return parser

def _add_verify(cmdpar):
    '''Register the verify subcommand parser.'''
    parser = cmdpar.add_parser(
        'verify',
        help=_CLI_DESC['verify'],
        description='Detect length, motif, and background k-mer conflicts in an oligo pool.',
        epilog=_notes_epilog('verify'),
        usage=argparse.SUPPRESS,
        formatter_class=OligopoolFormatter,
        add_help=False)
    req = parser.add_argument_group('Required Arguments')
    opt = parser.add_argument_group('Optional Arguments')
    req.add_argument(
        '--input-data',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Path to input CSV with an ID column and DNA columns.''')
    req.add_argument(
        '--oligo-length-limit',
        required=True,
        type=int,
        metavar='\b',
        help='''>>[required integer]
Maximum allowed oligo length (>= 4).''')
    req.add_argument(
        '--output-file',
        type=str,
        required=True,
        metavar='\b',
        help='''>>[required string]
Output CSV filename. A ".oligopool.verify.csv" suffix is added if missing.''')
    opt.add_argument(
        '--excluded-motifs',
        type=str,
        nargs='+',
        default=None,
        metavar='\b',
        help='''>>[optional string(s)]
Comma-separated motifs or path(s) to CSV (Exmotif column) / FASTA.''')
    opt.add_argument(
        '--background-directory',
        type=str,
        nargs='+',
        default=None,
        metavar='\b',
        help='''>>[optional string(s)]
Path(s) to background k-mer database(s) from the background command.''')
    _add_common_options(parser, opt)
    return parser

def _add_lenstat(cmdpar):
    '''Register the lenstat subcommand parser.'''
    parser = cmdpar.add_parser(
        'lenstat',
        help=_CLI_DESC['lenstat'],
        description='Compute length statistics (prints results; no output file).',
        epilog=_notes_epilog('lenstat'),
        usage=argparse.SUPPRESS,
        formatter_class=OligopoolFormatter,
        add_help=False)
    req = parser.add_argument_group('Required Arguments')
    opt = parser.add_argument_group('Optional Arguments')
    req.add_argument(
        '--input-data',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Path to input CSV with an ID column and DNA sequence columns.''')
    req.add_argument(
        '--oligo-length-limit',
        required=True,
        type=int,
        metavar='\b',
        help='''>>[required integer]
Maximum allowed oligo length for reporting (>= 4).''')
    _add_common_options(parser, opt)
    return parser

def _add_inspect(cmdpar):
    '''Register the inspect subcommand parser.'''
    parser = cmdpar.add_parser(
        'inspect',
        help=_CLI_DESC['inspect'],
        description='Inspect non-CSV artifacts (background DBs, index files, pack files).',
        epilog=_notes_epilog('inspect'),
        usage=argparse.SUPPRESS,
        formatter_class=OligopoolFormatter,
        add_help=False)
    req = parser.add_argument_group('Required Arguments')
    opt = parser.add_argument_group('Optional Arguments')
    req.add_argument(
        '--target',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Artifact path: background directory, ".oligopool.index", or ".oligopool.pack".''')
    opt.add_argument(
        '--kind',
        type=str,
        default='auto',
        metavar='\b',
        help='''>>[optional string]
Artifact kind: background, index, pack, or auto.
(default: auto).''')
    _add_common_options(parser, opt)
    return parser

def _add_final(cmdpar):
    '''Register the final subcommand parser.'''
    parser = cmdpar.add_parser(
        'final',
        help=_CLI_DESC['final'],
        description='Finalize the library and write an output CSV.',
        epilog=_notes_epilog('final'),
        usage=argparse.SUPPRESS,
        formatter_class=OligopoolFormatter,
        add_help=False)
    req = parser.add_argument_group('Required Arguments')
    opt = parser.add_argument_group('Optional Arguments')
    req.add_argument(
        '--input-data',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Path to input CSV with an ID column and DNA sequence columns.''')
    req.add_argument(
        '--output-file',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Output CSV filename for the finalized library.
A ".oligopool.final.csv" suffix is added if missing.''')
    _add_common_options(parser, opt)
    return parser

def _add_compress(cmdpar):
    '''Register the compress subcommand parser.'''
    parser = cmdpar.add_parser(
        'compress',
        help=_CLI_DESC['compress'],
        description='Compress DNA sequences into IUPAC-degenerate representation for Degenerate Mode.',
        epilog=_notes_epilog('compress'),
        usage=argparse.SUPPRESS,
        formatter_class=OligopoolFormatter,
        add_help=False)
    req = parser.add_argument_group('Required Arguments')
    opt = parser.add_argument_group('Optional Arguments')
    req.add_argument(
        '--input-data',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Path to input CSV with an ID column and DNA sequence columns.
All columns except ID are concatenated as the sequence.''')
    opt.add_argument(
        '--mapping-file',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Output filename for variant-to-degenerate mapping.
A ".oligopool.compress.mapping.csv" suffix is added if missing.''')
    opt.add_argument(
        '--synthesis-file',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Output filename for degenerate oligos ready for synthesis.
A ".oligopool.compress.synthesis.csv" suffix is added if missing.''')
    opt.add_argument(
        '--rollout-simulations',
        type=int,
        default=100,
        metavar='\b',
        help='''>>[optional integer]
Monte Carlo rollouts per decision (default: 100).''')
    opt.add_argument(
        '--rollout-horizon',
        type=int,
        default=4,
        metavar='\b',
        help='''>>[optional integer]
Lookahead positions for rollouts (default: 4).''')
    opt.add_argument(
        '--random-seed',
        type=int,
        default=None,
        metavar='\b',
        help='''>>[optional integer]
Random seed for reproducible compression.''')
    _add_common_options(parser, opt)
    return parser

def _add_expand(cmdpar):
    '''Register the expand subcommand parser.'''
    parser = cmdpar.add_parser(
        'expand',
        help=_CLI_DESC['expand'],
        description='Expand IUPAC-degenerate sequences into concrete A/T/G/C sequences.',
        epilog=_notes_epilog('expand'),
        usage=argparse.SUPPRESS,
        formatter_class=OligopoolFormatter,
        add_help=False)
    req = parser.add_argument_group('Required Arguments')
    opt = parser.add_argument_group('Optional Arguments')
    req.add_argument(
        '--input-data',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Path to input CSV with degenerate sequences.
Must contain an ID column (or DegenerateID from compress output).''')
    req.add_argument(
        '--sequence-column',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Column name containing IUPAC-degenerate sequences to expand.''')
    opt.add_argument(
        '--mapping-file',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Path to mapping CSV with ID and DegenerateID columns (from compress).
If provided, restores original variant IDs in output.''')
    opt.add_argument(
        '--output-file',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Output filename for expanded sequences.
A ".oligopool.expand.csv" suffix is added if missing.''')
    opt.add_argument(
        '--expansion-limit',
        type=int,
        default=None,
        metavar='\b',
        help='''>>[optional integer]
Maximum total expanded sequences (safety cap) (default: unlimited).''')
    _add_common_options(parser, opt)
    return parser

def _add_index(cmdpar):
    '''Register the index subcommand parser.'''
    parser = cmdpar.add_parser(
        'index',
        help=_CLI_DESC['index'],
        description='Index barcodes (and optionally associates) into an index file.',
        epilog=_notes_epilog('index'),
        usage=argparse.SUPPRESS,
        formatter_class=OligopoolFormatter,
        add_help=False)
    req = parser.add_argument_group('Required Arguments')
    opt = parser.add_argument_group('Optional Arguments')
    req.add_argument(
        '--barcode-data',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Path to barcode CSV with an ID column and barcode column.''')
    req.add_argument(
        '--barcode-column',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Column name containing barcodes to index.''')
    req.add_argument(
        '--index-file',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Output index filename. A ".oligopool.index" suffix is added if missing.''')
    opt.add_argument(
        '--barcode-prefix-column',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Column with constant barcode prefix anchor.
At least one of --barcode-prefix-column or --barcode-suffix-column is required.''')
    opt.add_argument(
        '--barcode-suffix-column',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Column with constant barcode suffix anchor.
At least one of --barcode-prefix-column or --barcode-suffix-column is required.''')
    opt.add_argument(
        '--barcode-prefix-gap',
        type=int,
        default=0,
        metavar='\b',
        help='''>>[optional integer]
Distance in bp between prefix anchor and barcode in reads.
(default: 0).''')
    opt.add_argument(
        '--barcode-suffix-gap',
        type=int,
        default=0,
        metavar='\b',
        help='''>>[optional integer]
Distance in bp between suffix anchor and barcode in reads.
(default: 0).''')
    opt.add_argument(
        '--associate-data',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Path to associate CSV with an ID column and associate column.''')
    opt.add_argument(
        '--associate-column',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Column name containing associate sequences to index.''')
    opt.add_argument(
        '--associate-prefix-column',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Column with constant associate prefix anchor.
At least one of --associate-prefix-column or --associate-suffix-column is required.''')
    opt.add_argument(
        '--associate-suffix-column',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Column with constant associate suffix anchor.
At least one of --associate-prefix-column or --associate-suffix-column is required.''')
    opt.add_argument(
        '--associate-prefix-gap',
        type=int,
        default=0,
        metavar='\b',
        help='''>>[optional integer]
Distance in bp between prefix anchor and associate in reads.
(default: 0).''')
    opt.add_argument(
        '--associate-suffix-gap',
        type=int,
        default=0,
        metavar='\b',
        help='''>>[optional integer]
Distance in bp between suffix anchor and associate in reads.
(default: 0).''')
    _add_common_options(parser, opt)
    return parser

def _add_pack(cmdpar):
    '''Register the pack subcommand parser.'''
    parser = cmdpar.add_parser(
        'pack',
        help=_CLI_DESC['pack'],
        description='Preprocess and pack FastQ reads for counting into a pack file.',
        epilog=_notes_epilog('pack'),
        usage=argparse.SUPPRESS,
        formatter_class=OligopoolFormatter,
        add_help=False)
    req = parser.add_argument_group('Required Arguments')
    opt = parser.add_argument_group('Optional Arguments')
    req.add_argument(
        '--r1-fastq-file',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Path to R1 FastQ file (gzipped ok).''')
    req.add_argument(
        '--r1-read-type',
        required=True,
        type=_parse_type_param,
        metavar='\b',
        help='''>>[required int/string]
R1 orientation: 0 or 'forward' for forward, 1 or 'reverse' for reverse.
Aliases: 'fwd', 'f', 'rev', 'r'.''')
    req.add_argument(
        '--pack-type',
        required=True,
        type=_parse_type_param,
        metavar='\b',
        help='''>>[required int/string]
Pack storage mode: 0 or 'concatenated' for concatenated, 1 or 'merged' for merged.
Aliases: 'concatenate', 'concat', 'cat', 'joined', 'join', 'merge', 'assemble', 'assembled', 'asm'.''')
    req.add_argument(
        '--pack-file',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Output pack filename. A ".oligopool.pack" suffix is added if missing.''')
    opt.add_argument(
        '--minimum-r1-read-length',
        type=int,
        default=1,
        metavar='\b',
        help='''>>[optional integer]
Minimum R1 read length (default: 1).''')
    opt.add_argument(
        '--minimum-r1-read-quality',
        type=int,
        default=20,
        metavar='\b',
        help='''>>[optional integer]
Minimum average R1 read quality (default: 20).''')
    opt.add_argument(
        '--r2-fastq-file',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Path to R2 FastQ file (paired-end only).''')
    opt.add_argument(
        '--r2-read-type',
        type=_parse_type_param,
        default=None,
        metavar='\b',
        help='''>>[optional int/string]
R2 orientation: 0 or 'forward' for forward, 1 or 'reverse' for reverse.
Aliases: 'fwd', 'f', 'rev', 'r'.''')
    opt.add_argument(
        '--minimum-r2-read-length',
        type=int,
        default=None,
        metavar='\b',
        help='''>>[optional integer]
Minimum R2 read length (default: None).''')
    opt.add_argument(
        '--minimum-r2-read-quality',
        type=int,
        default=None,
        metavar='\b',
        help='''>>[optional integer]
Minimum average R2 read quality (default: None).''')
    opt.add_argument(
        '--pack-size',
        type=float,
        default=3.0,
        metavar='\b',
        help='''>>[optional float]
Target million unique reads per pack (0.1 to 5.0).
(default: 3.0).''')
    opt.add_argument(
        '--core-count',
        type=int,
        default=0,
        metavar='\b',
        help='''>>[optional integer]
CPU cores to use (0 = auto).''')
    opt.add_argument(
        '--memory-limit',
        type=float,
        default=0.0,
        metavar='\b',
        help='''>>[optional float]
GB of memory per core (0 = auto).''')
    _add_common_options(parser, opt)
    return parser

def _add_acount(cmdpar):
    '''Register the acount subcommand parser.'''
    parser = cmdpar.add_parser(
        'acount',
        help=_CLI_DESC['acount'],
        description='Execute association counting and write a count matrix CSV.',
        epilog=_notes_epilog('acount'),
        usage=argparse.SUPPRESS,
        formatter_class=OligopoolFormatter,
        add_help=False)
    req = parser.add_argument_group('Required Arguments')
    opt = parser.add_argument_group('Optional Arguments')
    req.add_argument(
        '--index-file',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Index file path. A ".oligopool.index" suffix is added if missing.''')
    req.add_argument(
        '--pack-file',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Pack file path. A ".oligopool.pack" suffix is added if missing.''')
    req.add_argument(
        '--count-file',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Output count matrix filename. A ".oligopool.acount.csv" suffix is added if missing.''')
    opt.add_argument(
        '--mapping-type',
        type=_parse_type_param,
        default=0,
        metavar='\b',
        help='''>>[optional int/string]
Mapping mode: 0 or 'fast' for fast, 1 or 'sensitive' for sensitive.
Aliases: 'quick', 'near-exact', 'sens', 'accurate', 'slow'. (default: 0).''')
    opt.add_argument(
        '--barcode-errors',
        type=int,
        default=-1,
        metavar='\b',
        help='''>>[optional integer]
Max barcode errors (-1 = auto-infer).
(default: -1).''')
    opt.add_argument(
        '--associate-errors',
        type=int,
        default=-1,
        metavar='\b',
        help='''>>[optional integer]
Max associate errors (-1 = auto-infer).
(default: -1).''')
    opt.add_argument(
        '--core-count',
        type=int,
        default=0,
        metavar='\b',
        help='''>>[optional integer]
CPU cores to use (0 = auto).''')
    opt.add_argument(
        '--memory-limit',
        type=float,
        default=0.0,
        metavar='\b',
        help='''>>[optional float]
GB of memory per core (0 = auto).''')
    opt.add_argument(
        '--failed-reads-file',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Output CSV path for failed read samples.
A ".oligopool.acount.failedreads.csv" suffix is added if missing.''')
    opt.add_argument(
        '--failed-reads-sample-size',
        type=int,
        default=1000,
        metavar='\b',
        help='''>>[optional integer]
Maximum samples per failure category.
(default: 1000).''')
    _add_common_options(parser, opt)
    return parser

def _add_xcount(cmdpar):
    '''Register the xcount subcommand parser.'''
    parser = cmdpar.add_parser(
        'xcount',
        help=_CLI_DESC['xcount'],
        description='Execute combinatorial counting and write a count matrix CSV.',
        epilog=_notes_epilog('xcount'),
        usage=argparse.SUPPRESS,
        formatter_class=OligopoolFormatter,
        add_help=False)
    req = parser.add_argument_group('Required Arguments')
    opt = parser.add_argument_group('Optional Arguments')
    req.add_argument(
        '--index-files',
        required=True,
        type=str,
        nargs='+',
        metavar='\b',
        help='''>>[required string]
Comma-separated or space-separated list of index files for combinatorial counting.
A ".oligopool.index" suffix is added to each path if missing.''')
    req.add_argument(
        '--pack-file',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Pack file path. A ".oligopool.pack" suffix is added if missing.''')
    req.add_argument(
        '--count-file',
        required=True,
        type=str,
        metavar='\b',
        help='''>>[required string]
Output count matrix filename. A ".oligopool.xcount.csv" suffix is added if missing.''')
    opt.add_argument(
        '--mapping-type',
        type=_parse_type_param,
        default=0,
        metavar='\b',
        help='''>>[optional int/string]
Mapping mode: 0 or 'fast' for fast, 1 or 'sensitive' for sensitive.
Aliases: 'quick', 'near-exact', 'sens', 'accurate', 'slow'. (default: 0).''')
    opt.add_argument(
        '--barcode-errors',
        type=int,
        default=-1,
        metavar='\b',
        help='''>>[optional integer]
Max barcode errors (-1 = auto-infer).
(default: -1).''')
    opt.add_argument(
        '--core-count',
        type=int,
        default=0,
        metavar='\b',
        help='''>>[optional integer]
CPU cores to use (0 = auto).''')
    opt.add_argument(
        '--memory-limit',
        type=float,
        default=0.0,
        metavar='\b',
        help='''>>[optional float]
GB of memory per core (0 = auto).''')
    opt.add_argument(
        '--failed-reads-file',
        type=str,
        default=None,
        metavar='\b',
        help='''>>[optional string]
Output CSV path for failed read samples.
A ".oligopool.xcount.failedreads.csv" suffix is added if missing.''')
    opt.add_argument(
        '--failed-reads-sample-size',
        type=int,
        default=1000,
        metavar='\b',
        help='''>>[optional integer]
Maximum samples per failure category.
(default: 1000).''')
    _add_common_options(parser, opt)
    return parser

def _get_parsers():
    '''Create and return the top-level CLI parser.'''
    mainpar = OligopoolParser(
        prog='oligopool',
        usage='oligopool COMMAND --argument=<value> ...',
        formatter_class=OligopoolFormatter,
        description=None,
        add_help=False,
        epilog='''Run "oligopool COMMAND" to see command-specific options.''')
    mainpar._positionals.title = 'COMMANDS Available'
    mainpar._optionals.title = 'Optional Arguments'

    cmdpar = mainpar.add_subparsers(
        metavar=' ',
        dest='command',
        required=True)

    _add_manual(cmdpar)
    _add_cite(cmdpar)
    _add_pipeline(cmdpar)
    _add_barcode(cmdpar)
    _add_primer(cmdpar)
    _add_motif(cmdpar)
    _add_spacer(cmdpar)
    _add_background(cmdpar)
    _add_split(cmdpar)
    _add_pad(cmdpar)
    _add_merge(cmdpar)
    _add_revcomp(cmdpar)
    _add_join(cmdpar)
    _add_lenstat(cmdpar)
    _add_verify(cmdpar)
    _add_final(cmdpar)
    _add_compress(cmdpar)
    _add_expand(cmdpar)
    _add_index(cmdpar)
    _add_pack(cmdpar)
    _add_acount(cmdpar)
    _add_xcount(cmdpar)
    _add_inspect(cmdpar)
    _add_complete(cmdpar)

    return mainpar


# === Entry point ===

def main(argv=None):
    '''CLI entry point for Oligopool Calculator.

    Exit codes: 0 (success), 1 (runtime error or failed stats), 404 (argparse error).
    '''
    arg_list = sys.argv[1:] if argv is None else list(argv)
    parser = _get_parsers()
    argcomplete.autocomplete(parser)
    # Keep stdout clean for machine-readable JSON output.
    command = arg_list[0] if arg_list else None
    show_banner = ('--stats-json' not in arg_list) and (command != 'complete')
    if show_banner:
        _print_header()
    if not arg_list:
        if show_banner:
            print(MAIN_MENU_DESCRIPTION)
            print()
        parser.print_help()
        if show_banner:
            _print_footer()
        return 0

    # Inject config values into arg list before parsing (for required arg checks)
    if command and command not in ('manual', 'cite', 'complete', 'pipeline'):
        arg_list = _inject_config_args(arg_list, command, parser)

    args = parser.parse_args(arg_list)

    # Load config file for pipeline command (handled separately)
    config = None
    if args.command == 'pipeline' and hasattr(args, 'config') and args.config is not None:
        try:
            config = load_config(args.config)
        except FileNotFoundError:
            print(f'error: Config file not found: {args.config}', file=sys.stderr)
            return 1
        except Exception as exc:
            print(f'error: Failed to load config: {exc}', file=sys.stderr)
            return 1

    try:
        match args.command:
            case 'manual':
                exit_code = _print_manual(args.topic)
                if show_banner:
                    _print_footer()
                return exit_code
            case 'cite':
                exit_code = _print_cite()
                if show_banner:
                    _print_footer()
                return exit_code
            case 'complete':
                exit_code = _run_complete(args.shell, args.install, args.print_instructions)
                if show_banner:
                    _print_footer()
                return exit_code
            case 'pipeline':
                if config is None:
                    print('error: --config is required for pipeline command.', file=sys.stderr)
                    return 1
                exit_code = _run_pipeline(config, args.dry_run, args.verbose)
                if show_banner:
                    _print_footer()
                return exit_code
            case 'background':
                background = _load_api_func('background')
                result = background(
                    input_data=args.input_data,
                    maximum_repeat_length=args.maximum_repeat_length,
                    output_directory=args.output_directory,
                    verbose=args.verbose)
            case 'barcode':
                barcode = _load_api_func('barcode')
                cross_columns = _parse_list_str(args.cross_barcode_columns)
                if isinstance(cross_columns, str):
                    cross_columns = [cross_columns]
                if (cross_columns is None) ^ (args.minimum_cross_distance is None):
                    raise ValueError(
                        'cross_barcode_columns and minimum_cross_distance must be set together')
                result = barcode(
                    input_data=args.input_data,
                    oligo_length_limit=args.oligo_length_limit,
                    barcode_length=args.barcode_length,
                    minimum_hamming_distance=args.minimum_hamming_distance,
                    maximum_repeat_length=args.maximum_repeat_length,
                    barcode_column=args.barcode_column,
                    output_file=args.output_file,
                    barcode_type=args.barcode_type,
                    left_context_column=args.left_context_column,
                    right_context_column=args.right_context_column,
                    patch_mode=args.patch_mode,
                    cross_barcode_columns=cross_columns,
                    minimum_cross_distance=args.minimum_cross_distance,
                    excluded_motifs=_parse_excluded_motifs_arg(args.excluded_motifs),
                    background_directory=_parse_list_str(args.background_directory),
                    random_seed=args.random_seed,
                    verbose=args.verbose)
            case 'primer':
                primer = _load_api_func('primer')
                oligo_set = args.oligo_set
                if isinstance(oligo_set, list):
                    oligo_set = _parse_list_str(oligo_set)
                    if isinstance(oligo_set, list) and len(oligo_set) == 1 and _looks_like_path(oligo_set[0]):
                        oligo_set = oligo_set[0]
                result = primer(
                    input_data=args.input_data,
                    oligo_length_limit=args.oligo_length_limit,
                    primer_sequence_constraint=args.primer_sequence_constraint,
                    primer_type=args.primer_type,
                    minimum_melting_temperature=args.minimum_melting_temperature,
                    maximum_melting_temperature=args.maximum_melting_temperature,
                    maximum_repeat_length=args.maximum_repeat_length,
                    primer_column=args.primer_column,
                    output_file=args.output_file,
                    left_context_column=args.left_context_column,
                    right_context_column=args.right_context_column,
                    patch_mode=args.patch_mode,
                    oligo_set=oligo_set,
                    paired_primer_column=args.paired_primer_column,
                    excluded_motifs=_parse_excluded_motifs_arg(args.excluded_motifs),
                    background_directory=_parse_list_str(args.background_directory),
                    random_seed=args.random_seed,
                    verbose=args.verbose)
            case 'motif':
                motif = _load_api_func('motif')
                result = motif(
                    input_data=args.input_data,
                    oligo_length_limit=args.oligo_length_limit,
                    motif_sequence_constraint=args.motif_sequence_constraint,
                    maximum_repeat_length=args.maximum_repeat_length,
                    motif_column=args.motif_column,
                    output_file=args.output_file,
                    motif_type=args.motif_type,
                    left_context_column=args.left_context_column,
                    right_context_column=args.right_context_column,
                    patch_mode=args.patch_mode,
                    excluded_motifs=_parse_excluded_motifs_arg(args.excluded_motifs),
                    background_directory=_parse_list_str(args.background_directory),
                    random_seed=args.random_seed,
                    verbose=args.verbose)
            case 'spacer':
                spacer = _load_api_func('spacer')
                result = spacer(
                    input_data=args.input_data,
                    oligo_length_limit=args.oligo_length_limit,
                    maximum_repeat_length=args.maximum_repeat_length,
                    spacer_column=args.spacer_column,
                    output_file=args.output_file,
                    spacer_length=_parse_list_int(args.spacer_length),
                    left_context_column=args.left_context_column,
                    right_context_column=args.right_context_column,
                    patch_mode=args.patch_mode,
                    excluded_motifs=_parse_excluded_motifs_arg(args.excluded_motifs),
                    background_directory=_parse_list_str(args.background_directory),
                    random_seed=args.random_seed,
                    verbose=args.verbose)
            case 'split':
                split = _load_api_func('split')
                result = split(
                    input_data=args.input_data,
                    split_length_limit=args.split_length_limit,
                    minimum_melting_temperature=args.minimum_melting_temperature,
                    minimum_hamming_distance=args.minimum_hamming_distance,
                    minimum_overlap_length=args.minimum_overlap_length,
                    maximum_overlap_length=args.maximum_overlap_length,
                    output_file=args.output_file,
                    separate_outputs=args.separate_outputs,
                    random_seed=args.random_seed,
                    verbose=args.verbose)
            case 'pad':
                pad = _load_api_func('pad')
                result = pad(
                    input_data=args.input_data,
                    oligo_length_limit=args.oligo_length_limit,
                    split_column=args.split_column,
                    typeIIS_system=args.typeiis_system,
                    minimum_melting_temperature=args.minimum_melting_temperature,
                    maximum_melting_temperature=args.maximum_melting_temperature,
                    maximum_repeat_length=args.maximum_repeat_length,
                    output_file=args.output_file,
                    random_seed=args.random_seed,
                    verbose=args.verbose)
            case 'merge':
                merge = _load_api_func('merge')
                result = merge(
                    input_data=args.input_data,
                    merge_column=args.merge_column,
                    output_file=args.output_file,
                    left_context_column=args.left_context_column,
                    right_context_column=args.right_context_column,
                    verbose=args.verbose)
            case 'join':
                join = _load_api_func('join')
                result = join(
                    input_data=args.input_data,
                    other_data=args.other_data,
                    oligo_length_limit=args.oligo_length_limit,
                    join_policy=args.join_policy,
                    output_file=args.output_file,
                    verbose=args.verbose)
            case 'revcomp':
                revcomp = _load_api_func('revcomp')
                result = revcomp(
                    input_data=args.input_data,
                    output_file=args.output_file,
                    left_context_column=args.left_context_column,
                    right_context_column=args.right_context_column,
                    verbose=args.verbose)
            case 'verify':
                verify = _load_api_func('verify')
                result = verify(
                    input_data=args.input_data,
                    oligo_length_limit=args.oligo_length_limit,
                    output_file=args.output_file,
                    excluded_motifs=_parse_excluded_motifs_arg(args.excluded_motifs),
                    background_directory=_parse_list_str(args.background_directory),
                    verbose=args.verbose)
            case 'lenstat':
                lenstat = _load_api_func('lenstat')
                result = lenstat(
                    input_data=args.input_data,
                    oligo_length_limit=args.oligo_length_limit,
                    verbose=args.verbose)
            case 'inspect':
                inspect = _load_api_func('inspect')
                result = inspect(
                    target=args.target,
                    kind=args.kind,
                    verbose=args.verbose)
            case 'final':
                final = _load_api_func('final')
                result = final(
                    input_data=args.input_data,
                    output_file=args.output_file,
                    verbose=args.verbose)
            case 'compress':
                compress = _load_api_func('compress')
                result = compress(
                    input_data=args.input_data,
                    mapping_file=args.mapping_file,
                    synthesis_file=args.synthesis_file,
                    rollout_simulations=args.rollout_simulations,
                    rollout_horizon=args.rollout_horizon,
                    random_seed=args.random_seed,
                    verbose=args.verbose)
            case 'expand':
                expand = _load_api_func('expand')
                result = expand(
                    input_data=args.input_data,
                    sequence_column=args.sequence_column,
                    mapping_file=args.mapping_file,
                    output_file=args.output_file,
                    expansion_limit=args.expansion_limit,
                    verbose=args.verbose)
            case 'index':
                index = _load_api_func('index')
                result = index(
                    barcode_data=args.barcode_data,
                    barcode_column=args.barcode_column,
                    index_file=args.index_file,
                    barcode_prefix_column=args.barcode_prefix_column,
                    barcode_suffix_column=args.barcode_suffix_column,
                    barcode_prefix_gap=args.barcode_prefix_gap,
                    barcode_suffix_gap=args.barcode_suffix_gap,
                    associate_data=args.associate_data,
                    associate_column=args.associate_column,
                    associate_prefix_column=args.associate_prefix_column,
                    associate_suffix_column=args.associate_suffix_column,
                    associate_prefix_gap=args.associate_prefix_gap,
                    associate_suffix_gap=args.associate_suffix_gap,
                    verbose=args.verbose)
            case 'pack':
                pack = _load_api_func('pack')
                result = pack(
                    r1_fastq_file=args.r1_fastq_file,
                    r1_read_type=args.r1_read_type,
                    pack_type=args.pack_type,
                    pack_file=args.pack_file,
                    minimum_r1_read_length=args.minimum_r1_read_length,
                    minimum_r1_read_quality=args.minimum_r1_read_quality,
                    r2_fastq_file=args.r2_fastq_file,
                    r2_read_type=args.r2_read_type,
                    minimum_r2_read_length=args.minimum_r2_read_length,
                    minimum_r2_read_quality=args.minimum_r2_read_quality,
                    pack_size=args.pack_size,
                    core_count=args.core_count,
                    memory_limit=args.memory_limit,
                    verbose=args.verbose)
            case 'acount':
                acount = _load_api_func('acount')
                result = acount(
                    index_file=args.index_file,
                    pack_file=args.pack_file,
                    count_file=args.count_file,
                    mapping_type=args.mapping_type,
                    barcode_errors=args.barcode_errors,
                    associate_errors=args.associate_errors,
                    callback=None,
                    core_count=args.core_count,
                    memory_limit=args.memory_limit,
                    failed_reads_file=args.failed_reads_file,
                    failed_reads_sample_size=args.failed_reads_sample_size,
                    verbose=args.verbose)
            case 'xcount':
                xcount = _load_api_func('xcount')
                result = xcount(
                    index_files=_parse_list_str(args.index_files),
                    pack_file=args.pack_file,
                    count_file=args.count_file,
                    mapping_type=args.mapping_type,
                    barcode_errors=args.barcode_errors,
                    callback=None,
                    core_count=args.core_count,
                    memory_limit=args.memory_limit,
                    failed_reads_file=args.failed_reads_file,
                    failed_reads_sample_size=args.failed_reads_sample_size,
                    verbose=args.verbose)
            case _:
                parser.print_help()
                return 2
    except Exception as exc:
        print(f'error: {exc}', file=sys.stderr)
        return 1

    exit_code = _handle_result(result, args)
    if show_banner and exit_code == 0:
        _print_footer()
    return exit_code


if __name__ == '__main__':
    raise SystemExit(main())
