"""Action taxonomy — command classification table and policy defaults.

Classification data and policies are loaded from JSON files in data/.
"""

import json
import os
import sys
from pathlib import Path

_DATA_DIR = Path(__file__).parent / "data"

# Action types
FILESYSTEM_READ = "filesystem_read"
FILESYSTEM_WRITE = "filesystem_write"
FILESYSTEM_DELETE = "filesystem_delete"
GIT_SAFE = "git_safe"
GIT_WRITE = "git_write"
GIT_DISCARD = "git_discard"
GIT_HISTORY_REWRITE = "git_history_rewrite"
NETWORK_OUTBOUND = "network_outbound"
NETWORK_WRITE = "network_write"
NETWORK_DIAGNOSTIC = "network_diagnostic"
PACKAGE_INSTALL = "package_install"
PACKAGE_RUN = "package_run"
PACKAGE_UNINSTALL = "package_uninstall"
LANG_EXEC = "lang_exec"
PROCESS_SIGNAL = "process_signal"
CONTAINER_DESTRUCTIVE = "container_destructive"
DB_READ = "db_read"
DB_WRITE = "db_write"
OBFUSCATED = "obfuscated"
UNKNOWN = "unknown"

# Decision constants
ALLOW = "allow"
ASK = "ask"
BLOCK = "block"
CONTEXT = "context"

# Valid profiles.
PROFILES = ("full", "minimal", "none")

# Strictness ordering — higher = more restrictive. Used for tighten-only merges.
STRICTNESS = {ALLOW: 0, CONTEXT: 1, ASK: 2, BLOCK: 3}


def _load_classify_table(profile: str = "full") -> list[tuple[tuple[str, ...], str]]:
    """Load classify table from JSON files for the given profile."""
    if profile == "none":
        return []
    subdir = "classify_minimal" if profile == "minimal" else "classify_full"
    classify_dir = _DATA_DIR / subdir
    table: list[tuple[tuple[str, ...], str]] = []
    for json_file in classify_dir.glob("*.json"):
        action_type = json_file.stem  # e.g. "git_safe" from "git_safe.json"
        with open(json_file) as f:
            prefixes = json.load(f)
        for prefix_str in prefixes:
            parts = prefix_str.split()
            if parts:
                parts[0] = os.path.basename(parts[0]) or parts[0]
            table.append((tuple(parts), action_type))
    table.sort(key=lambda entry: len(entry[0]), reverse=True)
    return table


def _load_policies() -> dict[str, str]:
    """Load default policies from data/policies.json."""
    with open(_DATA_DIR / "policies.json") as f:
        return json.load(f)


# Cached built-in tables keyed by profile name.
_BUILTIN_TABLES: dict[str, list[tuple[tuple[str, ...], str]]] = {}
_TYPE_DESCRIPTIONS: dict[str, str] | None = None
_POLICIES = _load_policies()
POLICIES = _POLICIES

# Pre-load full profile at module level (most common path).
_BUILTIN_TABLES["full"] = _load_classify_table("full")


def get_builtin_table(profile: str = "full") -> list[tuple[tuple[str, ...], str]]:
    """Get cached built-in classify table for a profile."""
    if profile not in _BUILTIN_TABLES:
        _BUILTIN_TABLES[profile] = _load_classify_table(profile)
    return _BUILTIN_TABLES[profile]


def build_user_table(user_classify: dict[str, list[str]]) -> list[tuple[tuple[str, ...], str]]:
    """Build a sorted classify table from user config entries."""
    table: list[tuple[tuple[str, ...], str]] = []
    for action_type, prefixes in user_classify.items():
        if not isinstance(prefixes, list):
            continue
        for prefix_str in prefixes:
            parts = prefix_str.split()
            if parts:
                parts[0] = os.path.basename(parts[0]) or parts[0]
            table.append((tuple(parts), action_type))
    table.sort(key=lambda entry: len(entry[0]), reverse=True)
    return table


# Commands with Phase 2 flag classifiers (flag-dependent classification).
_FLAG_CLASSIFIER_CMDS = {"find", "sed", "awk", "gawk", "mawk", "nawk",
                          "tar", "git", "curl", "wget",
                          "http", "https", "xh", "xhs",
                          "npm", "pnpm", "bun", "pip", "pip3", "cargo", "gem"}

# Global-install flags that escalate to unknown (ask).
_GLOBAL_INSTALL_FLAGS = {"-g", "--global", "--system", "--target", "--root"}
_GLOBAL_INSTALL_CMDS = {"npm", "pnpm", "bun", "pip", "pip3", "cargo", "gem"}


def find_table_shadows(
    user_table: list[tuple[tuple[str, ...], str]],
    builtin_table: list[tuple[tuple[str, ...], str]],
) -> dict[tuple[str, ...], list[tuple[str, ...]]]:
    """Return {user_prefix: [shadowed_builtin_prefixes]}.

    A user prefix u shadows builtin prefix b when:
    - b == u (exact override), OR
    - len(b) > len(u) AND b[:len(u)] == u (user is a proper prefix of builtin)
    """
    shadows: dict[tuple[str, ...], list[tuple[str, ...]]] = {}
    for u_prefix, _ in user_table:
        matched = []
        for b_prefix, _ in builtin_table:
            if b_prefix == u_prefix:
                matched.append(b_prefix)
            elif len(b_prefix) > len(u_prefix) and b_prefix[:len(u_prefix)] == u_prefix:
                matched.append(b_prefix)
        if matched:
            shadows[u_prefix] = matched
    return shadows


def find_flag_classifier_shadows(
    user_table: list[tuple[tuple[str, ...], str]],
) -> list[tuple[str, ...]]:
    """Return user prefixes that shadow a Phase 2 flag classifier."""
    return [u_prefix for u_prefix, _ in user_table
            if len(u_prefix) == 1 and u_prefix[0] in _FLAG_CLASSIFIER_CMDS]


# Shell wrappers that need unwrapping.
_SHELL_WRAPPERS = {"bash", "sh", "dash", "zsh"}

# Exec sinks for pipe composition.
_EXEC_SINKS_DEFAULTS = {"bash", "sh", "dash", "zsh", "eval", "python", "python3",
                         "node", "ruby", "perl", "php", "bun", "deno", "fish", "pwsh",
                         "env"}
EXEC_SINKS: set[str] = set(_EXEC_SINKS_DEFAULTS)
_exec_sinks_merged = False


def _ensure_exec_sinks_merged():
    """Lazy one-time merge of config exec_sinks into EXEC_SINKS."""
    global _exec_sinks_merged
    if _exec_sinks_merged:
        return
    _exec_sinks_merged = True
    try:
        from nah.config import get_config, _parse_add_remove
        cfg = get_config()
        if cfg.profile == "none":
            EXEC_SINKS.clear()
        add, remove = _parse_add_remove(cfg.exec_sinks)
        EXEC_SINKS.update(str(s) for s in add)
        if remove:
            sys.stderr.write("nah: warning: exec_sinks.remove weakens composition rules\n")
            EXEC_SINKS.difference_update(str(s) for s in remove)
    except Exception as exc:
        sys.stderr.write(f"nah: config: exec_sinks: {exc}\n")


def reset_exec_sinks():
    """Restore defaults and clear merge flag (for testing)."""
    global _exec_sinks_merged
    _exec_sinks_merged = False
    EXEC_SINKS.clear()
    EXEC_SINKS.update(_EXEC_SINKS_DEFAULTS)


# Decode commands for pipe composition (command, flag).
_DECODE_COMMANDS_DEFAULTS: list[tuple[str, str | None]] = [
    ("base64", "-d"),
    ("base64", "--decode"),
    ("xxd", "-r"),
    ("uudecode", None),
]
DECODE_COMMANDS: list[tuple[str, str | None]] = list(_DECODE_COMMANDS_DEFAULTS)
_decode_commands_merged = False


def _ensure_decode_commands_merged():
    """Lazy one-time merge of config decode_commands into DECODE_COMMANDS."""
    global _decode_commands_merged
    if _decode_commands_merged:
        return
    _decode_commands_merged = True
    try:
        from nah.config import get_config, _parse_add_remove
        cfg = get_config()
        if cfg.profile == "none":
            DECODE_COMMANDS.clear()
        add, remove = _parse_add_remove(cfg.decode_commands)
        # Remove by command name (all flag variants)
        if remove:
            sys.stderr.write("nah: warning: decode_commands.remove weakens composition rules\n")
            remove_cmds = {str(c) for c in remove}
            DECODE_COMMANDS[:] = [(c, f) for c, f in DECODE_COMMANDS if c not in remove_cmds]
        # Add: "command flag" or "command" (space-separated string)
        for entry in add:
            parts = str(entry).split(None, 1)
            if parts:
                cmd = parts[0]
                flag = parts[1] if len(parts) > 1 else None
                DECODE_COMMANDS.append((cmd, flag))
    except Exception as exc:
        sys.stderr.write(f"nah: config: decode_commands: {exc}\n")


def reset_decode_commands():
    """Restore defaults and clear merge flag (for testing)."""
    global _decode_commands_merged
    _decode_commands_merged = False
    DECODE_COMMANDS.clear()
    DECODE_COMMANDS.extend(_DECODE_COMMANDS_DEFAULTS)


def _prefix_match(tokens: list[str], table: list[tuple[tuple[str, ...], str]]) -> str:
    """First prefix match in a single sorted table. Returns action type or UNKNOWN."""
    for prefix, action_type in table:
        if len(tokens) >= len(prefix) and tuple(tokens[:len(prefix)]) == prefix:
            return action_type
    return UNKNOWN


def classify_tokens(
    tokens: list[str],
    global_table: list | None = None,
    builtin_table: list | None = None,
    project_table: list | None = None,
    *,
    profile: str = "full",
) -> str:
    """Classify command tokens via three-phase lookup.

    Phase 1: Global table (trusted user config) — always runs.
    Phase 2: Flag classifiers (built-in opinions) — skipped when profile == "none".
    Phase 3: Remaining tables (builtin, project) — global already checked.
    """
    if not tokens:
        return UNKNOWN

    # Basename normalization — resolve /usr/bin/rm → rm (FD-065)
    base = os.path.basename(tokens[0])
    if base and base != tokens[0]:
        tokens = [base] + tokens[1:]

    # --- Phase 1: Global table override (trusted user config) ---
    # Non-git: check global table on raw tokens.
    if global_table and tokens[0] != "git":
        result = _prefix_match(tokens, global_table)
        if result != UNKNOWN:
            return result

    # Git: strip global flags first, then check global table on clean tokens.
    if tokens[0] == "git":
        tokens = _strip_git_global_flags(tokens)
        if global_table:
            result = _prefix_match(tokens, global_table)
            if result != UNKNOWN:
                return result

    # --- Phase 2: Flag classifiers (built-in opinions) ---
    # Skipped entirely when profile == "none".
    if profile != "none":
        action = _classify_find(tokens)
        if action is not None:
            return action
        action = _classify_sed(tokens)
        if action is not None:
            return action
        action = _classify_awk(tokens)
        if action is not None:
            return action
        action = _classify_tar(tokens)
        if action is not None:
            return action
        if tokens[0] == "git":
            action = _classify_git(tokens)
            if action is not None:
                return action
        action = _classify_curl(tokens)
        if action is not None:
            return action
        action = _classify_wget(tokens)
        if action is not None:
            return action
        action = _classify_httpie(tokens)
        if action is not None:
            return action
        action = _classify_global_install(tokens)
        if action is not None:
            return action

    # --- Phase 3: Remaining tables (builtin, project) ---
    for table in (builtin_table, project_table):
        if table:
            result = _prefix_match(tokens, table)
            if result != UNKNOWN:
                return result

    return UNKNOWN


# Git global flags that take a value argument (must consume next token too).
_GIT_VALUE_FLAGS = {"-C", "--git-dir", "--work-tree", "--namespace", "-c"}

# Git global flags that are standalone (no value argument).
_GIT_BOOLEAN_FLAGS = {"--no-pager", "--no-replace-objects", "--bare", "--literal-pathspecs",
                      "--glob-pathspecs", "--noglob-pathspecs", "--no-optional-locks"}


def _strip_git_global_flags(tokens: list[str]) -> list[str]:
    """Strip git global flags (e.g. -C <dir>, --no-pager) from token list.

    Preserves 'git' as first token followed by the subcommand and its args.
    """
    result = [tokens[0]]  # keep "git"
    i = 1
    while i < len(tokens):
        tok = tokens[i]
        if tok in _GIT_VALUE_FLAGS:
            i += 2  # skip flag + its value
        elif tok in _GIT_BOOLEAN_FLAGS:
            i += 1  # skip flag only
        else:
            # Reached the subcommand — append rest as-is.
            result.extend(tokens[i:])
            break
    return result


def _classify_find(tokens: list[str]) -> str | None:
    """Special classifier for find — flag-dependent action type."""
    if not tokens or tokens[0] != "find":
        return None
    for tok in tokens[1:]:
        if tok in ("-delete", "-exec", "-execdir", "-ok"):
            return FILESYSTEM_DELETE
    return FILESYSTEM_READ


def _classify_sed(tokens: list[str]) -> str | None:
    """Flag-dependent: sed -i/-I → filesystem_write; else → filesystem_read."""
    if not tokens or tokens[0] != "sed":
        return None
    for tok in tokens[1:]:
        # -i/-I or -i.bak/-I.bak (GNU lowercase, BSD uppercase)
        if tok == "-i" or tok.startswith("-i") or tok == "-I" or tok.startswith("-I"):
            return FILESYSTEM_WRITE
        # --in-place or --in-place=.bak (GNU long form)
        if tok.startswith("--in-place"):
            return FILESYSTEM_WRITE
        # Combined short flags: -ni, -nI, -ein, etc.
        if tok.startswith("-") and not tok.startswith("--") and ("i" in tok or "I" in tok):
            return FILESYSTEM_WRITE
    return FILESYSTEM_READ


def _classify_awk(tokens: list[str]) -> str | None:
    """Flag-dependent: awk with system()/getline/pipes → lang_exec."""
    if not tokens or tokens[0] not in ("awk", "gawk", "mawk", "nawk"):
        return None
    for tok in tokens[1:]:
        if tok.startswith("-"):
            continue
        if any(p in tok for p in ("system(", "| getline", "|&", "| \"", "print >")):
            return LANG_EXEC
    return None


def _classify_tar(tokens: list[str]) -> str | None:
    """Flag-dependent: tar mode detection. Write takes precedence. Default: write."""
    if not tokens or tokens[0] != "tar":
        return None
    found_read = False
    found_write = False
    args = tokens[1:]
    if not args:
        return FILESYSTEM_WRITE  # Conservative default
    # Check if first arg is a bare mode string (no leading dash): tf, czf, xf
    first = args[0]
    if first and not first.startswith("-"):
        if any(c in first for c in "cxru"):
            found_write = True
        elif "t" in first:
            found_read = True
    # Check all flag arguments
    for tok in args:
        if tok.startswith("-") and len(tok) > 1 and tok[1] != "-":
            # Short flags: -tf, -czf, -xf, etc.
            letters = tok[1:]
            if "t" in letters:
                found_read = True
            if any(c in letters for c in "cxru"):
                found_write = True
        elif tok.startswith("--"):
            if tok == "--list":
                found_read = True
            if tok in ("--create", "--extract", "--append", "--update",
                       "--get", "--delete"):
                found_write = True
    if found_write:
        return FILESYSTEM_WRITE
    if found_read:
        return FILESYSTEM_READ
    return FILESYSTEM_WRITE  # Conservative default


_CURL_DATA_FLAGS = {
    "-d", "--data", "--data-raw", "--data-binary", "--data-urlencode",
    "-F", "--form", "--form-string", "-T", "--upload-file", "--json",
}
_CURL_DATA_LONG_PREFIXES = (
    "--data=", "--data-raw=", "--data-binary=", "--data-urlencode=",
    "--form=", "--form-string=", "--upload-file=", "--json=",
)
_CURL_METHOD_FLAGS = {"-X", "--request"}
_WRITE_METHODS = {"POST", "PUT", "DELETE", "PATCH"}


def _classify_curl(tokens: list[str]) -> str | None:
    """Flag-dependent: curl with write flags → network_write; else → network_outbound."""
    if not tokens or tokens[0] != "curl":
        return None

    has_data = False
    has_write_method = False

    i = 1
    while i < len(tokens):
        tok = tokens[i]

        # Standalone data flags
        if tok in _CURL_DATA_FLAGS:
            has_data = True
            i += 1
            continue

        # =joined long data flags
        if any(tok.startswith(p) for p in _CURL_DATA_LONG_PREFIXES):
            has_data = True
            i += 1
            continue

        # Method flags: -X METHOD, --request METHOD, --request=METHOD
        if tok in _CURL_METHOD_FLAGS:
            if i + 1 < len(tokens):
                method = tokens[i + 1].upper()
                if method in _WRITE_METHODS:
                    has_write_method = True
            i += 2
            continue
        if tok.startswith("--request="):
            method = tok.split("=", 1)[1].upper()
            if method in _WRITE_METHODS:
                has_write_method = True
            i += 1
            continue

        # Combined short flags: -sXPOST, -XPOST, etc.
        if tok.startswith("-") and not tok.startswith("--") and len(tok) > 1:
            letters = tok[1:]
            if "X" in letters:
                x_idx = letters.index("X")
                rest = letters[x_idx + 1:]
                # Extract method: chars after X until non-alpha
                method_chars = []
                for c in rest:
                    if c.isalpha():
                        method_chars.append(c)
                    else:
                        break
                if method_chars:
                    method = "".join(method_chars).upper()
                    if method in _WRITE_METHODS:
                        has_write_method = True
                elif i + 1 < len(tokens):
                    # X is last char in combined flags, method is next token
                    method = tokens[i + 1].upper()
                    if method in _WRITE_METHODS:
                        has_write_method = True
                    i += 2
                    continue

        i += 1

    # Data flags take priority over method flags
    if has_data:
        return NETWORK_WRITE
    if has_write_method:
        return NETWORK_WRITE
    return NETWORK_OUTBOUND


def _classify_wget(tokens: list[str]) -> str | None:
    """Flag-dependent: wget with write flags → network_write; else → network_outbound."""
    if not tokens or tokens[0] != "wget":
        return None

    has_data = False
    has_write_method = False

    i = 1
    while i < len(tokens):
        tok = tokens[i]

        # --post-data, --post-file (standalone or =joined)
        if tok in ("--post-data", "--post-file"):
            has_data = True
            i += 2  # skip value
            continue
        if tok.startswith("--post-data=") or tok.startswith("--post-file="):
            has_data = True
            i += 1
            continue

        # --method METHOD or --method=METHOD
        if tok == "--method":
            if i + 1 < len(tokens):
                method = tokens[i + 1].upper()
                if method in _WRITE_METHODS:
                    has_write_method = True
            i += 2
            continue
        if tok.startswith("--method="):
            method = tok.split("=", 1)[1].upper()
            if method in _WRITE_METHODS:
                has_write_method = True
            i += 1
            continue

        i += 1

    if has_data:
        return NETWORK_WRITE
    if has_write_method:
        return NETWORK_WRITE
    return NETWORK_OUTBOUND


_HTTPIE_CMDS = {"http", "https", "xh", "xhs"}
_HTTPIE_METHODS = {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}


def _classify_httpie(tokens: list[str]) -> str | None:
    """Flag-dependent: httpie with write indicators → network_write; else → network_outbound."""
    if not tokens or tokens[0] not in _HTTPIE_CMDS:
        return None

    args = tokens[1:]
    has_form = False
    has_write_method = False
    has_data_item = False
    found_url = False

    for arg in args:
        # Check for --form / -f
        if arg == "--form" or arg == "-f":
            has_form = True
            continue

        # Skip other flags
        if arg.startswith("-"):
            continue

        # First non-flag arg: check if it's an uppercase method
        if not found_url and arg.upper() in _HTTPIE_METHODS:
            if arg.upper() in _WRITE_METHODS:
                has_write_method = True
            continue

        if not found_url:
            found_url = True
            continue

        # After URL: check for data item patterns (key=value, key:=value, key@file)
        if "=" in arg or ":=" in arg or "@" in arg:
            has_data_item = True

    if has_write_method:
        return NETWORK_WRITE
    if has_form:
        return NETWORK_WRITE
    if has_data_item:
        return NETWORK_WRITE
    return NETWORK_OUTBOUND


def _classify_global_install(tokens: list[str]) -> str | None:
    """Flag-dependent: global-install flags escalate to unknown (ask)."""
    if not tokens or tokens[0] not in _GLOBAL_INSTALL_CMDS:
        return None
    for tok in tokens[1:]:
        if tok in _GLOBAL_INSTALL_FLAGS:
            return UNKNOWN
    return None


def _classify_git(tokens: list[str]) -> str | None:
    """Flag-dependent classification for 12 git subcommands.

    Returns action type or None (fall through to prefix matching).
    Called after _strip_git_global_flags(), so tokens are clean.
    """
    if len(tokens) < 2 or tokens[0] != "git":
        return None

    sub = tokens[1]
    args = tokens[2:]

    if sub == "tag":
        return GIT_SAFE if not args else GIT_WRITE

    if sub == "branch":
        if not args:
            return GIT_SAFE
        for a in args:
            if a in ("-a", "-r", "--list", "-v", "-vv"):
                return GIT_SAFE
            if a == "-d":
                return GIT_DISCARD
            if a == "-D":
                return GIT_HISTORY_REWRITE
        return GIT_WRITE

    if sub == "config":
        for a in args:
            if a in ("--get", "--list", "--get-all", "--get-regexp"):
                return GIT_SAFE
            if a in ("--unset", "--unset-all", "--replace-all"):
                return GIT_WRITE
        # Count non-flag args: 0-1 = read (get), 2+ = write (set)
        non_flag = [a for a in args if not a.startswith("-")]
        return GIT_SAFE if len(non_flag) <= 1 else GIT_WRITE

    if sub == "reset":
        return GIT_DISCARD if "--hard" in args else GIT_WRITE

    if sub == "push":
        _FORCE_FLAGS = {"--force", "-f", "--force-with-lease", "--force-if-includes"}
        for a in args:
            if a in _FORCE_FLAGS:
                return GIT_HISTORY_REWRITE
            # +refspec means force push
            if a.startswith("+") and len(a) > 1:
                return GIT_HISTORY_REWRITE
        return GIT_WRITE

    if sub == "add":
        return GIT_SAFE if ("--dry-run" in args or "-n" in args) else GIT_WRITE

    if sub == "rm":
        return GIT_WRITE if "--cached" in args else GIT_DISCARD

    if sub == "clean":
        return GIT_SAFE if ("--dry-run" in args or "-n" in args) else GIT_HISTORY_REWRITE

    if sub == "reflog":
        if args and args[0] in ("delete", "expire"):
            return GIT_DISCARD
        return GIT_SAFE

    if sub == "checkout":
        _DISCARD = {".", "--", "HEAD", "--force", "-f", "--ours", "--theirs", "-B"}
        for a in args:
            if a in _DISCARD:
                return GIT_DISCARD
        return GIT_WRITE

    if sub == "switch":
        _DISCARD = {"--discard-changes", "--force", "-f"}
        for a in args:
            if a in _DISCARD:
                return GIT_DISCARD
        return GIT_WRITE

    if sub == "restore":
        return GIT_WRITE if "--staged" in args else GIT_DISCARD

    return None


def load_type_descriptions() -> dict[str, str]:
    """Load action type descriptions from types.json. Cached at module level."""
    global _TYPE_DESCRIPTIONS
    if _TYPE_DESCRIPTIONS is not None:
        return _TYPE_DESCRIPTIONS
    with open(_DATA_DIR / "types.json") as f:
        _TYPE_DESCRIPTIONS = json.load(f)
    return _TYPE_DESCRIPTIONS


def validate_action_type(name: str) -> tuple[bool, list[str]]:
    """Check if name is a valid action type. Returns (valid, close_matches)."""
    import difflib
    all_types = list(load_type_descriptions().keys())
    if name in all_types:
        return True, []
    matches = difflib.get_close_matches(name, all_types, n=3, cutoff=0.5)
    return False, matches


def get_policy(action_type: str, user_actions: dict[str, str] | None = None) -> str:
    """Return policy for an action type. Checks user overrides first, then built-in."""
    if user_actions and action_type in user_actions:
        return user_actions[action_type]
    return _POLICIES.get(action_type, ASK)


def is_shell_wrapper(tokens: list[str]) -> tuple[bool, str | None]:
    """Detect bash -c, eval, source. Returns (is_wrapper, inner_command_or_None)."""
    if not tokens:
        return False, None

    cmd = tokens[0]

    # bash/sh/dash/zsh -c "inner"
    if cmd in _SHELL_WRAPPERS and len(tokens) >= 3 and tokens[1] == "-c":
        return True, tokens[2]

    # eval "string"
    if cmd == "eval" and len(tokens) >= 2:
        return True, " ".join(tokens[1:])

    # bash/sh/dash/zsh <<< "inner" (here-string)
    if cmd in _SHELL_WRAPPERS and len(tokens) >= 3 and tokens[1] == "<<<":
        return True, tokens[2]

    # source / . (not unwrapped — classify as lang_exec)
    if cmd in ("source", "."):
        return False, None

    return False, None


def is_exec_sink(token: str) -> bool:
    """Check if a token is an exec sink (for pipe composition rules)."""
    _ensure_exec_sinks_merged()
    return token in EXEC_SINKS


def is_decode_stage(tokens: list[str]) -> bool:
    """Check if tokens represent a decode command (base64 -d, xxd -r, etc.)."""
    _ensure_decode_commands_merged()
    if not tokens:
        return False
    for cmd, flag in DECODE_COMMANDS:
        if tokens[0] == cmd:
            if flag is None:
                return True
            if flag in tokens[1:]:
                return True
    return False
