# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========

import asyncio

import pytest

from mirage.resource.ram import RAMResource
from mirage.runtime.python import LocalRuntime
from mirage.types import MountMode
from mirage.workspace import Workspace


def _run(coro):
    return asyncio.run(coro)


def _ws():
    """Workspace with 3 RAM mounts: /s3/, /disk/, /ram/."""
    s3 = RAMResource()
    s3.caches_reads = True
    disk = RAMResource()
    disk.caches_reads = True
    ram = RAMResource()

    s3._store.files["/report.csv"] = b"name,age\nalice,30\nbob,25\n"
    s3._store.files["/data.txt"] = b"hello from s3\n"
    s3._store.files["/users.json"] = (
        b'[{"name":"alice","age":30},{"name":"bob","age":25}]\n')
    s3._store.files["/config.env"] = b"DB_HOST=localhost\nDB_PORT=5432\n"
    s3._store.files["/access.log"] = (b"2024-01-01 GET /api 200\n"
                                      b"2024-01-01 POST /api 500\n"
                                      b"2024-01-02 GET /api 200\n"
                                      b"2024-01-02 GET /health 200\n"
                                      b"2024-01-03 POST /api 500\n")
    s3._store.files["/script.py"] = (b"import json\n"
                                     b"data = json.loads('[1,2,3]')\n"
                                     b"print(sum(data))\n")

    disk._store.files["/readme.txt"] = b"disk readme\n"
    disk._store.dirs.add("/sub")
    disk._store.files["/sub/deep.txt"] = b"deep content\n"

    ram._store.files["/notes.txt"] = b"line1\nline2\nline3\n"
    ram._store.files["/nums.txt"] = b"5\n3\n1\n4\n2\n"
    ram._store.files["/words.txt"] = b"banana\napple\ncherry\napple\n"

    ws = Workspace(resources={
        "/s3/": (s3, MountMode.EXEC),
        "/disk/": (disk, MountMode.EXEC),
        "/ram/": (ram, MountMode.EXEC),
    }, )
    ws.get_session(ws.default_session_id).cwd = "/s3"
    return ws


def _exec(ws, cmd, stdin=None):
    return _run(ws.execute(cmd, stdin=stdin))


def _stdout(io):
    if io.stdout is None:
        return b""
    if isinstance(io.stdout, bytes):
        return io.stdout
    if isinstance(io.stdout, memoryview):
        return bytes(io.stdout)
    return b""


# ── basic commands ─────────────────────────────


def test_cat_file():
    ws = _ws()
    io = _exec(ws, "cat /s3/report.csv")
    assert io.exit_code == 0
    assert b"alice" in _stdout(io)


def test_cat_missing_file():
    ws = _ws()
    io = _exec(ws, "cat /s3/nonexistent.txt")
    assert io.exit_code != 0


def test_thrown_command_error_prefixes_argv0(monkeypatch):
    """A thrown command error is reported GNU-style: prog: message."""
    ws = _ws()
    mount = ws._registry.mount_for("/ram/notes.txt")

    async def boom(*args, **kwargs):
        raise RuntimeError("kaboom")

    monkeypatch.setattr(mount, "execute_cmd", boom)
    io = _exec(ws, "cat /ram/notes.txt")
    assert io.exit_code == 1
    assert io.stderr == b"cat: kaboom\n"


def test_ls_directory():
    ws = _ws()
    io = _exec(ws, "ls /disk/")
    assert io.exit_code == 0


def test_head_file():
    ws = _ws()
    io = _exec(ws, "head /ram/notes.txt")
    assert io.exit_code == 0
    assert b"line1" in _stdout(io)


# ── export / env ───────────────────────────────


def test_export():
    ws = _ws()
    _exec(ws, "export MSG=hello")
    assert ws.get_session(ws.default_session_id).env["MSG"] == "hello"


def test_export_used_in_command():
    ws = _ws()
    _exec(ws, "export DIR=/s3")
    io = _exec(ws, "cat $DIR/report.csv")
    assert io.exit_code == 0
    assert b"alice" in _stdout(io)


# ── cd ─────────────────────────────────────────


def test_cd():
    ws = _ws()
    _exec(ws, "cd /disk")
    assert ws.get_session(ws.default_session_id).cwd == "/disk"


def test_cd_nonexistent():
    ws = _ws()
    io = _exec(ws, "cd /nonexistent")
    assert io.exit_code != 0


# ── pipeline ───────────────────────────────────


def test_pipeline_cat_sort():
    ws = _ws()
    io = _exec(ws, "cat /s3/report.csv | sort")
    assert io.exit_code == 0


def test_pipeline_cat_wc():
    ws = _ws()
    io = _exec(ws, "cat /ram/notes.txt | wc")
    assert io.exit_code == 0


# ── redirect ───────────────────────────────────


def test_redirect_write():
    ws = _ws()
    io = _exec(ws, "cat /s3/data.txt > /disk/out.txt")
    assert io.exit_code == 0
    io2 = _exec(ws, "cat /disk/out.txt")
    assert b"hello from s3" in _stdout(io2)


def test_redirect_append():
    ws = _ws()
    _exec(ws, "cat /s3/data.txt > /disk/log.txt")
    _exec(ws, "cat /s3/report.csv >> /disk/log.txt")
    io = _exec(ws, "cat /disk/log.txt")
    out = _stdout(io)
    assert b"hello from s3" in out
    assert b"alice" in out


# ── control flow ───────────────────────────────


def test_if_true():
    ws = _ws()
    _exec(ws, "if true; then export R=yes; fi")
    assert ws.get_session(ws.default_session_id).env["R"] == "yes"


def test_if_false_else():
    ws = _ws()
    _exec(ws, "if false; then export R=yes; else export R=no; fi")
    assert ws.get_session(ws.default_session_id).env["R"] == "no"


def test_for_loop():
    ws = _ws()
    _exec(ws, "for x in a b c; do export LAST=$x; done")
    assert ws.get_session(ws.default_session_id).env["LAST"] == "c"


def test_while_false():
    ws = _ws()
    io = _exec(ws, "while false; do export RAN=yes; done")
    assert io.exit_code == 0
    assert "RAN" not in ws.get_session(ws.default_session_id).env


def test_case_match():
    ws = _ws()
    _exec(ws, "case hello in hello) export M=yes;; esac")
    assert ws.get_session(ws.default_session_id).env["M"] == "yes"


def test_case_fallthrough_semi_amp():
    ws = _ws()
    io = _exec(ws,
               "case a in a) echo one;& b) echo two;; c) echo three;; esac")
    assert _stdout(io) == b"one\ntwo\n"


def test_case_continue_match_semi_semi_amp():
    ws = _ws()
    io = _exec(ws,
               "case a in a) echo one;;& a) echo two;;& b) echo three;; esac")
    assert _stdout(io) == b"one\ntwo\n"


def test_case_semi_semi_amp_no_further_match():
    ws = _ws()
    io = _exec(ws, "case a in a) echo one;;& z) echo two;; esac")
    assert _stdout(io) == b"one\n"


def test_source_sets_positional_args():
    ws = _ws()
    _exec(ws, "printf 'echo argc=$# a1=$1 a2=$2\\n' > /ram/s.sh")
    io = _exec(ws, "source /ram/s.sh AA BB")
    assert _stdout(io) == b"argc=2 a1=AA a2=BB\n"


def test_source_without_args_keeps_parent_positional():
    ws = _ws()
    _exec(ws, "printf 'echo a1=$1\\n' > /ram/s.sh")
    io = _exec(ws, "set -- P1 P2; source /ram/s.sh; echo after=$1")
    assert _stdout(io) == b"a1=P1\nafter=P1\n"


def test_source_positional_args_keep_the_spelling():
    ws = _ws()
    _exec(ws, "printf 'echo a1=$1\\n' > /ram/s.sh")
    # A path-looking argument stays as typed, not resolved.
    io = _exec(ws, "cd /ram && source /ram/s.sh ./sub/x.txt")
    assert _stdout(io) == b"a1=./sub/x.txt\n"


def test_readonly_bare_name_registers():
    ws = _ws()
    io = _exec(ws, "RO=1; readonly RO; RO=2")
    assert io.exit_code == 1
    assert io.stderr == b"bash: RO: readonly variable\n"


def test_readonly_array_form_registers():
    ws = _ws()
    io = _exec(ws, "readonly -a RA=(x y); unset 'RA[1]'; echo rc=$?")
    assert _stdout(io) == b"rc=1\n"
    assert io.stderr == (b"bash: unset: RA: cannot unset: "
                         b"readonly variable\n")


# ── operators ──────────────────────────────────


def test_semicolons():
    ws = _ws()
    _exec(ws, "export A=1; export B=2; export C=3")
    s = ws.get_session(ws.default_session_id)
    assert s.env["A"] == "1"
    assert s.env["B"] == "2"
    assert s.env["C"] == "3"


def test_and_chain():
    ws = _ws()
    _exec(ws, "true && export OK=yes")
    assert ws.get_session(ws.default_session_id).env["OK"] == "yes"


def test_and_short_circuit():
    ws = _ws()
    _exec(ws, "false && export SKIP=yes")
    assert "SKIP" not in ws.get_session(ws.default_session_id).env


def test_or_fallback():
    ws = _ws()
    _exec(ws, "false || export FALL=yes")
    assert ws.get_session(ws.default_session_id).env["FALL"] == "yes"


# ── subshell ───────────────────────────────────


def test_subshell_isolates_env():
    ws = _ws()
    _exec(ws, "export X=outer")
    _exec(ws, "(export X=inner)")
    assert ws.get_session(ws.default_session_id).env["X"] == "outer"


# ── function ───────────────────────────────────


def test_function_define_call():
    ws = _ws()
    _exec(ws, "greet() { export MSG=hello; }; greet")
    assert ws.get_session(ws.default_session_id).env["MSG"] == "hello"


def test_function_with_args():
    ws = _ws()
    _exec(ws, "f() { export A=$1; export B=$2; }; f x y")
    s = ws.get_session(ws.default_session_id)
    assert s.env["A"] == "x"
    assert s.env["B"] == "y"


# ── negation ───────────────────────────────────


def test_negated_true():
    ws = _ws()
    io = _exec(ws, "! true")
    assert io.exit_code == 1


def test_negated_false():
    ws = _ws()
    io = _exec(ws, "! false")
    assert io.exit_code == 0


# ── brace group ────────────────────────────────


def test_brace_group():
    ws = _ws()
    _exec(ws, "{ export A=1; export B=2; }")
    s = ws.get_session(ws.default_session_id)
    assert s.env["A"] == "1"
    assert s.env["B"] == "2"


def test_status_inside_subshell_and_brace_group():
    """$? inside ( ) and { } must track prior commands (issue #476)."""
    ws = _ws()
    io = _exec(ws, "(false; echo subshell=$?)")
    assert io.exit_code == 0
    assert _stdout(io) == b"subshell=1\n"
    io = _exec(ws, "{ false; echo group=$?; }")
    assert io.exit_code == 0
    assert _stdout(io) == b"group=1\n"


def test_status_lazy_exit_code_inside_subshell_and_brace_group():
    """grep's exit code is finalized lazily; $? must still see it."""
    ws = _ws()
    io = _exec(ws, "(grep missing /ram/notes.txt; echo s=$?)")
    assert io.exit_code == 0
    assert _stdout(io) == b"s=1\n"
    io = _exec(ws, "{ grep missing /ram/notes.txt; echo s=$?; }")
    assert io.exit_code == 0
    assert _stdout(io) == b"s=1\n"


def test_status_updates_in_all_construct_bodies():
    """$? tracks the previous statement inside every construct body."""
    ws = _ws()
    for cmd in [
            "if true; then false; echo s=$?; fi",
            "for i in 1; do false; echo s=$?; done",
            "while true; do false; echo s=$?; break; done",
            "case x in x) false; echo s=$?;; esac",
            "f() { false; echo s=$?; }; f",
            "if true; then grep missing /ram/notes.txt; echo s=$?; fi",
            "for i in 1; do grep missing /ram/notes.txt; echo s=$?; done",
            "case x in x) grep missing /ram/notes.txt; echo s=$?;; esac",
            "g() { grep missing /ram/notes.txt; echo s=$?; }; g",
    ]:
        io = _exec(ws, cmd)
        assert io.exit_code == 0, cmd
        assert _stdout(io) == b"s=1\n", cmd


def test_status_negation_background_and_assignments():
    """$? parity for `!`, background launches, and assignment statements."""
    ws = _ws()
    for cmd, want in [
        ("! grep missing /ram/notes.txt; echo s=$?", b"s=0\n"),
        ("false; true & echo s=$?; wait", b"s=0\n"),
        ("x=$(false); echo s=$?", b"s=1\n"),
        ("x=$(exit 42); echo s=$?", b"s=42\n"),
        ("x=$(true) y=$(false); echo s=$?", b"s=1\n"),
        ("x=$(false) y=$(true); echo s=$?", b"s=0\n"),
        ("x=1 y=2; echo s=$? x=$x y=$y", b"s=0 x=1 y=2\n"),
        ("export e=$(false); echo s=$?", b"s=0\n"),
        ("arr=($(false)); echo s=$?", b"s=1\n"),
        ("x=$(false) && echo yes; echo s=$?", b"s=1\n"),
        ("g() { local lx=$(false); echo s=$?; }; g", b"s=0\n"),
    ]:
        io = _exec(ws, cmd)
        assert _stdout(io) == want, cmd


def test_c_style_for():
    """C-style for matches bash: init/cond/update, break/continue, $?."""
    ws = _ws()
    for cmd, want in [
        ("for ((i=0;i<3;i++)); do echo $i; done", b"0\n1\n2\n"),
        ("for ((i=0;i<3;i++)); do false; done; echo code=$?", b"code=1\n"),
        ("for ((i=5;i<3;i++)); do echo x; done; echo code=$?", b"code=0\n"),
        ("x=0; for ((i=0;i<4;i++)); do x=$((x+i)); done; echo $x", b"6\n"),
        ("for ((i=0;i<5;i++)); do if ((i==2)); then continue; fi; "
         "echo $i; done", b"0\n1\n3\n4\n"),
        ("for ((i=0;i<5;i++)); do if ((i==2)); then break; fi; "
         "echo $i; done", b"0\n1\n"),
        ("for ((;;)); do break; done; echo code=$?", b"code=0\n"),
        ("n=2; for ((i=n;i<4;i++)); do echo $i; done", b"2\n3\n"),
        ("for ((i=0;i<2;i++)); do echo a; done; echo i=$i", b"a\na\ni=2\n"),
        ("for ((i=0;i<1;i++)); do false; echo s=$?; done", b"s=1\n"),
    ]:
        io = _exec(ws, cmd)
        assert _stdout(io) == want, cmd


def test_c_style_for_arith_error_aborts_with_1():
    ws = _ws()
    io = _exec(ws, "for ((i=@;i<1;i++)); do echo x; done; echo c=$?")
    assert _stdout(io) == b"c=1\n"


def test_c_style_for_readonly_aborts_with_1():
    """A slot assigning to a readonly variable aborts the loop with 1,
    keeping the output of iterations that already ran (bash 5.2)."""
    ws = _ws()
    io = _exec(
        ws, "readonly i=5; for ((i=0;i<2;i++)); do echo x; done; "
        "echo c=$?")
    assert _stdout(io) == b"c=1\n"
    assert b"bash: i: readonly variable\n" in (io.stderr or b"")

    ws = _ws()
    io = _exec(
        ws, "readonly i=5; for ((;i<10;i++)); do echo hi; done; "
        "echo c=$?; echo i=$i")
    assert _stdout(io) == b"hi\nc=1\ni=5\n"


def test_find_mtime_observed_write_fallback():
    """A write through mirage stamps an observed mtime that find sees
    even when the backend reports none; touch still overrides."""
    ram = RAMResource()
    ws = Workspace(resources={"/ram/": (ram, MountMode.EXEC)})
    _exec(ws, "echo hi > /ram/probe.txt")
    ram._store.modified.clear()
    io = _exec(ws, "find /ram -name probe.txt -mtime -1")
    assert _stdout(io) == b"/ram/probe.txt\n"
    io = _exec(ws, "find /ram -name probe.txt -mtime +5")
    assert _stdout(io) == b""
    _exec(ws, "touch -d 2020-01-01 /ram/probe.txt")
    io = _exec(ws, "find /ram -name probe.txt -mtime +5")
    assert _stdout(io) == b"/ram/probe.txt\n"


# ── variable expansion ─────────────────────────


def test_var_in_path():
    ws = _ws()
    _exec(ws, "export F=/s3/report.csv")
    io = _exec(ws, "cat $F")
    assert io.exit_code == 0
    assert b"alice" in _stdout(io)


def test_var_concat_path():
    ws = _ws()
    _exec(ws, "export DIR=/s3")
    io = _exec(ws, "cat $DIR/data.txt")
    assert io.exit_code == 0
    assert b"hello from s3" in _stdout(io)


# ── assignment ─────────────────────────────────


def test_bare_assignment():
    ws = _ws()
    _exec(ws, "X=hello")
    assert ws.get_session(ws.default_session_id).env["X"] == "hello"


def test_assignment_expansion():
    ws = _ws()
    _exec(ws, "export BASE=/s3")
    _exec(ws, "OUT=$BASE/result.txt")
    assert ws.get_session(ws.default_session_id).env["OUT"] == "/s3/result.txt"


# ── while read ─────────────────────────────────


def test_while_read():
    ws = _ws()
    _exec(ws,
          "while read LINE; do export LAST=$LINE; done",
          stdin=b"a\nb\nc\n")
    assert ws.get_session(ws.default_session_id).env["LAST"] == "c"


# ── cross-mount ────────────────────────────────


def test_cross_mount_cat():
    ws = _ws()
    io1 = _exec(ws, "cat /s3/report.csv")
    io2 = _exec(ws, "cat /ram/notes.txt")
    assert io1.exit_code == 0
    assert io2.exit_code == 0
    assert b"alice" in _stdout(io1)
    assert b"line1" in _stdout(io2)


# ── complex: pipeline + redirect + expansion ───


def test_pipeline_redirect_expansion():
    ws = _ws()
    _exec(ws, "export DIR=/disk")
    io = _exec(ws, "cat /s3/report.csv | grep alice > $DIR/result.txt")
    assert io.exit_code == 0
    io2 = _exec(ws, "cat /disk/result.txt")
    assert b"alice" in _stdout(io2)


def test_for_with_redirect():
    ws = _ws()
    _exec(ws, "for name in hello world; do "
          "echo $name > /disk/$name.txt; done")
    io1 = _exec(ws, "cat /disk/hello.txt")
    io2 = _exec(ws, "cat /disk/world.txt")
    assert b"hello" in _stdout(io1)
    assert b"world" in _stdout(io2)


# ── session isolation ──────────────────────────


def test_separate_sessions():
    ws = _ws()
    ws.create_session("worker")
    _exec(ws, "export X=default")
    assert "X" not in ws.get_session("worker").env


# ── exit code ──────────────────────────────────


def test_exit_code_propagation():
    ws = _ws()
    io = _exec(ws, "true")
    assert io.exit_code == 0
    io = _exec(ws, "false")
    assert io.exit_code == 1


def test_last_exit_code():
    ws = _ws()
    _exec(ws, "true")
    assert ws.get_session(ws.default_session_id).last_exit_code == 0
    _exec(ws, "false")
    assert ws.get_session(ws.default_session_id).last_exit_code == 1


# ═══════════════════════════════════════════════
# Complex nested: real-world patterns
# ═══════════════════════════════════════════════


def test_etl_pipeline():
    """Simulate: read CSV, filter, write result.

    cat /s3/report.csv | grep alice > /disk/filtered.txt
    Then verify filtered.txt has alice but not bob.
    """
    ws = _ws()
    _exec(ws, "cat /s3/report.csv | grep alice > /disk/filtered.txt")
    io = _exec(ws, "cat /disk/filtered.txt")
    out = _stdout(io)
    assert b"alice" in out
    assert b"bob" not in out


def test_multi_step_processing():
    """Multi-step: export vars, loop files, process each.

    export SRC=/s3
    export DST=/disk
    for f in report.csv data.txt; do
        cat $SRC/$f > $DST/$f
    done
    """
    ws = _ws()
    _exec(
        ws, "export SRC=/s3; export DST=/disk; "
        "for f in report.csv data.txt; do "
        "cat $SRC/$f > $DST/$f; done")
    io1 = _exec(ws, "cat /disk/report.csv")
    io2 = _exec(ws, "cat /disk/data.txt")
    assert b"alice" in _stdout(io1)
    assert b"hello from s3" in _stdout(io2)


def test_conditional_processing():
    """if/else with pipeline and redirect.

    if cat /s3/report.csv | grep alice; then
        echo found > /disk/status.txt
    else
        echo missing > /disk/status.txt
    fi
    """
    ws = _ws()
    _exec(
        ws, "if cat /s3/report.csv | grep alice; then "
        "echo found > /disk/status.txt; "
        "else echo missing > /disk/status.txt; fi")
    io = _exec(ws, "cat /disk/status.txt")
    assert b"found" in _stdout(io)


def test_function_with_pipeline_redirect():
    """Define function that processes a file.

    process() {
        cat $1 | sort > $2
    }
    process /ram/notes.txt /disk/sorted.txt
    """
    ws = _ws()
    _exec(
        ws, "process() { cat $1 | sort > $2; }; "
        "process /ram/notes.txt /disk/sorted.txt")
    io = _exec(ws, "cat /disk/sorted.txt")
    out = _stdout(io)
    assert b"line1" in out
    lines = out.decode().strip().split("\n")
    assert lines == sorted(lines)


def test_while_read_with_conditional_write():
    """Read lines, write matching ones to file.

    while read LINE; do
        case $LINE in
            alice*) echo $LINE >> /disk/matches.txt;;
        esac
    done
    """
    ws = _ws()
    _exec(ws, "while read LINE; do "
          "case $LINE in "
          "alice*) echo $LINE >> /disk/matches.txt;; "
          "esac; done",
          stdin=b"alice,30\nbob,25\nalice,40\n")
    io = _exec(ws, "cat /disk/matches.txt")
    out = _stdout(io)
    assert out.count(b"alice") == 2
    assert b"bob" not in out


def test_nested_for_cross_mount():
    """Nested for across mounts.

    for src in /s3 /ram; do
        for f in report.csv notes.txt; do
            cat $src/$f 2>/dev/null && export FOUND=$src/$f
        done
    done
    """
    ws = _ws()
    s = ws.get_session(ws.default_session_id)
    _exec(
        ws, "for src in /s3 /ram; do "
        "for f in report.csv notes.txt; do "
        "cat $src/$f && export FOUND=$src/$f; "
        "done; done")
    assert "FOUND" in s.env


def test_background_with_foreground_work():
    """Background sleep while foreground does work.

    sleep 0.01 &
    export A=1
    export B=2
    cat /s3/report.csv > /disk/copy.txt
    """
    ws = _ws()
    _exec(
        ws, "sleep 0.01 & export A=1; export B=2; "
        "cat /s3/report.csv > /disk/copy.txt")
    s = ws.get_session(ws.default_session_id)
    assert s.env["A"] == "1"
    assert s.env["B"] == "2"
    io = _exec(ws, "cat /disk/copy.txt")
    assert b"alice" in _stdout(io)


def test_subshell_pipeline_redirect():
    """Subshell isolates env but output flows.

    (export TMP=inner; cat /s3/report.csv) | sort > /disk/out.txt
    """
    ws = _ws()
    _exec(ws, "(export TMP=inner; cat /s3/report.csv) | "
          "sort > /disk/out.txt")
    s = ws.get_session(ws.default_session_id)
    assert "TMP" not in s.env
    io = _exec(ws, "cat /disk/out.txt")
    out = _stdout(io)
    assert len(out) > 0


def test_brace_group_pipeline():
    """Brace group output piped.

    { echo header; cat /s3/report.csv; } | sort > /disk/combined.txt
    """
    ws = _ws()
    _exec(
        ws, "{ echo header; cat /s3/report.csv; } | "
        "sort > /disk/combined.txt")
    io = _exec(ws, "cat /disk/combined.txt")
    out = _stdout(io)
    assert b"header" in out
    assert b"alice" in out


def test_echo_seq_for_redirect():
    """Use seq in for loop to generate numbered files.

    for n in 1 2 3; do
        echo "file $n" > /disk/f$n.txt
    done
    """
    ws = _ws()
    _exec(ws, 'for n in 1 2 3; do '
          'echo "file $n" > /disk/f$n.txt; done')
    io1 = _exec(ws, "cat /disk/f1.txt")
    io3 = _exec(ws, "cat /disk/f3.txt")
    assert b"file 1" in _stdout(io1)
    assert b"file 3" in _stdout(io3)


def test_multi_pipeline_chain():
    """Chained pipelines with &&.

    cat /s3/report.csv | grep alice && echo found
    """
    ws = _ws()
    io = _exec(ws, "cat /s3/report.csv | grep alice && echo found")
    out = _stdout(io)
    assert b"found" in out


def test_error_handling_or():
    """cat missing || echo fallback.

    When cat fails, fallback runs.
    """
    ws = _ws()
    io = _exec(ws, "cat /s3/missing.txt || echo fallback")
    out = _stdout(io)
    assert b"fallback" in out


def test_full_script_simulation():
    """Simulate a real script:

    export SRC=/s3
    export DST=/disk

    if cat $SRC/report.csv | grep alice; then
        cat $SRC/report.csv | sort > $DST/sorted.txt
        echo "done" > $DST/status.txt
    else
        echo "no data" > $DST/status.txt
    fi
    """
    ws = _ws()
    script = ("export SRC=/s3; export DST=/disk; "
              "if cat $SRC/report.csv | grep alice; then "
              "cat $SRC/report.csv | sort > $DST/sorted.txt; "
              "echo done > $DST/status.txt; "
              "else echo no_data > $DST/status.txt; fi")
    _exec(ws, script)
    io_status = _exec(ws, "cat /disk/status.txt")
    assert b"done" in _stdout(io_status)
    io_sorted = _exec(ws, "cat /disk/sorted.txt")
    out = _stdout(io_sorted)
    assert b"alice" in out
    lines = out.decode().strip().split("\n")
    assert lines == sorted(lines)


# ═══════════════════════════════════════════════
# grep, awk, sed, jq, wc, head, tail, cut, uniq, tr
# ═══════════════════════════════════════════════


def test_grep_pattern():
    ws = _ws()
    io = _exec(ws, "grep alice /s3/report.csv")
    assert io.exit_code == 0
    assert b"alice" in _stdout(io)
    assert b"bob" not in _stdout(io)


def test_grep_count():
    ws = _ws()
    io = _exec(ws, "grep -c GET /s3/access.log")
    assert io.exit_code == 0
    assert b"3" in _stdout(io)


def test_grep_invert():
    ws = _ws()
    io = _exec(ws, "grep -v 500 /s3/access.log")
    out = _stdout(io)
    assert b"500" not in out
    assert b"200" in out


def test_grep_pipe():
    ws = _ws()
    io = _exec(ws, "cat /s3/access.log | grep POST")
    out = _stdout(io)
    assert out.count(b"POST") == 2
    assert b"GET" not in out


def test_awk_print_field():
    ws = _ws()
    io = _exec(ws, "awk -F, '{print $1}' /s3/report.csv")
    out = _stdout(io)
    assert b"name" in out
    assert b"alice" in out


def test_awk_sum():
    ws = _ws()
    io = _exec(ws, "awk -F, 'NR>1{s+=$2}END{print s}' /s3/report.csv")
    out = _stdout(io)
    assert b"55" in out


def test_sed_substitute():
    ws = _ws()
    io = _exec(ws, "sed 's/alice/ALICE/' /s3/report.csv")
    out = _stdout(io)
    assert b"ALICE" in out
    assert b"alice" not in out


def test_sed_delete_line():
    ws = _ws()
    io = _exec(ws, "sed '/bob/d' /s3/report.csv")
    out = _stdout(io)
    assert b"bob" not in out
    assert b"alice" in out


def test_sed_pipe():
    ws = _ws()
    io = _exec(ws, "cat /s3/report.csv | sed 's/,/ | /g'")
    out = _stdout(io)
    assert b" | " in out


def test_jq_field():
    ws = _ws()
    io = _exec(ws, "jq '.[0].name' /s3/users.json")
    out = _stdout(io)
    assert b"alice" in out


def test_jq_length():
    ws = _ws()
    io = _exec(ws, "jq 'length' /s3/users.json")
    out = _stdout(io)
    assert b"2" in out


def test_jq_pipe():
    ws = _ws()
    io = _exec(ws, "cat /s3/users.json | jq '.[1].age'")
    out = _stdout(io)
    assert b"25" in out


def test_wc_lines():
    ws = _ws()
    io = _exec(ws, "wc -l /ram/notes.txt")
    out = _stdout(io)
    assert b"3" in out


def test_wc_pipe():
    ws = _ws()
    io = _exec(ws, "cat /s3/access.log | wc -l")
    out = _stdout(io)
    assert b"5" in out


def test_head_n():
    ws = _ws()
    io = _exec(ws, "head -n 2 /s3/access.log")
    out = _stdout(io)
    lines = out.strip().split(b"\n")
    assert len(lines) == 2


def test_tail_n():
    ws = _ws()
    io = _exec(ws, "tail -n 2 /s3/access.log")
    out = _stdout(io)
    lines = out.strip().split(b"\n")
    assert len(lines) == 2


def test_cut_field():
    ws = _ws()
    io = _exec(ws, "cut -d, -f1 /s3/report.csv")
    out = _stdout(io)
    assert b"name" in out
    assert b"alice" in out


def test_uniq_dedup():
    ws = _ws()
    io = _exec(ws, "sort /ram/words.txt | uniq")
    out = _stdout(io)
    assert out.count(b"apple") == 1


def test_tr_upper():
    ws = _ws()
    io = _exec(ws, "cat /s3/data.txt | tr 'a-z' 'A-Z'")
    out = _stdout(io)
    assert b"HELLO FROM S3" in out


def test_sort_numeric():
    ws = _ws()
    io = _exec(ws, "sort -n /ram/nums.txt")
    out = _stdout(io)
    lines = out.decode().strip().split("\n")
    assert lines == ["1", "2", "3", "4", "5"]


def test_rev_pipe():
    ws = _ws()
    io = _exec(ws, "echo hello | rev")
    assert b"olleh" in _stdout(io)


def test_nl_numbers():
    ws = _ws()
    io = _exec(ws, "nl /ram/notes.txt")
    out = _stdout(io)
    assert b"1" in out
    assert b"line1" in out


# ═══════════════════════════════════════════════
# env set / unset / printenv
# ═══════════════════════════════════════════════


def test_unset_removes():
    ws = _ws()
    _exec(ws, "export FOO=bar; unset FOO")
    assert "FOO" not in ws.get_session(ws.default_session_id).env


def test_printenv_single():
    ws = _ws()
    _exec(ws, "export MY_VAR=hello")
    io = _exec(ws, "printenv MY_VAR")
    assert b"hello" in _stdout(io)


def test_printenv_all():
    ws = _ws()
    _exec(ws, "export A=1; export B=2")
    io = _exec(ws, "printenv")
    out = _stdout(io)
    assert b"A=1" in out
    assert b"B=2" in out


def test_export_override():
    ws = _ws()
    _exec(ws, "export X=first; export X=second")
    assert ws.get_session(ws.default_session_id).env["X"] == "second"


def test_env_in_pipeline():
    ws = _ws()
    _exec(ws, "export PATTERN=alice")
    io = _exec(ws, "cat /s3/report.csv | grep $PATTERN")
    assert b"alice" in _stdout(io)


# ═══════════════════════════════════════════════
# python execution
# ═══════════════════════════════════════════════


def test_python_script():
    ws = _ws()
    io = _exec(ws, "python3 /s3/script.py")
    assert io.exit_code == 0


def test_python_c_flag():
    ws = _ws()
    io = _exec(ws, "python3 -c 'print(1+2)'")
    assert io.exit_code == 0


def test_python_pipe():
    ws = _ws()
    io = _exec(ws, "python3 -c 'print(42)' | grep 42")
    assert io.exit_code == 0


# ═══════════════════════════════════════════════
# echo / sleep / background
# ═══════════════════════════════════════════════


def test_echo_basic_ws():
    ws = _ws()
    io = _exec(ws, "echo hello world")
    assert _stdout(io) == b"hello world\n"


def test_echo_n():
    ws = _ws()
    io = _exec(ws, "echo -n hello")
    assert _stdout(io) == b"hello"


def test_sleep_and_echo():
    ws = _ws()
    io = _exec(ws, "sleep 0; echo done")
    assert b"done" in _stdout(io)


def test_background_foreground():
    ws = _ws()
    io = _exec(ws, "sleep 0.01 & echo foreground")
    assert b"foreground" in _stdout(io)


# ═══════════════════════════════════════════════
# Complex: real-world scripts
# ═══════════════════════════════════════════════


def test_log_analysis():
    """grep 500 | wc -l > error_count.txt."""
    ws = _ws()
    _exec(ws, "grep 500 /s3/access.log | wc -l > /disk/err.txt")
    io = _exec(ws, "cat /disk/err.txt")
    assert b"2" in _stdout(io)


def test_csv_extract_sort_uniq():
    """tail | cut | sort | uniq."""
    ws = _ws()
    io = _exec(ws, "tail -n +2 /s3/report.csv | cut -d, -f1 | sort | uniq")
    out = _stdout(io)
    assert b"alice" in out
    assert b"bob" in out


def test_config_loader():
    """while read LINE; do export $LINE; done."""
    ws = _ws()
    _exec(ws,
          "while read LINE; do export $LINE; done",
          stdin=b"DB_HOST=localhost\nDB_PORT=5432\n")
    s = ws.get_session(ws.default_session_id)
    assert s.env["DB_HOST"] == "localhost"
    assert s.env["DB_PORT"] == "5432"


def test_multi_mount_awk_sort():
    """cat | awk | sort > file."""
    ws = _ws()
    _exec(
        ws, "cat /s3/report.csv | awk -F, 'NR>1{print $1}' | "
        "sort > /disk/names.txt")
    io = _exec(ws, "cat /disk/names.txt")
    out = _stdout(io)
    assert b"alice" in out
    assert b"bob" in out
    lines = out.decode().strip().split("\n")
    assert lines == sorted(lines)


def test_conditional_grep_sed():
    """if grep errors; then sed replace > file."""
    ws = _ws()
    _exec(
        ws, "if grep 500 /s3/access.log; then "
        "grep 500 /s3/access.log | sed 's/500/ERROR/' "
        "> /disk/errors.txt; fi")
    io = _exec(ws, "cat /disk/errors.txt")
    out = _stdout(io)
    assert b"ERROR" in out
    assert b"500" not in out


def test_function_grep_wc():
    """filter() { grep $1 $2 | wc -l; }."""
    ws = _ws()
    _exec(
        ws, "filter() { grep $1 $2 | wc -l > /disk/c.txt; }; "
        "filter GET /s3/access.log")
    io = _exec(ws, "cat /disk/c.txt")
    assert b"3" in _stdout(io)


def test_jq_transform_write():
    """jq extract > file."""
    ws = _ws()
    _exec(ws, "jq '.[].name' /s3/users.json > /disk/names.json")
    io = _exec(ws, "cat /disk/names.json")
    out = _stdout(io)
    assert b"alice" in out
    assert b"bob" in out


def test_background_with_pipeline():
    """Background sleep, foreground pipeline."""
    ws = _ws()
    _exec(
        ws, "sleep 0.01 & "
        "cat /s3/report.csv | grep alice | "
        "sed 's/alice/ALICE/' > /disk/bg.txt")
    io = _exec(ws, "cat /disk/bg.txt")
    assert b"ALICE" in _stdout(io)


def test_nested_function_tr():
    """upper() { tr a-z A-Z; }; process() { cat $1 | upper > $2; }."""
    ws = _ws()
    _exec(
        ws, "upper() { tr 'a-z' 'A-Z'; }; "
        "process() { cat $1 | upper > $2; }; "
        "process /s3/data.txt /disk/upper.txt")
    io = _exec(ws, "cat /disk/upper.txt")
    assert b"HELLO FROM S3" in _stdout(io)


# ═══════════════════════════════════════════════
# Redirect: 2>&1, 2>/dev/null, 2>file
# ═══════════════════════════════════════════════


def test_stderr_to_stdout_merge():
    """cat missing 2>&1 → stderr appears in stdout."""
    ws = _ws()
    io = _exec(ws, "cat /s3/nonexistent.txt 2>&1")
    out = _stdout(io)
    assert b"nonexistent" in out or io.exit_code != 0


def test_stderr_to_file():
    """cat missing 2> /disk/err.log → stderr written to file."""
    ws = _ws()
    _exec(ws, "cat /s3/nonexistent.txt 2> /disk/err.log")
    io = _exec(ws, "cat /disk/err.log")
    out = _stdout(io)
    assert b"nonexistent" in out


def test_stderr_to_dev_null():
    """cat missing 2>/dev/null → stderr suppressed."""
    ws = _ws()
    io = _exec(ws, "cat /s3/nonexistent.txt 2> /disk/null.txt")
    assert io.stderr is None or io.stderr == b""


def test_stderr_merge_in_pipeline():
    """{ cat /s3/report.csv; cat /s3/missing.txt; } 2>&1 | sort."""
    ws = _ws()
    io = _exec(ws, "{ cat /s3/report.csv; } 2>&1 | sort")
    assert io.exit_code == 0


def test_stdout_redirect_preserves_stderr():
    """echo hello > /disk/out.txt → stderr not affected."""
    ws = _ws()
    io = _exec(ws, "echo hello > /disk/out.txt")
    assert io.exit_code == 0
    io2 = _exec(ws, "cat /disk/out.txt")
    assert b"hello" in _stdout(io2)


def test_stderr_redirect_preserves_stdout():
    """cat /s3/report.csv 2> /disk/err.log → stdout still works."""
    ws = _ws()
    io = _exec(ws, "cat /s3/report.csv 2> /disk/err.log")
    assert io.exit_code == 0
    assert b"alice" in _stdout(io)


def test_both_to_file():
    """echo hello &> /disk/both.txt → stdout+stderr to file."""
    ws = _ws()
    _exec(ws, "echo hello &> /disk/both.txt")
    io = _exec(ws, "cat /disk/both.txt")
    assert b"hello" in _stdout(io)


def test_both_append():
    """echo a &> /disk/b.txt; echo b &>> /disk/b.txt → both appended."""
    ws = _ws()
    _exec(ws, "echo first &> /disk/b.txt")
    _exec(ws, "echo second &>> /disk/b.txt")
    io = _exec(ws, "cat /disk/b.txt")
    out = _stdout(io)
    assert b"first" in out
    assert b"second" in out


def test_stderr_to_file_on_error():
    """cat missing 2> /disk/err.txt → error goes to file."""
    ws = _ws()
    _exec(ws, "cat /s3/nope.txt 2> /disk/err.txt")
    io = _exec(ws, "cat /disk/err.txt")
    assert b"nope" in _stdout(io)


def test_both_redirect_on_error():
    """cat missing &> /disk/all.txt → stderr in file."""
    ws = _ws()
    _exec(ws, "cat /s3/nope.txt &> /disk/all.txt")
    io = _exec(ws, "cat /disk/all.txt")
    assert b"nope" in _stdout(io)


def test_redirect_with_var_target():
    """echo hello > $OUT → expanded path."""
    ws = _ws()
    _exec(ws, "export OUT=/disk/var_out.txt")
    _exec(ws, "echo hello > $OUT")
    io = _exec(ws, "cat /disk/var_out.txt")
    assert b"hello" in _stdout(io)


def test_multiple_redirects_stdout_and_stderr():
    """cat report > /disk/out.txt 2> /disk/err.txt."""
    ws = _ws()
    _exec(ws, "cat /s3/report.csv > /disk/out.txt 2> /disk/err.txt")
    io = _exec(ws, "cat /disk/out.txt")
    assert b"alice" in _stdout(io)


# ═══════════════════════════════════════════════
# Pipeline mount fallback (no path args)
# ═══════════════════════════════════════════════


def test_pipeline_wc():
    """cat file | wc -l → wc finds mount via fallback."""
    ws = _ws()
    io = _exec(ws, "cat /ram/notes.txt | wc -l")
    assert io.exit_code == 0
    assert b"3" in _stdout(io)


def test_pipeline_head():
    """cat file | head -n 1 → head finds mount."""
    ws = _ws()
    io = _exec(ws, "cat /ram/notes.txt | head -n 1")
    assert io.exit_code == 0
    out = _stdout(io)
    assert b"line1" in out


def test_pipeline_tail():
    """cat file | tail -n 1 → tail finds mount."""
    ws = _ws()
    io = _exec(ws, "cat /ram/notes.txt | tail -n 1")
    assert io.exit_code == 0
    out = _stdout(io)
    assert b"line3" in out


def test_pipeline_uniq():
    """sort file | uniq → uniq finds mount."""
    ws = _ws()
    io = _exec(ws, "sort /ram/words.txt | uniq")
    assert io.exit_code == 0
    out = _stdout(io)
    assert out.count(b"apple") == 1


def test_pipeline_cut():
    """cat csv | cut -d, -f1 → cut finds mount."""
    ws = _ws()
    io = _exec(ws, "cat /s3/report.csv | cut -d, -f1")
    assert io.exit_code == 0
    out = _stdout(io)
    assert b"name" in out
    assert b"alice" in out


def test_pipeline_tr():
    """cat file | tr a-z A-Z → tr finds mount."""
    ws = _ws()
    io = _exec(ws, "cat /s3/data.txt | tr 'a-z' 'A-Z'")
    assert io.exit_code == 0
    assert b"HELLO" in _stdout(io)


def test_pipeline_grep():
    """cat file | grep pattern → grep finds mount."""
    ws = _ws()
    io = _exec(ws, "cat /s3/report.csv | grep alice")
    assert io.exit_code == 0
    assert b"alice" in _stdout(io)


def test_pipeline_four_stages():
    """cat | grep | sort | uniq → all find mounts."""
    ws = _ws()
    io = _exec(ws, "cat /ram/words.txt | grep apple | sort | uniq")
    assert io.exit_code == 0
    assert _stdout(io).count(b"apple") == 1


def test_pipeline_with_default_cwd():
    """Pipeline works even with default cwd=/mirage."""
    ws = _ws()
    ws.get_session(ws.default_session_id).cwd = "/mirage"
    io = _exec(ws, "cat /s3/report.csv | wc -l")
    assert io.exit_code == 0
    assert b"3" in _stdout(io)


def test_pipeline_sed():
    """cat file | sed → sed finds mount."""
    ws = _ws()
    io = _exec(ws, "cat /s3/report.csv | sed 's/alice/ALICE/'")
    assert io.exit_code == 0
    assert b"ALICE" in _stdout(io)


# ── cache resource fallback ───────────────────


def test_cache_fallback_wc():
    """wc uses cache resource when cwd has no mount."""
    ws = _ws()
    ws.get_session(ws.default_session_id).cwd = "/mirage"
    io = _exec(ws, "cat /s3/report.csv | wc -l")
    assert io.exit_code == 0
    assert b"3" in _stdout(io)


def test_cache_fallback_head():
    """head uses cache resource fallback."""
    ws = _ws()
    ws.get_session(ws.default_session_id).cwd = "/nonexistent"
    io = _exec(ws, "cat /ram/notes.txt | head -n 1")
    assert io.exit_code == 0
    assert b"line1" in _stdout(io)


def test_cache_fallback_grep():
    """grep uses cache resource fallback in pipeline."""
    ws = _ws()
    ws.get_session(ws.default_session_id).cwd = "/mirage"
    io = _exec(ws, "cat /s3/report.csv | grep alice")
    assert io.exit_code == 0
    assert b"alice" in _stdout(io)


def test_cache_fallback_sort_uniq():
    """sort | uniq uses cache resource."""
    ws = _ws()
    ws.get_session(ws.default_session_id).cwd = "/mirage"
    io = _exec(ws, "cat /ram/words.txt | sort | uniq")
    assert io.exit_code == 0
    assert _stdout(io).count(b"apple") == 1


def test_cache_fallback_multi_pipe():
    """Four-stage pipeline with cache fallback."""
    ws = _ws()
    ws.get_session(ws.default_session_id).cwd = "/mirage"
    io = _exec(ws, "cat /s3/report.csv | grep -v name "
               "| cut -d, -f1 | sort")
    assert io.exit_code == 0
    out = _stdout(io)
    assert b"alice" in out
    assert b"bob" in out


# ── general commands (no resource needed) ─────


def test_seq_basic():
    ws = _ws()
    io = _exec(ws, "seq 5")
    assert _stdout(io) == b"1\n2\n3\n4\n5\n"


def test_seq_range():
    ws = _ws()
    io = _exec(ws, "seq 2 5")
    assert _stdout(io) == b"2\n3\n4\n5\n"


def test_seq_separator():
    ws = _ws()
    io = _exec(ws, "seq -s , 3")
    assert _stdout(io) == b"1,2,3\n"


def test_expr_add():
    ws = _ws()
    io = _exec(ws, "expr 2 + 3")
    assert b"5" in _stdout(io)


def test_expr_multiply():
    ws = _ws()
    io = _exec(ws, "expr 4 '*' 3")
    assert b"12" in _stdout(io)


def test_bc_basic():
    ws = _ws()
    io = _exec(ws, "echo '2+3' | bc")
    assert b"5" in _stdout(io)


def test_date_iso():
    ws = _ws()
    io = _exec(ws, "date -I")
    assert io.exit_code == 0
    out = _stdout(io)
    assert b"202" in out


def test_echo_escape():
    ws = _ws()
    io = _exec(ws, "echo -e 'hello\\nworld'")
    assert _stdout(io) == b"hello\nworld\n"


def test_echo_no_newline():
    ws = _ws()
    io = _exec(ws, "echo -n hello")
    assert _stdout(io) == b"hello"


# ── sort as resource command ──────────────────


def test_sort_file():
    ws = _ws()
    io = _exec(ws, "sort /ram/nums.txt")
    assert io.exit_code == 0
    lines = _stdout(io).decode().strip().split("\n")
    assert lines == ["1", "2", "3", "4", "5"]


def test_sort_reverse():
    ws = _ws()
    io = _exec(ws, "sort -r /ram/nums.txt")
    lines = _stdout(io).decode().strip().split("\n")
    assert lines == ["5", "4", "3", "2", "1"]


def test_sort_stdin():
    ws = _ws()
    io = _exec(ws, "echo '3\n1\n2' | sort -n")
    assert io.exit_code == 0


def test_sort_unique():
    ws = _ws()
    io = _exec(ws, "sort -u /ram/words.txt")
    lines = _stdout(io).decode().strip().split("\n")
    assert len(lines) == 3


def test_seq_one():
    ws = _ws()
    io = _exec(ws, "seq 1")
    assert _stdout(io) == b"1\n"


def test_seq_in_for():
    ws = _ws()
    io = _exec(ws, "for n in $(seq 3); do echo $n; done")
    out = _stdout(io)
    assert b"1" in out
    assert b"3" in out


def test_sort_empty_stdin():
    ws = _ws()
    io = _exec(ws, "echo -n '' | sort")
    assert io.exit_code == 0


def test_sort_single_line():
    ws = _ws()
    io = _exec(ws, "echo hello | sort")
    assert b"hello" in _stdout(io)


def test_expr_zero_returns_1():
    ws = _ws()
    io = _exec(ws, "expr 0 + 0")
    assert io.exit_code == 1


def test_seq_in_pipeline():
    ws = _ws()
    io = _exec(ws, "seq 5 | sort -rn")
    lines = _stdout(io).decode().strip().split("\n")
    assert lines == ["5", "4", "3", "2", "1"]


# ── python3 ───────────────────────────────────


def test_python3_c_simple():
    ws = _ws()
    io = _exec(ws, 'python3 -c "print(42)"')
    assert io.exit_code == 0
    assert _stdout(io) == b"42\n"


def test_python3_c_multiline():
    ws = _ws()
    io = _exec(ws, 'python3 -c "x = 2\nprint(x * 3)"')
    assert _stdout(io) == b"6\n"


def test_python3_c_with_stdin():
    # sys.stdin is a host feature: monty has no stdin, so piping into
    # python3 is a local-runtime capability.
    ws = _ws()
    ws._registry.runtime_bindings = {
        "python3": LocalRuntime(),
        "python": LocalRuntime()
    }
    io = _exec(
        ws, 'echo hello | python3 -c "import sys; '
        'print(sys.stdin.read().strip().upper())"')
    assert _stdout(io) == b"HELLO\n"


def test_python3_c_path_in_code():
    """Paths inside -c code should not be classified as PathSpec."""
    ws = _ws()
    io = _exec(ws, "python3 -c \"print('/s3/data/file.txt')\"")
    assert io.exit_code == 0
    assert b"/s3/data/file.txt" in _stdout(io)


def test_python3_c_with_star():
    """* in -c code should not be glob-expanded."""
    ws = _ws()
    io = _exec(ws, 'python3 -c "print(2 * 3)"')
    assert _stdout(io) == b"6\n"


def test_python3_script_file():
    ws = _ws()
    _exec(ws, "echo 'print(99)' > /disk/script.py")
    io = _exec(ws, "python3 /disk/script.py")
    assert io.exit_code == 0
    assert _stdout(io) == b"99\n"


def test_python3_session_env():
    ws = _ws()
    _exec(ws, "export MY_VAR=hello_mirage")
    io = _exec(
        ws, 'python3 -c "import os; '
        "print(os.environ.get('MY_VAR', 'none'))\"")
    assert _stdout(io) == b"hello_mirage\n"


def test_python3_no_args():
    ws = _ws()
    io = _exec(ws, "python3")
    assert io.exit_code == 1
    assert b"no input" in io.stderr


def test_python3_c_with_argv():
    """python3 -c "code" arg1 arg2 → arg1/arg2 reach sys.argv as bare text."""
    ws = _ws()
    # sys.argv is a host feature; monty exposes `argv` instead.
    ws._registry.runtime_bindings = {
        "python3": LocalRuntime(),
        "python": LocalRuntime()
    }
    io = _exec(ws, 'python3 -c "import sys; print(sys.argv[1:])" alpha beta')
    assert io.exit_code == 0
    assert _stdout(io) == b"['alpha', 'beta']\n"


def test_python3_c_with_abs_path_argv():
    """python3 -c "code" /abs/path → abs path stays text argv (not script)."""
    ws = _ws()
    # sys.argv is a host feature; monty exposes `argv` instead.
    ws._registry.runtime_bindings = {
        "python3": LocalRuntime(),
        "python": LocalRuntime()
    }
    io = _exec(ws,
               'python3 -c "import sys; print(sys.argv[1:])" /disk/some_file')
    assert io.exit_code == 0
    assert _stdout(io) == b"['/disk/some_file']\n"


def test_python3_script_with_argv():
    """python3 /abs/script.py arg1 arg2 → script reads, argv passed through."""
    ws = _ws()
    # sys.argv is a host feature; monty exposes `argv` instead.
    ws._registry.runtime_bindings = {
        "python3": LocalRuntime(),
        "python": LocalRuntime()
    }
    _exec(ws, "echo 'import sys; print(sys.argv[1:])' > /disk/argv.py")
    io = _exec(ws, "python3 /disk/argv.py alpha beta")
    assert io.exit_code == 0
    assert _stdout(io) == b"['alpha', 'beta']\n"


def test_python3_bare_name_script_via_cwd():
    """python3 script.py (bare name) → resolves against cwd, dispatched."""
    ws = _ws()
    _exec(ws, "echo 'print(123)' > /disk/bare.py")
    io = _exec(ws, "cd /disk && python3 bare.py")
    assert io.exit_code == 0
    assert _stdout(io) == b"123\n"


def test_python3_bare_name_script_with_argv():
    """python3 script.py one two (bare + argv) → cwd-resolved + argv passes."""
    ws = _ws()
    # sys.argv is a host feature; monty exposes `argv` instead.
    ws._registry.runtime_bindings = {
        "python3": LocalRuntime(),
        "python": LocalRuntime()
    }
    _exec(ws, "echo 'import sys; print(sys.argv[1:])' > /disk/with_argv.py")
    io = _exec(ws, "cd /disk && python3 with_argv.py one two")
    assert io.exit_code == 0
    assert _stdout(io) == b"['one', 'two']\n"


# ── cross-mount commands ──────────────────────


def test_cross_mount_cp():
    ws = _ws()
    io = _exec(ws, "cp /s3/data.txt /disk/data_copy.txt")
    assert io.exit_code == 0
    io = _exec(ws, "cat /disk/data_copy.txt")
    assert b"hello from s3" in _stdout(io)


def test_cross_mount_cp_reverse():
    ws = _ws()
    _exec(ws, "echo test_data > /disk/new.txt")
    io = _exec(ws, "cp /disk/new.txt /ram/new_copy.txt")
    assert io.exit_code == 0
    io = _exec(ws, "cat /ram/new_copy.txt")
    assert b"test_data" in _stdout(io)


def test_cross_mount_mv():
    ws = _ws()
    _exec(ws, "echo moveme > /disk/moveme.txt")
    io = _exec(ws, "mv /disk/moveme.txt /ram/moved.txt")
    assert io.exit_code == 0
    io = _exec(ws, "cat /ram/moved.txt")
    assert b"moveme" in _stdout(io)
    io = _exec(ws, "cat /disk/moveme.txt")
    assert io.exit_code == 1


def test_cross_mount_cp_into_directory():
    ws = _ws()
    _exec(ws, "echo source > /ram/source.txt")
    _exec(ws, "mkdir /disk/target")

    io = _exec(ws, "cp /ram/source.txt /disk/target")

    assert io.exit_code == 0
    assert _stdout(_exec(ws, "cat /disk/target/source.txt")) == b"source\n"
    assert _stdout(_exec(ws, "cat /ram/source.txt")) == b"source\n"


def test_cross_mount_mv_into_directory():
    ws = _ws()
    _exec(ws, "echo source > /ram/source.txt")
    _exec(ws, "mkdir /disk/target")

    io = _exec(ws, "mv /ram/source.txt /disk/target")

    assert io.exit_code == 0
    assert _stdout(_exec(ws, "cat /disk/target/source.txt")) == b"source\n"
    assert _exec(ws, "cat /ram/source.txt").exit_code != 0


def test_cross_mount_cp_no_clobber_into_directory():
    ws = _ws()
    _exec(ws, "echo source > /ram/source.txt")
    _exec(ws, "mkdir /disk/target")
    _exec(ws, "echo existing > /disk/target/source.txt")

    io = _exec(ws, "cp -vn /ram/source.txt /disk/target")

    assert io.exit_code == 0
    assert _stdout(_exec(ws, "cat /disk/target/source.txt")) == b"existing\n"
    assert _stdout(_exec(ws, "cat /ram/source.txt")) == b"source\n"


def test_cross_mount_mv_no_clobber_into_directory():
    ws = _ws()
    _exec(ws, "echo source > /ram/source.txt")
    _exec(ws, "mkdir /disk/target")
    _exec(ws, "echo existing > /disk/target/source.txt")

    io = _exec(ws, "mv -vn /ram/source.txt /disk/target")

    assert io.exit_code == 0
    assert _stdout(_exec(ws, "cat /disk/target/source.txt")) == b"existing\n"
    assert _stdout(_exec(ws, "cat /ram/source.txt")) == b"source\n"


def test_cross_mount_cp_no_clobber_duplicate_basenames():
    ws = _ws()
    _exec(ws, "echo first > /ram/shared.txt")
    _exec(ws, "echo second > /s3/shared.txt")
    _exec(ws, "mkdir /disk/target")

    io = _exec(ws, "cp -n /ram/shared.txt /s3/shared.txt /disk/target")

    assert io.exit_code == 0
    assert _stdout(_exec(ws, "cat /disk/target/shared.txt")) == b"first\n"
    assert _stdout(_exec(ws, "cat /ram/shared.txt")) == b"first\n"
    assert _stdout(_exec(ws, "cat /s3/shared.txt")) == b"second\n"


def test_cross_mount_no_clobber_uses_shared_command_spec():
    ws = _ws()
    source_mount = ws._registry.mount_for("/ram/source.txt")
    source_mount._cmds.pop(("cp", None))
    source_mount._cmd_specs.pop("cp")
    _exec(ws, "echo source > /ram/source.txt")
    _exec(ws, "echo existing > /disk/target.txt")

    io = _exec(ws, "cp -n /ram/source.txt /disk/target.txt")

    assert io.exit_code == 0
    assert _stdout(_exec(ws, "cat /disk/target.txt")) == b"existing\n"
    assert _stdout(_exec(ws, "cat /ram/source.txt")) == b"source\n"


def test_cross_mount_mv_no_clobber_duplicate_basenames():
    ws = _ws()
    _exec(ws, "echo first > /ram/shared.txt")
    _exec(ws, "echo second > /s3/shared.txt")
    _exec(ws, "mkdir /disk/target")

    io = _exec(ws, "mv -n /ram/shared.txt /s3/shared.txt /disk/target")

    assert io.exit_code == 0
    assert _stdout(_exec(ws, "cat /disk/target/shared.txt")) == b"first\n"
    assert _exec(ws, "cat /ram/shared.txt").exit_code != 0
    assert _stdout(_exec(ws, "cat /s3/shared.txt")) == b"second\n"


def test_cross_mount_cp_multiple_sources_require_directory():
    ws = _ws()
    _exec(ws, "echo first > /ram/a.txt")
    _exec(ws, "echo second > /ram/b.txt")
    _exec(ws, "echo target > /disk/target.txt")

    io = _exec(ws, "cp /ram/a.txt /ram/b.txt /disk/target.txt")

    assert io.exit_code != 0
    assert io.stderr == b"cp: target '/disk/target.txt': Not a directory\n"
    assert _stdout(_exec(ws, "cat /ram/a.txt")) == b"first\n"
    assert _stdout(_exec(ws, "cat /ram/b.txt")) == b"second\n"
    assert _stdout(_exec(ws, "cat /disk/target.txt")) == b"target\n"


def test_cross_mount_mv_multiple_sources_require_directory():
    ws = _ws()
    _exec(ws, "echo first > /ram/a.txt")
    _exec(ws, "echo second > /ram/b.txt")
    _exec(ws, "echo target > /disk/target.txt")

    io = _exec(ws, "mv /ram/a.txt /ram/b.txt /disk/target.txt")

    assert io.exit_code != 0
    assert io.stderr == b"mv: target '/disk/target.txt': Not a directory\n"
    assert _stdout(_exec(ws, "cat /ram/a.txt")) == b"first\n"
    assert _stdout(_exec(ws, "cat /ram/b.txt")) == b"second\n"
    assert _stdout(_exec(ws, "cat /disk/target.txt")) == b"target\n"


def test_cross_mount_cp_multiple_sources_into_directory():
    ws = _ws()
    _exec(ws, "echo first > /ram/a.txt")
    _exec(ws, "echo second > /ram/b.txt")
    _exec(ws, "mkdir /disk/target")

    io = _exec(ws, "cp /ram/a.txt /ram/b.txt /disk/target")

    assert io.exit_code == 0
    assert _stdout(_exec(ws, "cat /disk/target/a.txt")) == b"first\n"
    assert _stdout(_exec(ws, "cat /disk/target/b.txt")) == b"second\n"
    assert _stdout(_exec(ws, "cat /ram/a.txt")) == b"first\n"
    assert _stdout(_exec(ws, "cat /ram/b.txt")) == b"second\n"


def test_cross_mount_mv_multiple_sources_into_directory():
    ws = _ws()
    _exec(ws, "echo first > /ram/a.txt")
    _exec(ws, "echo second > /ram/b.txt")
    _exec(ws, "mkdir /disk/target")

    io = _exec(ws, "mv /ram/a.txt /ram/b.txt /disk/target")

    assert io.exit_code == 0
    assert _stdout(_exec(ws, "cat /disk/target/a.txt")) == b"first\n"
    assert _stdout(_exec(ws, "cat /disk/target/b.txt")) == b"second\n"
    assert _exec(ws, "cat /ram/a.txt").exit_code != 0
    assert _exec(ws, "cat /ram/b.txt").exit_code != 0


def test_cross_mount_diff_same():
    ws = _ws()
    _exec(ws, "echo same > /disk/a.txt")
    _exec(ws, "echo same > /ram/b.txt")
    io = _exec(ws, "diff /disk/a.txt /ram/b.txt")
    assert io.exit_code == 0


def test_cross_mount_diff_different():
    ws = _ws()
    _exec(ws, "echo aaa > /disk/a.txt")
    _exec(ws, "echo bbb > /ram/b.txt")
    io = _exec(ws, "diff /disk/a.txt /ram/b.txt")
    assert io.exit_code == 1
    out = _stdout(io)
    assert b"< aaa" in out
    assert b"> bbb" in out


def test_cross_mount_cmp_same():
    ws = _ws()
    _exec(ws, "echo identical > /disk/a.txt")
    _exec(ws, "echo identical > /ram/b.txt")
    io = _exec(ws, "cmp /disk/a.txt /ram/b.txt")
    assert io.exit_code == 0


def test_cross_mount_cmp_different():
    ws = _ws()
    _exec(ws, "echo xxx > /disk/a.txt")
    _exec(ws, "echo yyy > /ram/b.txt")
    io = _exec(ws, "cmp /disk/a.txt /ram/b.txt")
    assert io.exit_code == 1
    assert b"differ" in _stdout(io)


# ── cache hit/miss/invalidation ─────────────────────────────────────────


def test_cache_hit_serves_from_ram():
    ws = _ws()
    io1 = _exec(ws, "cat /s3/data.txt")
    assert _stdout(io1) == b"hello from s3\n"
    records_after_first = list(ws.ops.records)
    io2 = _exec(ws, "cat /s3/data.txt")
    assert _stdout(io2) == b"hello from s3\n"
    new_records = ws.ops.records[len(records_after_first):]
    sources = [r.source for r in new_records if r.op == "read"]
    assert all(s == "ram" for s in sources)


def test_cache_miss_reads_from_resource():
    ws = _ws()
    io = _exec(ws, "cat /s3/data.txt")
    assert _stdout(io) == b"hello from s3\n"
    sources = [r.source for r in ws.ops.records if r.op == "read"]
    assert sources[0] == "ram"


def test_cache_invalidation_after_write():
    ws = _ws()
    _exec(ws, "cat /disk/readme.txt")
    cached = _run(ws._cache.get("/disk/readme.txt"))
    assert cached is not None
    _exec(ws, "echo updated > /disk/readme.txt")
    cached = _run(ws._cache.get("/disk/readme.txt"))
    assert cached is None


def test_grep_uses_cache():
    ws = _ws()
    _exec(ws, "cat /s3/report.csv")
    records_before = len(ws.ops.records)
    io = _exec(ws, "grep alice /s3/report.csv")
    assert b"alice" in _stdout(io)
    new_records = ws.ops.records[records_before:]
    sources = [r.source for r in new_records if r.op == "read"]
    assert all(s == "ram" for s in sources)


# ── readdir index cache ─────────────────────────────────────────────────


def test_readdir_populates_index():
    """readdir stores listing in index (verified via internal dict)."""
    ws = _ws()
    resource = ws._registry.mount_for("/s3/report.csv").resource
    assert len(resource.index._entries) == 0
    _exec(ws, "ls /s3")
    assert len(resource.index._entries) > 0


# ── grep -l / -m early termination ──────────────────────────────────────


def test_grep_files_only_returns_filename():
    """grep -l returns matching filename, not content."""
    ws = _ws()
    io = _exec(ws, "grep -l alice /s3/report.csv")
    stdout = _stdout(io)
    assert b"report.csv" in stdout
    assert b"alice,30" not in stdout


def test_grep_max_count_limits_output():
    """grep -m 1 returns at most 1 match."""
    ws = _ws()
    io = _exec(ws, "grep -m 1 GET /s3/access.log")
    lines = _stdout(io).strip().split(b"\n")
    assert len(lines) == 1
    assert b"GET" in lines[0]


# ── for loop break/continue ─────────────────────────────────────────────


def test_for_break_preserves_output():
    """break stops loop but output before break is preserved."""
    ws = _ws()
    io = _exec(ws, "for x in a b c; do echo $x; break; done")
    assert _stdout(io) == b"a\n"


def test_for_break_with_condition():
    """for with [ $x = c ] break stops at correct iteration."""
    ws = _ws()
    io = _exec(
        ws,
        "for x in a b c d; do if [ $x = c ]; then break; fi; echo $x; done")
    assert _stdout(io) == b"a\nb\n"


def test_for_continue_with_condition():
    """for with [ $x = b ] continue skips that iteration."""
    ws = _ws()
    io = _exec(
        ws,
        "for x in a b c d; do if [ $x = b ]; then continue; fi; echo $x; done")
    assert _stdout(io) == b"a\nc\nd\n"


def test_for_continue_skips_iteration():
    """continue skips to next iteration, other iterations produce output."""
    ws = _ws()
    io = _exec(ws,
               "for x in a b c; do if true; then echo $x; continue; fi; done")
    assert _stdout(io) == b"a\nb\nc\n"


def test_test_literal_comparison():
    """[ a = a ] and [ a = b ] work correctly."""
    ws = _ws()
    assert _stdout(_exec(ws, "[ a = a ] && echo yes || echo no")) == b"yes\n"
    assert _stdout(_exec(ws, "[ a = b ] && echo yes || echo no")) == b"no\n"


def test_test_variable_comparison():
    """[ $var = value ] expands variable and compares."""
    ws = _ws()
    io = _exec(ws,
               "for x in a c; do [ $x = c ] && echo match || echo miss; done")
    assert _stdout(io) == b"miss\nmatch\n"


def test_test_numeric_comparison():
    """[ -lt -gt -eq ] numeric comparisons work."""
    ws = _ws()
    assert _stdout(_exec(ws, "[ 1 -lt 3 ] && echo yes || echo no")) == b"yes\n"
    assert _stdout(_exec(ws, "[ 3 -gt 1 ] && echo yes || echo no")) == b"yes\n"
    assert _stdout(_exec(ws, "[ 2 -eq 2 ] && echo yes || echo no")) == b"yes\n"


def test_arithmetic_expansion():
    """$(( )) arithmetic with variables works."""
    ws = _ws()
    assert _stdout(_exec(ws, "echo $((1 + 2))")) == b"3\n"
    assert _stdout(_exec(ws, "x=5; echo $(($x + 1))")) == b"6\n"


def test_while_loop():
    """while [ $x -lt 3 ] with arithmetic increment."""
    ws = _ws()
    io = _exec(ws, "x=0; while [ $x -lt 3 ]; do echo $x; x=$(($x + 1)); done")
    assert _stdout(io) == b"0\n1\n2\n"


def test_eval_echo():
    """eval executes command and returns stdout."""
    ws = _ws()
    assert _stdout(_exec(ws, 'eval "echo hello"')) == b"hello\n"


def test_eval_variable():
    """eval expands variables."""
    ws = _ws()
    assert _stdout(_exec(ws, 'x=hello; eval "echo $x"')) == b"hello\n"


# ── bash / sh ───────────────────────────────────────────────────────────


def test_bash_dash_c_basic():
    """`bash -c '...'` runs the script through Mirage's shell."""
    ws = _ws()
    assert _stdout(_exec(ws, "bash -c 'echo hello'")) == b"hello\n"


def test_bash_combined_short_flags():
    """`bash -lc '...'` unbundles -l (no-op) + -c (script)."""
    ws = _ws()
    io = _exec(ws, "bash -lc 'echo combined'")
    assert io.exit_code == 0
    assert _stdout(io) == b"combined\n"


def test_sh_alias():
    """`sh -c '...'` is treated as an alias for bash."""
    ws = _ws()
    assert _stdout(_exec(ws, 'sh -c "echo via-sh"')) == b"via-sh\n"


def test_bash_dash_c_for_loop_over_dirs():
    """`bash -lc` with a for-loop iterating mount paths."""
    s3 = RAMResource()
    s3.caches_reads = True
    s3._store.dirs.update({
        "/INBOX",
        "/INBOX/2026-04-28",
        "/INBOX/2026-04-29",
    })
    s3._store.files["/INBOX/2026-04-28/m1.txt"] = b""
    s3._store.files["/INBOX/2026-04-29/m2.txt"] = b""
    ws = Workspace(resources={"/gmail/": (s3, MountMode.EXEC)}, )
    cmd = ('bash -lc \'for d in /gmail/INBOX/2026-04-28 '
           '/gmail/INBOX/2026-04-29; do echo "== $d =="; ls "$d"; done\'')
    io = _exec(ws, cmd)
    assert io.exit_code == 0
    out = _stdout(io)
    assert b"== /gmail/INBOX/2026-04-28 ==" in out
    assert b"== /gmail/INBOX/2026-04-29 ==" in out
    assert b"m1.txt" in out
    assert b"m2.txt" in out


def test_bash_pipes_through_mirage_shell():
    """Pipes inside `bash -c '...'` go back through the same parser."""
    ws = _ws()
    io = _exec(ws, "bash -c 'echo hello | tr a-z A-Z'")
    assert io.exit_code == 0
    assert _stdout(io) == b"HELLO\n"


def test_bash_stdin_flows_into_script():
    """Stdin piped into `bash -c '...'` is visible to the inner command."""
    ws = _ws()
    io = _exec(ws, 'echo piped | bash -c "cat"')
    assert io.exit_code == 0
    assert _stdout(io) == b"piped\n"


def test_bash_dash_s_reads_script_from_stdin():
    """`bash -s` reads the script body from stdin."""
    ws = _ws()
    io = _exec(ws, 'echo "echo from-stdin" | bash -s')
    assert io.exit_code == 0
    assert _stdout(io) == b"from-stdin\n"


def test_bash_missing_c_argument_errors():
    """`bash -c` with no argument is a usage error."""
    ws = _ws()
    io = _exec(ws, "bash -c")
    assert io.exit_code == 2
    assert b"-c" in (io.stderr or b"")


def test_man_bash_renders_spec():
    """`man bash` returns the bash spec; `man sh` is the same."""
    ws = _ws()
    io = _exec(ws, "man bash")
    assert io.exit_code == 0
    out = _stdout(io)
    assert b"# bash" in out
    assert b"-c" in out
    assert b"RESOURCES" not in out
    io2 = _exec(ws, "man sh")
    assert io2.exit_code == 0
    assert b"# sh" in _stdout(io2)


def test_python_pipe_stdin():
    """echo code | python3 reads code from stdin."""
    ws = _ws()
    io = _exec(ws, 'echo "print(1+2)" | python3')
    assert _stdout(io) == b"3\n"


# ── function fixes ───────────────────────────────────────────────────────


def test_function_return_exit_code():
    """return N propagates exit code for || and &&."""
    ws = _ws()
    assert _stdout(_exec(
        ws, "check() { return 1; }; check || echo failed")) == b"failed\n"
    assert _stdout(_exec(
        ws, "ok() { return 0; }; ok && echo success")) == b"success\n"


def test_function_local_scope():
    """local variables restored after function returns."""
    ws = _ws()
    io = _exec(ws, 'x=outside; f() { local x=inside; echo $x; }; f; echo $x')
    assert _stdout(io) == b"inside\noutside\n"


def test_function_shift_args():
    """shift in function moves positional params."""
    ws = _ws()
    io = _exec(ws, "f() { echo $1; shift; echo $1; }; f a b")
    assert _stdout(io) == b"a\nb\n"


def test_function_nested_output():
    """nested function calls preserve all output."""
    ws = _ws()
    io = _exec(
        ws, "inner() { echo inner; }; outer() { inner; echo outer; }; outer")
    assert _stdout(io) == b"inner\nouter\n"


# ── cross-mount multi-file ───────────────────────────────────────────────


def test_cat_cross_mount():
    """cat files from different mounts concatenates correctly."""
    ws = _ws()
    io = _exec(ws, "cat /s3/data.txt /disk/readme.txt")
    assert b"hello from s3" in _stdout(io)
    assert b"disk readme" in _stdout(io)


def test_head_cross_mount():
    """head -n 1 across mounts shows headers."""
    ws = _ws()
    io = _exec(ws, "head -n 1 /s3/data.txt /disk/readme.txt")
    out = _stdout(io)
    assert b"==> /s3/data.txt <==" in out
    assert b"==> /disk/readme.txt <==" in out


def test_grep_cross_mount():
    """grep across mounts prefixes filename."""
    ws = _ws()
    io = _exec(ws, "grep hello /s3/data.txt /disk/readme.txt")
    out = _stdout(io)
    assert b"/s3/data.txt:" in out


def test_wc_cross_mount():
    """wc -l across mounts shows per-file counts."""
    ws = _ws()
    io = _exec(ws, "wc -l /s3/data.txt /disk/readme.txt")
    out = _stdout(io)
    assert b"/s3/data.txt" in out
    assert b"/disk/readme.txt" in out


# ── heredoc/herestring ───────────────────────────────────────────────────


def test_heredoc():
    """cat << EOF passes body as stdin."""
    ws = _ws()
    io = _exec(ws, "cat << EOF\nhello\nworld\nEOF")
    assert _stdout(io) == b"hello\nworld\n"


def test_herestring():
    """cat <<< passes string as stdin."""
    ws = _ws()
    io = _exec(ws, 'cat <<< "hello world"')
    assert _stdout(io) == b"hello world\n"


def test_python_heredoc():
    """python3 << EOF runs code from heredoc."""
    ws = _ws()
    io = _exec(ws, "python3 << 'PYEOF'\nprint(1 + 2)\nPYEOF")
    assert _stdout(io) == b"3\n"


def test_heredoc_unquoted_expands_vars():
    """Unquoted heredoc delimiter → variables get expanded in body."""
    ws = _ws()
    io = _exec(ws, "X=world\ncat << EOF\nhello $X\nEOF")
    assert _stdout(io) == b"hello world\n"


def test_heredoc_quoted_no_expansion():
    """Quoted delimiter ('EOF') → variables are literal in body."""
    ws = _ws()
    io = _exec(ws, "X=world\ncat << 'EOF'\nhello $X\nEOF")
    assert _stdout(io) == b"hello $X\n"


def test_heredoc_dash_strips_tabs():
    """<<-EOF strips leading tabs from each body line."""
    ws = _ws()
    io = _exec(ws, "cat <<-EOF\n\thello\n\tworld\nEOF")
    assert _stdout(io) == b"hello\nworld\n"


def test_heredoc_in_for_loop():
    """Heredoc inside a for-loop body fires per iteration."""
    ws = _ws()
    io = _exec(ws, "for x in a b c; do cat <<EOF\nitem=$x\nEOF\ndone")
    assert _stdout(io) == b"item=a\nitem=b\nitem=c\n"


def test_python_heredoc_dash_strips_indentation():
    """python3 <<-PYEOF strips leading tabs so indented code parses."""
    ws = _ws()
    io = _exec(
        ws, "python3 <<-PYEOF\n"
        "\tfor i in range(3):\n"
        "\t    print(i)\n"
        "\tPYEOF")
    assert _stdout(io) == b"0\n1\n2\n"


def test_python_heredoc_quoted_keeps_dollar_literal():
    """python3 << 'PYEOF' — body keeps $-strings literal (no shell expand)."""
    ws = _ws()
    io = _exec(
        ws, "X=shellval\n"
        "python3 << 'PYEOF'\n"
        "x = '$X'\n"
        "print(x)\n"
        "PYEOF")
    assert _stdout(io) == b"$X\n"


def test_python_heredoc_unquoted_expands():
    """python3 << PYEOF (unquoted) — shell vars expand into the body."""
    ws = _ws()
    io = _exec(ws, "X=fromshell\n"
               "python3 << PYEOF\n"
               "print('$X')\n"
               "PYEOF")
    assert _stdout(io) == b"fromshell\n"


def test_relative_path_with_slash():
    """./file.txt and sub/file.txt resolve against cwd."""
    ws = _ws()
    _exec(ws, "echo test > /disk/out.txt")
    io = _exec(ws, "cd /disk && cat ./out.txt")
    assert _stdout(io) == b"test\n"


def test_set_positional_args():
    """set -- a b c makes $@ and $1 $2 $3 available."""
    ws = _ws()
    io = _exec(ws, "set -- a b c; echo $@")
    assert _stdout(io) == b"a b c\n"


def test_set_positional_numbered():
    """set -- x y makes $1 and $2 available."""
    ws = _ws()
    io = _exec(ws, "set -- x y; echo $1 $2")
    assert _stdout(io) == b"x y\n"


# ── glob expansion ──────────────────────────────────────────────────────


def test_echo_glob_star():
    """echo /path/*.ext expands to matching files."""
    ws = _ws()
    io = _exec(ws, "echo /s3/*.csv")
    assert b"report.csv" in _stdout(io)


def test_for_glob():
    """for f in /path/*.ext iterates matching files."""
    ws = _ws()
    io = _exec(ws, "for f in /ram/*.txt; do echo $f; done")
    out = _stdout(io)
    assert b"notes.txt" in out
    assert b"nums.txt" in out


def test_command_sub_word_split_in_for():
    """$(echo a b c) word-splits into 3 separate for values."""
    ws = _ws()
    io = _exec(ws, "for x in $(echo a b c); do echo item:$x; done")
    assert _stdout(io) == b"item:a\nitem:b\nitem:c\n"


# ── pipe exit code ──────────────────────────────────────────────────────


def test_pipe_exit_code_grep_no_match():
    """grep with no match in pipe returns exit code 1."""
    ws = _ws()
    io = _exec(ws, "echo hello | grep nope")
    assert io.exit_code == 1


def test_pipe_exit_code_grep_match():
    """grep with match in pipe returns exit code 0."""
    ws = _ws()
    io = _exec(ws, "echo hello | grep hello")
    assert io.exit_code == 0


# ── timeout ─────────────────────────────────────────────────────────────


def test_timeout_runs_command():
    """timeout N cmd executes the command."""
    ws = _ws()
    io = _exec(ws, "timeout 5 echo hello")
    assert _stdout(io) == b"hello\n"


def test_timeout_preserves_quoted_arg():
    """timeout N cmd 'a  b' keeps the quoted word as one argument."""
    ws = _ws()
    io = _exec(ws, "timeout 5 echo 'a  b'")
    assert _stdout(io) == b"a  b\n"


# ── xargs ───────────────────────────────────────────────────────────────


def test_xargs_basic():
    """echo args | xargs echo passes stdin as arguments."""
    ws = _ws()
    io = _exec(ws, 'echo "a b c" | xargs echo')
    assert _stdout(io) == b"a b c\n"


def test_xargs_keeps_initial_args():
    """xargs cmd initial-args appends stdin words after them (GNU)."""
    ws = _ws()
    io = _exec(ws, "echo c | xargs echo a b")
    assert _stdout(io) == b"a b c\n"


def test_xargs_input_words_stay_literal():
    """Input words are argv tokens, not shell source (GNU execs argv)."""
    ws = _ws()
    io = _exec(ws, "echo '$(echo pwned)' | xargs echo")
    assert _stdout(io) == b"$(echo pwned)\n"


def test_xargs_input_quote_char():
    """A quote character in input does not break the inner command."""
    ws = _ws()
    io = _exec(ws, 'echo "don\'t" | xargs echo')
    assert _stdout(io) == b"don't\n"


def test_variable_command_name():
    """$E hi runs the expanded command name (bash expands, then runs)."""
    ws = _ws()
    io = _exec(ws, "E=echo; $E hi")
    assert _stdout(io) == b"hi\n"


def test_quoted_command_name():
    """A quoted command name expands like any other word."""
    ws = _ws()
    io = _exec(ws, '"echo" hi')
    assert _stdout(io) == b"hi\n"


# ── glob rule: resolved by whoever consumes the word, exactly once ──────


def test_glob_unmatched_keeps_literal():
    """Zero-match glob stays the literal word (bash nullglob off)."""
    ws = _ws()
    io = _exec(ws, "echo /ram/*.nope")
    assert _stdout(io) == b"/ram/*.nope\n"


def test_glob_test_f_resolves():
    """test -f sees the shell-resolved match, not the pattern."""
    ws = _ws()
    io = _exec(ws, "test -f /ram/note* && echo yes")
    assert _stdout(io) == b"yes\n"


def test_glob_function_args_resolve():
    """Function positional args receive matches, not the pattern."""
    ws = _ws()
    io = _exec(ws, "f() { echo $1 $#; }; f /ram/*.txt")
    assert _stdout(io) == b"/ram/notes.txt 3\n"


def test_ln_multi_source_is_error():
    """GNU ln: multiple sources need a directory target."""
    ws = _ws()
    io = _exec(ws, "ln -s /ram/*.txt /ram/lnk")
    assert io.exit_code == 1
    assert b": Not a directory" in io.stderr


def test_ln_single_match_resolves():
    """ln -s links to the glob's match, not the literal pattern."""
    ws = _ws()
    _exec(ws, "ln -s /ram/note* /ram/l2")
    io = _exec(ws, "readlink /ram/l2")
    assert _stdout(io) == b"/ram/notes.txt\n"


def test_unknown_command_not_found():
    """A name nobody registers fails 127 without backend errors."""
    ws = _ws()
    io = _exec(ws, "nosuchcmd /ram/*.txt")
    assert io.exit_code == 127
    assert io.stderr == b"nosuchcmd: command not found\n"


# ── additional fixes ────────────────────────────────────────────────────


def test_for_empty_list():
    """for x in; do ... done with empty list skips body."""
    ws = _ws()
    io = _exec(ws, "for x in; do echo $x; done; echo done")
    assert _stdout(io) == b"done\n"


def test_escaped_quote_in_double():
    """Escaped quote inside double quotes is unescaped."""
    ws = _ws()
    io = _exec(ws, 'echo "hello \\"world\\""')
    assert _stdout(io) == b'hello "world"\n'


def test_special_var_at_split():
    '''"$@" in for loop splits into separate args.'''
    ws = _ws()
    io = _exec(ws, 'f() { for x in "$@"; do echo $x; done; }; f a b c')
    assert _stdout(io) == b"a\nb\nc\n"


def test_background_then_foreground():
    """echo bg &; echo fg — foreground runs after background."""
    ws = _ws()
    io = _exec(ws, "echo bg &; echo fg")
    assert b"fg" in _stdout(io)


def test_heredoc_pipe():
    """python3 << EOF | head -n 1 — pipe after heredoc."""
    ws = _ws()
    cmd = ("python3 << 'PYEOF' | head -n 1\n"
           "for i in range(5):\n"
           "    print(i)\n"
           "PYEOF")
    io = _exec(ws, cmd)
    assert _stdout(io) == b"0\n"


# -- bare filename (CommandSpec PATH classification) -------------------------


def test_cd_and_cat_bare_filename():
    """cat file.txt after cd resolves bare filename via CommandSpec."""
    ws = _ws()
    io = _exec(ws, "cd /disk/sub; cat deep.txt")
    assert _stdout(io) == b"deep content\n"


def test_cd_and_head_bare_filename():
    ws = _ws()
    io = _exec(ws, "cd /ram; head -n 2 notes.txt")
    assert _stdout(io) == b"line1\nline2\n"


def test_cd_and_wc_bare_filename():
    ws = _ws()
    io = _exec(ws, "cd /ram; wc -l notes.txt")
    assert b"3" in _stdout(io)


def test_cd_and_grep_bare_filename():
    ws = _ws()
    io = _exec(ws, "cd /s3; grep POST access.log")
    out = _stdout(io).decode()
    assert out.count("POST") == 2


def test_bare_filename_for_loop_stays_text():
    """for f in file.txt should NOT resolve as a path."""
    ws = _ws()
    io = _exec(ws, "for f in notes.txt; do echo $f; done")
    assert _stdout(io) == b"notes.txt\n"


def test_find_name_not_glob_expanded():
    """find -name '*.txt' should not glob-expand the pattern."""
    ws = _ws()
    io = _exec(ws, "find /s3 -name '*.txt'")
    out = _stdout(io).decode()
    assert "data.txt" in out


def test_subshell_cd_and_cat_bare_filename():
    ws = _ws()
    io = _exec(ws, "(cd /disk/sub; cat deep.txt)")
    assert b"deep content" in _stdout(io)


# ── job table cleanup ──────────────────────────────────────────────────


def test_bg_sleep_kill_and_jobs_cleanup():
    """Background sleep, kill it, jobs shows killed; second empty."""
    ws = _ws()
    # `kill %1` joins the job, so the killed status is already settled
    # when `jobs` lists it. After the listing, pop_completed removes it.
    io = _exec(ws, "sleep 10 & kill %1; jobs")
    out = _stdout(io)
    assert b"killed" in out

    io = _exec(ws, "jobs")
    out = _stdout(io)
    assert out == b""


def test_wait_by_id_reaps_so_jobs_is_empty_after_it():
    """GNU deletes a job waited on by id; `jobs` prints nothing after."""
    ws = _ws()
    io = _exec(ws, "echo hi & wait %1; jobs")
    assert _stdout(io) == b"hi\n"


def test_bare_wait_does_not_replay_a_targeted_waits_output():
    """`wait %1` adopted the output; bare `wait` must not print it again."""
    ws = _ws()
    io = _exec(ws, "echo hi & wait %1; wait")
    assert _stdout(io) == b"hi\n"


def test_job_numbering_restarts_once_the_table_empties():
    """GNU numbers the next job [1] again after the list empties."""
    ws = _ws()
    io = _exec(ws, "echo a & wait %1; echo b & wait %1")
    assert _stdout(io) == b"a\nb\n"


def test_bare_wait_reaps_so_jobs_is_empty_after_it():
    """GNU prints nothing from `jobs` once `wait` has reaped."""
    ws = _ws()
    io = _exec(ws, "echo hi & wait; jobs")
    assert _stdout(io) == b"hi\n"


# ── while loop limit warning ───────────────────────────────────────────


def test_while_loop_warns_on_max_iterations():
    """while loop emits stderr warning when hitting iteration limit."""
    ws = _ws()

    async def _run():
        # _MAX_WHILE = 10000; loop unconditionally to trigger the cap
        io = await ws.execute("while true; do export X=$X.; done")
        return await io.stderr_str()

    err = asyncio.run(_run())
    assert "warning" in err
    assert "terminated after" in err
    assert "10000" in err


def test_while_loop_under_limit_no_warning():
    """while loop under limit produces no stderr warning."""
    ws = _ws()

    async def _run():
        io = await ws.execute("i=0; while [ $i -lt 5 ]; do i=$((i+1)); done")
        return await io.stderr_str()

    err = asyncio.run(_run())
    assert err == ""


# ── unmount ────────────────────────────────────────────────────────────


def test_unmount_removes_mount():
    """unmount drops the mount and rejects subsequent dispatch."""
    ws = _ws()
    assert any(m.prefix == "/s3/" for m in ws.mounts())
    asyncio.run(ws.unmount("/s3"))
    assert not any(m.prefix == "/s3/" for m in ws.mounts())


def test_workspace_rejects_invalid_resource_tuple_shape():
    with pytest.raises(TypeError, match="resource tuples must be"):
        Workspace({"/x": ()})


def test_unmount_closes_resource_when_owned():
    """unmount closes a resource that has open()/close() (best-effort)."""
    closed = []

    class TrackingRAM(RAMResource):

        async def close(self):
            closed.append("yes")

    ws = Workspace({"/x": TrackingRAM()}, mode=MountMode.WRITE)
    asyncio.run(ws.unmount("/x"))
    assert closed == ["yes"]


def test_unmount_rejects_reserved_prefixes():
    """unmount of virtual root / history view / dev / unknown prefix raises."""
    ws = _ws()
    import pytest

    with pytest.raises(ValueError, match="virtual root"):
        asyncio.run(ws.unmount("/"))
    with pytest.raises(ValueError, match="history view"):
        asyncio.run(ws.unmount("/.bash_history"))
    with pytest.raises(ValueError, match="reserved"):
        asyncio.run(ws.unmount("/dev"))
    with pytest.raises(ValueError, match="no mount at prefix"):
        asyncio.run(ws.unmount("/missing"))


def test_unmount_after_close_raises():
    """unmount on a closed workspace raises RuntimeError."""
    ws = _ws()
    asyncio.run(ws.close())
    import pytest

    with pytest.raises(RuntimeError, match="closed"):
        asyncio.run(ws.unmount("/s3"))


# ── cd does not change cwd for nonexistent paths ─────────────────


def test_cd_nonexistent_under_mount_keeps_cwd():
    ws = Workspace(resources={"/": (RAMResource(), MountMode.WRITE)}, )
    before = ws.get_session(ws.default_session_id).cwd
    io = _exec(ws, "cd /missing")
    assert io.exit_code != 0
    assert b"No such file or directory" in io.stderr
    assert ws.get_session(ws.default_session_id).cwd == before


def test_cd_into_mount_root_succeeds():
    ws = Workspace(resources={
        "/": (RAMResource(), MountMode.WRITE),
        "/data/": (RAMResource(), MountMode.WRITE),
    }, )
    io = _exec(ws, "cd /data")
    assert io.exit_code == 0
    assert ws.get_session(ws.default_session_id).cwd == "/data"


# ── ls injects child mounts as virtual subdirectories ─────────────


def _ws_for_ls(mounts: dict[str, RAMResource]) -> Workspace:
    return Workspace(resources={
        p: (r, MountMode.WRITE)
        for p, r in mounts.items()
    }, )


def test_ls_root_shows_child_mount_data():
    ws = _ws_for_ls({"/": RAMResource(), "/data/": RAMResource()})
    io = _exec(ws, "ls /")
    assert io.exit_code == 0
    out = _stdout(io).decode()
    assert "data" in out.split("\n")


def test_ls_classify_child_mount_with_trailing_slash():
    ws = _ws_for_ls({"/": RAMResource(), "/data/": RAMResource()})
    io = _exec(ws, "ls -F /")
    assert io.exit_code == 0
    out = _stdout(io).decode()
    assert "data/" in out.split("\n")


def test_ls_hides_dot_bash_history_by_default_shows_with_a():
    ws = _ws_for_ls({"/": RAMResource()})
    plain = _exec(ws, "ls /")
    assert ".bash_history" not in _stdout(plain).decode().split("\n")
    all_io = _exec(ws, "ls -a /")
    assert ".bash_history" in _stdout(all_io).decode().split("\n")


def test_ls_does_not_duplicate_existing_entry():
    ws = _ws_for_ls({"/": RAMResource(), "/data/": RAMResource()})
    _exec(ws, "mkdir -p /data/sub")
    io = _exec(ws, "ls /data")
    lines = [line for line in _stdout(io).decode().split("\n") if line]
    assert sum(1 for line in lines if line in ("sub", "sub/")) == 1


def test_ls_nested_child_mount():
    ws = _ws_for_ls({
        "/": RAMResource(),
        "/data/": RAMResource(),
        "/data/inner/": RAMResource(),
    })
    io = _exec(ws, "ls /data")
    assert "inner" in _stdout(io).decode().split("\n")


def test_ls_dash_d_does_not_inject_mounts():
    ws = _ws_for_ls({"/": RAMResource(), "/data/": RAMResource()})
    io = _exec(ws, "ls -d /")
    assert "data" not in _stdout(io).decode().split("\n")


def test_man_date_entry():
    ws = _ws()
    io = _exec(ws, "man date")
    assert io.exit_code == 0
    text = _stdout(io).decode()
    assert text.startswith("# date\n")
    assert "## OPTIONS" in text
    assert "RESOURCES" not in text


def test_man_no_entry():
    ws = _ws()
    io = _exec(ws, "man definitely-not-a-real-command")
    assert io.exit_code == 1
    assert io.stderr == b"man: no entry for definitely-not-a-real-command\n"


def test_man_index_lists_commands():
    ws = _ws()
    io = _exec(ws, "man")
    assert io.exit_code == 0
    text = _stdout(io).decode()
    assert text.startswith("# commands\n\n")
    assert "- bc" in text
    assert "# general" not in text
