# ========= 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 json
from functools import partial

import pytest

from mirage.accessor.s3 import S3Accessor
from mirage.commands.builtin.generic_bind.adapter import CommandIO
from mirage.commands.builtin.generic_bind.builders.jq import jq as jq_builder
from mirage.commands.config import CommandOpts
from mirage.core.jq import jq_eval
from mirage.types import MountMode, PathSpec

from .conftest import (collect, eval_one, jq, jq_all, mem_ws, run_raw,
                       write_to_backend)


async def _const_bytes(data, accessor, path, index=None):
    return data


async def _collect_jq(ops, accessor, paths, expr):
    out, _ = await jq_builder(ops, accessor, paths, [expr], CommandOpts())
    if isinstance(out, bytes):
        return out
    if hasattr(out, "__aiter__"):
        return b"".join([chunk async for chunk in out])
    return b"".join(out)


def test_jq_dot_returns_full_object(backend):
    data = {"a": 1, "b": 2}
    write_to_backend(backend, "/tmp/f.json", json.dumps(data).encode())
    result = jq(backend, "/tmp/f.json", ".")
    assert result == data


def test_jq_key_access(backend):
    write_to_backend(backend, "/tmp/f.json", b'{"name": "alice"}')
    assert jq(backend, "/tmp/f.json", ".name") == "alice"


def test_jq_nested_key(backend):
    write_to_backend(backend, "/tmp/f.json", b'{"a": {"b": 42}}')
    assert jq(backend, "/tmp/f.json", ".a.b") == 42


def test_jq_array_iteration(backend):
    write_to_backend(backend, "/tmp/f.json", b'{"items": [1, 2, 3]}')
    result = jq_all(backend, "/tmp/f.json", ".items[]")
    assert result == [1, 2, 3]


def test_jq_invalid_expression_raises(backend):
    write_to_backend(backend, "/tmp/f.json", b'{"a": 1}')
    with pytest.raises(ValueError):
        jq(backend, "/tmp/f.json", "a.b")


def test_jq_pipe(backend):
    write_to_backend(backend, "/tmp/f.json", b'{"items": [1, 2, 3]}')
    result = jq(backend, "/tmp/f.json", ".items | length")
    assert result == 3


def test_jq_select(backend):
    write_to_backend(backend, "/tmp/f.json", b'[{"a": 1}, {"a": 2}, {"a": 3}]')
    result = jq_all(backend, "/tmp/f.json", ".[] | select(.a > 1)")
    assert result == [{"a": 2}, {"a": 3}]


def test_jq_map(backend):
    write_to_backend(backend, "/tmp/f.json", b'[1, 2, 3]')
    result = jq(backend, "/tmp/f.json", "map(. > 1)")
    assert result == [False, True, True]


def test_jq_keys(backend):
    write_to_backend(backend, "/tmp/f.json", b'{"b": 1, "a": 2}')
    result = jq(backend, "/tmp/f.json", "keys")
    assert result == ["a", "b"]


def test_jq_values_passes_through_non_null(backend):
    write_to_backend(backend, "/tmp/f.json", b'{"a": 1, "b": 2}')
    result = jq(backend, "/tmp/f.json", "values")
    assert result == {"a": 1, "b": 2}


def test_jq_values_drops_null(backend):
    write_to_backend(backend, "/tmp/f.json", b'{"a": 1}')
    assert jq_all(backend, "/tmp/f.json", ".missing | values") == []


def test_jq_object_values_via_spread(backend):
    write_to_backend(backend, "/tmp/f.json", b'{"a": 1, "b": 2}')
    result = jq(backend, "/tmp/f.json", "[.[]]")
    assert result == [1, 2]


def test_jq_array_slice(backend):
    write_to_backend(backend, "/tmp/f.json", b'[10, 20, 30, 40, 50]')
    result = jq(backend, "/tmp/f.json", ".[1:3]")
    assert result == [20, 30]


def test_jq_length(backend):
    write_to_backend(backend, "/tmp/f.json", b'[1, 2, 3]')
    result = jq(backend, "/tmp/f.json", "length")
    assert result == 3


def test_jq_string_interpolation():
    result = eval_one({"name": "world"}, '"Hello \\(.name)"')
    assert result == "Hello world"


def test_jq_string_interpolation_nested():
    result = eval_one({"a": 1, "b": 2}, '"\\(.a) + \\(.b)"')
    assert result == "1 + 2"


def test_jq_try_catch_returns_null_when_no_error():
    # In real jq, .missing.x is NOT an error; it returns null.
    # Hence catch is never triggered.
    assert eval_one({}, 'try .missing.x catch "fallback"') is None


def test_jq_try_catch_triggered_on_real_error():
    assert eval_one([1, 2, 3], 'try .name catch "fallback"') == "fallback"


def test_jq_try_no_catch_returns_null():
    assert eval_one({}, "try .missing.x") is None


def test_jq_try_no_catch_swallows_real_error():
    assert jq_eval([1, 2, 3], "try .name") == []


class TestJqMapValues:

    def test_map_values_dict(self):
        result = eval_one({"a": 1, "b": 2}, "map_values(. > 1)")
        assert result == {"a": False, "b": True}

    def test_map_values_list(self):
        result = eval_one([1, 2, 3], "map_values(. > 1)")
        assert result == [False, True, True]

    def test_map_values_dict_arithmetic(self):
        result = eval_one({"x": 10, "y": 20}, 'map_values(type)')
        assert result == {"x": "number", "y": "number"}


class TestJqHas:

    def test_has_present_key(self):
        assert eval_one({"name": "alice"}, 'has("name")') is True

    def test_has_absent_key(self):
        assert eval_one({"name": "alice"}, 'has("age")') is False

    def test_has_array_with_int_key(self):
        # In real jq, has() on arrays takes an integer index.
        assert eval_one([1, 2, 3], "has(1)") is True
        assert eval_one([1, 2, 3], "has(5)") is False


class TestJqContains:

    def test_contains_string_in_string(self):
        assert eval_one("foobar", 'contains("foo")') is True

    def test_contains_string_not_found(self):
        assert eval_one("foobar", 'contains("xyz")') is False

    def test_contains_element_requires_array_arg(self):
        # In real jq, contains(x) requires arg of same type.
        # For arrays, use contains([item]) — see test_contains_subarray.
        with pytest.raises(ValueError):
            eval_one([1, 2, 3], "contains(2)")

    def test_contains_subarray(self):
        assert eval_one([1, 2, 3], "contains([1, 2])") is True

    def test_contains_subarray_not_found(self):
        assert eval_one([1, 2, 3], "contains([4, 5])") is False


class TestJqComparisons:

    def test_eq_true(self):
        assert eval_one({"a": 1}, ".a == 1") is True

    def test_eq_false(self):
        assert eval_one({"a": 1}, ".a == 2") is False

    def test_neq_true(self):
        assert eval_one({"a": 1}, ".a != 2") is True

    def test_neq_false(self):
        assert eval_one({"a": 1}, ".a != 1") is False

    def test_gt(self):
        assert eval_one({"a": 5}, ".a > 3") is True

    def test_gt_false(self):
        assert eval_one({"a": 1}, ".a > 3") is False

    def test_lt(self):
        assert eval_one({"a": 1}, ".a < 3") is True

    def test_lt_false(self):
        assert eval_one({"a": 5}, ".a < 3") is False

    def test_gte(self):
        assert eval_one({"a": 3}, ".a >= 3") is True

    def test_gte_false(self):
        assert eval_one({"a": 2}, ".a >= 3") is False

    def test_lte(self):
        assert eval_one({"a": 3}, ".a <= 3") is True

    def test_lte_false(self):
        assert eval_one({"a": 5}, ".a <= 3") is False

    def test_string_eq(self):
        assert eval_one({"s": "hello"}, '.s == "hello"') is True


class TestJqPipes:

    def test_three_stage_pipe(self, backend):
        write_to_backend(backend, "/tmp/f.json",
                         b'{"data": {"items": [1, 2, 3]}}')
        result = jq(backend, "/tmp/f.json", ".data | .items | length")
        assert result == 3

    def test_pipe_with_iteration_and_select(self, backend):
        write_to_backend(backend, "/tmp/f.json",
                         b'[{"x": 1}, {"x": 5}, {"x": 10}]')
        result = jq_all(backend, "/tmp/f.json", ".[] | select(.x > 3)")
        assert result == [{"x": 5}, {"x": 10}]

    def test_pipe_keys_then_length(self):
        result = eval_one({"a": 1, "b": 2, "c": 3}, "keys | length")
        assert result == 3

    def test_pipe_object_values_via_spread_then_sort(self):
        # Real jq: `values` returns the object as-is (it's the identity for
        # non-null). To get the object's values list, use [.[]].
        result = eval_one({"a": 3, "b": 1, "c": 2}, "[.[]] | sort")
        assert result == [1, 2, 3]

    def test_pipe_map_then_select(self):
        data = [1, 2, 3, 4, 5]
        result = eval_one(data, "map(select(. > 2))")
        assert result == [3, 4, 5]


class TestJqArrayAccess:

    def test_index_access(self):
        result = eval_one({"items": ["a", "b", "c"]}, ".items[0]")
        assert result == "a"

    def test_index_access_last(self):
        result = eval_one({"items": ["a", "b", "c"]}, ".items[2]")
        assert result == "c"

    def test_nested_array_iteration(self, backend):
        write_to_backend(backend, "/tmp/f.json",
                         b'{"users": [{"name": "alice"}, {"name": "bob"}]}')
        result = jq_all(backend, "/tmp/f.json", ".users[].name")
        assert result == ["alice", "bob"]

    def test_slice_from_start(self):
        result = eval_one([1, 2, 3, 4, 5], ".[:2]")
        assert result == [1, 2]

    def test_slice_to_end(self):
        result = eval_one([1, 2, 3, 4, 5], ".[3:]")
        assert result == [4, 5]


class TestJqEdgeCases:

    def test_empty_object(self):
        assert eval_one({}, ".") == {}

    def test_empty_array(self):
        assert eval_one([], ".") == []

    def test_deeply_nested(self):
        data = {"a": {"b": {"c": {"d": 42}}}}
        assert eval_one(data, ".a.b.c.d") == 42

    def test_unicode_content(self, backend):
        data = {"name": "café", "emoji": "hello"}
        write_to_backend(backend, "/tmp/f.json", json.dumps(data).encode())
        result = jq(backend, "/tmp/f.json", ".name")
        assert result == "café"

    def test_json_literal_number(self):
        assert eval_one({}, "42") == 42

    def test_json_literal_string(self):
        assert eval_one({}, '"hello"') == "hello"

    def test_json_literal_array(self):
        assert eval_one({}, "[1, 2, 3]") == [1, 2, 3]

    def test_length_of_string(self):
        assert eval_one("hello", "length") == 5

    def test_length_of_dict(self):
        assert eval_one({"a": 1, "b": 2}, "length") == 2

    def test_keys_of_array(self):
        assert eval_one(["a", "b", "c"], "keys") == [0, 1, 2]

    def test_string_interpolation_with_nested_expr(self):
        data = {"items": [1, 2, 3]}
        result = eval_one(data, '"count: \\(.items | length)"')
        assert result == "count: 3"

    def test_select_drops_non_matching(self):
        assert jq_eval({"a": 1}, "select(.a > 10)") == []

    def test_map_requires_iterable(self):
        # Real jq: map(.) on a non-iterable raises ValueError.
        with pytest.raises(ValueError):
            eval_one(5, "map(.)")

    def test_missing_key_returns_null(self):
        # Real jq: missing keys silently return null (no exception).
        assert eval_one({"a": 1}, ".b") is None

    def test_type_error_dot_on_list(self):
        with pytest.raises((TypeError, KeyError, ValueError)):
            eval_one([1, 2, 3], ".name")

    def test_try_catch_type_error(self):
        result = eval_one([1, 2], 'try .name catch "no key"')
        assert result == "no key"


class TestJqComplex:

    def test_map_select_keys(self):
        data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
        result = jq_eval(data, "map(select(.a > 1)) | .[] | .b")
        assert result == [4]

    def test_iteration_pipe_type(self):
        data = [1, "hello", True, None, [1], {"a": 1}]
        result = jq_eval(data, ".[] | type")
        assert result == [
            "number", "string", "boolean", "null", "array", "object"
        ]

    def test_flatten_then_unique_then_sort(self):
        data = [[3, 1], [2, 1], [3, 2]]
        result = eval_one(data, "flatten | unique | sort")
        assert result == [1, 2, 3]

    def test_map_length(self):
        data = ["hello", "hi", "hey"]
        result = eval_one(data, "map(length)")
        assert result == [5, 2, 3]

    def test_select_with_has(self):
        data = [{"name": "alice"}, {"age": 30}, {"name": "bob"}]
        result = eval_one(data, 'map(select(has("name")))')
        assert result == [{"name": "alice"}, {"name": "bob"}]


class TestJqArithmetic:

    def test_add_numbers(self):
        assert eval_one({"a": 1, "b": 2}, ".a + .b") == 3

    def test_subtract(self):
        assert eval_one({"a": 10, "b": 3}, ".a - .b") == 7

    def test_multiply(self):
        assert eval_one({"a": 4, "b": 5}, ".a * .b") == 20

    def test_divide(self):
        assert eval_one({"a": 10, "b": 2}, ".a / .b") == 5.0

    def test_add_string_concat(self):
        assert eval_one({
            "a": "hello",
            "b": " world"
        }, ".a + .b") == "hello world"

    def test_add_literal_number(self):
        assert eval_one({"a": 1}, ".a + 10") == 11


class TestJqObjectConstruction:

    def test_object_construction(self):
        data = {"name": "alice", "age": 30}
        result = eval_one(data, "{name: .name}")
        assert result == {"name": "alice"}

    def test_object_construction_multiple_keys(self):
        data = {"x": 1, "y": 2, "z": 3}
        result = eval_one(data, "{a: .x, b: .y}")
        assert result == {"a": 1, "b": 2}


class TestJqAlternative:

    def test_alternative_with_null(self):
        assert eval_one({"a": None}, '.a // "default"') == "default"

    def test_alternative_with_value(self):
        assert eval_one({"a": 42}, '.a // "default"') == 42

    def test_alternative_missing_key(self):
        assert eval_one({}, '.missing // "fallback"') == "fallback"


class TestJqIfThenElse:

    def test_if_then_else_true(self):
        result = eval_one({"x": 5}, 'if .x > 3 then "big" else "small" end')
        assert result == "big"

    def test_if_then_else_false(self):
        result = eval_one({"x": 1}, 'if .x > 3 then "big" else "small" end')
        assert result == "small"


class TestJqSortByGroupBy:

    def test_sort_by(self):
        data = [{"a": 3}, {"a": 1}, {"a": 2}]
        result = eval_one(data, "sort_by(.a)")
        assert result == [{"a": 1}, {"a": 2}, {"a": 3}]

    def test_group_by(self):
        data = [{"k": "a", "v": 1}, {"k": "b", "v": 2}, {"k": "a", "v": 3}]
        result = eval_one(data, "group_by(.k)")
        assert result == [
            [{
                "k": "a",
                "v": 1
            }, {
                "k": "a",
                "v": 3
            }],
            [{
                "k": "b",
                "v": 2
            }],
        ]


class TestJqBugFixes:

    def test_double_array_iteration(self):
        data = [[1, 2], [3, 4]]
        result = jq_eval(data, ".[] | .[]")
        assert result == [1, 2, 3, 4]

    def test_double_array_iteration_with_tail(self):
        data = [["ab", "cd"], ["ef"]]
        result = jq_eval(data, ".[] | .[] | length")
        assert result == [2, 2, 2]

    def test_dot_key_on_list_raises(self):
        # Real jq raises ValueError "Cannot index array with string".
        with pytest.raises(ValueError):
            eval_one([1, 2, 3], ".name")

    def test_contains_dict_subset(self):
        assert eval_one({"a": 1, "b": 2}, 'contains({"a": 1})') is True

    def test_contains_dict_missing_key(self):
        assert eval_one({"a": 1}, 'contains({"b": 2})') is False

    def test_contains_dict_wrong_value(self):
        assert eval_one({"a": 1}, 'contains({"a": 2})') is False

    def test_contains_dict_full_match(self):
        assert eval_one({"a": 1}, 'contains({"a": 1})') is True


class TestJqParensAndArrayConstruction:

    def test_parens_unwrap_single(self):
        assert eval_one({"items": [1, 2, 3]}, "(.items | length)") == 3

    def test_parens_in_object_value(self):
        result = eval_one({"items": [1, 2, 3]},
                          "{n: (.items | length), first: .items[0]}")
        assert result == {"n": 3, "first": 1}

    def test_array_construction_collects_spread(self):
        data = {"slides": [{"id": "a"}, {"id": "b"}, {"id": "c"}]}
        assert eval_one(data, "[.slides[].id]") == ["a", "b", "c"]

    def test_array_construction_wraps_single_value(self):
        assert eval_one({"x": 5}, "[.x]") == [5]

    def test_object_with_array_literal_value_does_not_split_inside(self):
        assert eval_one({}, "{x: 1, y: [1,2,3]}") == {"x": 1, "y": [1, 2, 3]}

    def test_object_with_nested_object_value(self):
        result = eval_one({"a": 1, "b": 2}, "{outer: {x: .a, y: .b}}")
        assert result == {"outer": {"x": 1, "y": 2}}

    def test_join_separator(self):
        assert eval_one([1, 2, 3], 'join("-")') == "1-2-3"

    def test_join_empty_separator(self):
        assert eval_one(["foo", "bar"], 'join("")') == "foobar"

    def test_array_construction_then_join(self):
        data = {"slides": [{"id": "a"}, {"id": "b"}]}
        assert eval_one(data, '[.slides[].id] | join(",")') == "a,b"

    def test_full_slides_summary_expression(self):
        data = {
            "title":
            "Deck",
            "slides": [
                {
                    "objectId":
                    "s1",
                    "pageElements": [
                        {
                            "shape": {
                                "shapeType": "TITLE",
                                "text": {
                                    "textElements": [
                                        {
                                            "textRun": {
                                                "content": "Hello "
                                            }
                                        },
                                        {
                                            "textRun": {
                                                "content": "world"
                                            }
                                        },
                                    ]
                                },
                            }
                        },
                        {
                            "image": {
                                "url": "..."
                            }
                        },
                    ],
                },
                {
                    "objectId":
                    "s2",
                    "pageElements": [{
                        "shape": {
                            "shapeType": "TEXT_BOX",
                            "text": {
                                "textElements": [{
                                    "textRun": {
                                        "content": "Bye"
                                    }
                                }]
                            },
                        }
                    }],
                },
            ],
        }
        expr = ('{title: .title, slideCount: (.slides | length), '
                'slides: [.slides[] | {objectId, '
                'elements: [.pageElements[] | select(.shape != null) | '
                '{type: .shape.shapeType, '
                'text: [.shape.text.textElements[].textRun.content] '
                '| join("")}]}]}')
        assert eval_one(data, expr) == {
            "title":
            "Deck",
            "slideCount":
            2,
            "slides": [
                {
                    "objectId": "s1",
                    "elements": [{
                        "type": "TITLE",
                        "text": "Hello world"
                    }],
                },
                {
                    "objectId": "s2",
                    "elements": [{
                        "type": "TEXT_BOX",
                        "text": "Bye"
                    }],
                },
            ],
        }


class TestJqRealLibjqExpressions:
    """Expressions that broke the homegrown parser before the libjq swap.
    Each is a real-world query an agent might run; if any of these fail
    again it means the engine regressed."""

    def _slides_doc(self) -> dict:
        return {
            "title":
            "Deck",
            "slides": [
                {
                    "objectId":
                    "s1",
                    "pageElements": [
                        {
                            "shape": {
                                "shapeType": "TITLE",
                                "text": {
                                    "textElements": [
                                        {
                                            "textRun": {
                                                "content": "Hello "
                                            }
                                        },
                                        {
                                            "paragraphMarker": {}
                                        },
                                        {
                                            "textRun": {
                                                "content": "world"
                                            }
                                        },
                                    ]
                                },
                            }
                        },
                    ],
                },
                {
                    "objectId":
                    "s2",
                    "pageElements": [{
                        "shape": {
                            "shapeType": "TEXT_BOX",
                            "text": {
                                "textElements": [{
                                    "textRun": {
                                        "content": "Bye"
                                    }
                                }]
                            },
                        }
                    }],
                },
            ],
        }

    def test_user_query_with_select_textRun_then_join(self):
        expr = ('[.slides[].pageElements[].shape.text.textElements[] | '
                'select(.textRun != null) | .textRun.content] | join("")')
        assert eval_one(self._slides_doc(), expr) == "Hello worldBye"

    def test_user_query_array_per_slide_then_join(self):
        expr = ('[.slides[] | '
                '[.pageElements[].shape.text.textElements[].textRun.content] '
                '| join("")]')
        # paragraphMarker has no textRun -> jq returns null; join treats null
        # as empty -> "Hello world" for s1, "Bye" for s2.
        assert eval_one(self._slides_doc(), expr) == ["Hello world", "Bye"]

    def test_user_query_full_summary_object(self):
        expr = ('{title: .title, '
                'slideCount: (.slides | length), '
                'slides: [.slides[] | {objectId, '
                'elements: [.pageElements[] | select(.shape != null) | '
                '{type: .shape.shapeType, '
                'text: [.shape.text.textElements[].textRun.content] '
                '| join("")}]}]}')
        assert eval_one(self._slides_doc(), expr) == {
            "title":
            "Deck",
            "slideCount":
            2,
            "slides": [
                {
                    "objectId": "s1",
                    "elements": [{
                        "type": "TITLE",
                        "text": "Hello world"
                    }],
                },
                {
                    "objectId": "s2",
                    "elements": [{
                        "type": "TEXT_BOX",
                        "text": "Bye"
                    }],
                },
            ],
        }

    def test_recurse_then_select(self):
        # `recurse` (..) is a real jq builtin the homegrown didn't have.
        data = {"a": 1, "b": {"c": 2, "d": {"e": 3}}}
        result = eval_one(data, "[.. | numbers] | sort")
        assert result == [1, 2, 3]

    def test_string_split_join_round_trip(self):
        assert eval_one("a-b-c", 'split("-")') == ["a", "b", "c"]
        assert eval_one(["a", "b", "c"], 'join("-")') == "a-b-c"

    def test_alternative_operator_default(self):
        # `// "default"` falls back when LHS is null/false.
        assert eval_one({"a": None}, '.a // "fallback"') == "fallback"
        assert eval_one({"a": "real"}, '.a // "fallback"') == "real"

    def test_to_entries_from_entries(self):
        result = eval_one({"a": 1, "b": 2}, "to_entries")
        assert result == [{"key": "a", "value": 1}, {"key": "b", "value": 2}]
        result2 = eval_one([{"key": "x", "value": 9}], "from_entries")
        assert result2 == {"x": 9}

    def test_string_endswith_startswith(self):
        assert eval_one("foobar", 'startswith("foo")') is True
        assert eval_one("foobar", 'endswith("bar")') is True
        assert eval_one("foobar", 'startswith("xyz")') is False

    def test_test_regex(self):
        assert eval_one("hello world", 'test("w.rld")') is True
        assert eval_one("hello world", 'test("xyz")') is False

    def test_walk_transform(self):
        # `walk` is a real jq builtin the homegrown didn't have.
        data = {"a": "FOO", "b": ["BAR", "BAZ"]}
        result = eval_one(
            data, 'walk(if type == "string" then ascii_downcase else . end)')
        assert result == {"a": "foo", "b": ["bar", "baz"]}

    def test_nested_object_with_paren_value(self):
        data = {"items": [1, 2, 3]}
        result = eval_one(
            data, "{count: (.items | length), max: (.items | max), "
            "sum: ([.items[]] | add)}")
        assert result == {"count": 3, "max": 3, "sum": 6}

    def test_select_chain_inside_array_construction(self):
        data = {
            "users": [
                {
                    "name": "alice",
                    "active": True
                },
                {
                    "name": "bob",
                    "active": False
                },
                {
                    "name": "carol",
                    "active": True
                },
            ]
        }
        result = eval_one(
            data, "[.users[] | select(.active) | .name] | join(\", \")")
        assert result == "alice, carol"

    def test_top_level_select_produces_no_output(self):
        # Real jq: select(false) produces 0 outputs, which the caller
        # serializes as empty bytes.
        assert jq_eval({"x": 5}, "select(.x > 100)") == []


class TestJqMemoryBackend:

    def test_single_path(self):
        ws = mem_ws({"/data.json": b'{"key": "value"}'})
        stdout, _ = run_raw(ws, 'jq .key /data/data.json')
        result = json.loads(collect(stdout))
        assert result == "value"

    def test_multiple_paths(self):
        ws = mem_ws({"/a.json": b'{"x": 1}', "/b.json": b'{"x": 2}'})
        stdout, _ = run_raw(ws, "jq .x /data/a.json /data/b.json")
        raw = collect(stdout).decode()
        assert "1" in raw
        assert "2" in raw

    def test_stdin_bytes(self):
        ws = mem_ws()
        stdout, _ = run_raw(ws, "jq .name", stdin=b'{"name": "stdin-test"}')
        result = json.loads(collect(stdout))
        assert result == "stdin-test"

    def test_dot_returns_full(self):
        ws = mem_ws({"/f.json": b'[1, 2, 3]'})
        stdout, _ = run_raw(ws, "jq . /data/f.json")
        result = json.loads(collect(stdout))
        assert result == [1, 2, 3]

    def test_missing_expression_defaults_dot(self):
        ws = mem_ws({"/f.json": b'{"a": 1}'})
        stdout, io = run_raw(ws, "jq")
        assert io.exit_code == 0
        assert collect(stdout) == b""

    def test_pipe_in_command(self):
        ws = mem_ws({"/f.json": b'{"items": [1, 2, 3]}'})
        stdout, _ = run_raw(ws, "jq '.items | length' /data/f.json")
        result = json.loads(collect(stdout))
        assert result == 3

    def test_select_via_command(self):
        ws = mem_ws({"/f.json": b'[{"a": 1}, {"a": 5}]'})
        stdout, _ = run_raw(ws, "jq '.[] | select(.a > 2)' /data/f.json")
        result = json.loads(collect(stdout))
        assert result == {"a": 5}

    def test_keys_via_command(self):
        ws = mem_ws({"/f.json": b'{"b": 1, "a": 2}'})
        stdout, _ = run_raw(ws, "jq keys /data/f.json")
        result = json.loads(collect(stdout))
        assert result == ["a", "b"]

    def test_spread_array_iteration(self):
        ws = mem_ws({"/f.json": b'[10, 20, 30]'})
        stdout, _ = run_raw(ws, "jq '.[]' /data/f.json")
        lines = collect(stdout).strip().splitlines()
        result = [json.loads(line) for line in lines]
        assert result == [10, 20, 30]

    def test_no_spread_dot_on_array(self):
        ws = mem_ws({"/f.json": b'[10, 20, 30]'})
        stdout, _ = run_raw(ws, "jq '.' /data/f.json")
        result = json.loads(collect(stdout))
        assert result == [10, 20, 30]

    def test_spread_nested_iteration(self):
        ws = mem_ws({"/f.json": b'[{"x": 1}, {"x": 2}]'})
        stdout, _ = run_raw(ws, "jq '.[].x' /data/f.json")
        lines = collect(stdout).strip().splitlines()
        result = [json.loads(line) for line in lines]
        assert result == [1, 2]

    def test_spread_via_stdin(self):
        ws = mem_ws()
        stdout, _ = run_raw(ws, "jq '.[]'", stdin=b'[1, 2, 3]')
        lines = collect(stdout).strip().splitlines()
        result = [json.loads(line) for line in lines]
        assert result == [1, 2, 3]

    def test_no_spread_dot_via_stdin(self):
        ws = mem_ws()
        stdout, _ = run_raw(ws, "jq '.'", stdin=b'[1, 2, 3]')
        result = json.loads(collect(stdout))
        assert result == [1, 2, 3]

    def test_spread_with_raw_flag(self):
        ws = mem_ws({"/f.json": b'["hello", "world"]'})
        stdout, _ = run_raw(ws, "jq -r '.[]' /data/f.json")
        output = collect(stdout).decode()
        assert output == "hello\nworld\n"

    def test_spread_select_multiple(self):
        ws = mem_ws({"/f.json": b'[{"a": 1}, {"a": 5}, {"a": 10}]'})
        stdout, _ = run_raw(ws, "jq -c '.[] | select(.a > 4)' /data/f.json")
        lines = collect(stdout).strip().splitlines()
        result = [json.loads(line) for line in lines]
        assert result == [{"a": 5}, {"a": 10}]


class TestJqDiskBackend:

    def _disk_ws(self, tmp_path, files):
        from mirage.resource.disk.disk import DiskResource
        for name, data in files.items():
            fp = tmp_path / name.lstrip("/")
            fp.parent.mkdir(parents=True, exist_ok=True)
            fp.write_bytes(data)
        disk = DiskResource(str(tmp_path))
        from mirage.workspace import Workspace
        return Workspace(
            {"/disk": (disk, MountMode.WRITE)},
            mode=MountMode.WRITE,
        )

    def test_single_path(self, tmp_path):
        ws = self._disk_ws(tmp_path, {"/f.json": b'{"val": 42}'})
        stdout, _ = run_raw(ws, "jq .val /disk/f.json")
        result = json.loads(collect(stdout))
        assert result == 42

    def test_stdin_bytes(self, tmp_path):
        ws = self._disk_ws(tmp_path, {})
        stdout, _ = run_raw(ws, "jq .x", stdin=b'{"x": "disk-stdin"}')
        result = json.loads(collect(stdout))
        assert result == "disk-stdin"

    def test_nested_key(self, tmp_path):
        ws = self._disk_ws(tmp_path, {"/f.json": b'{"a": {"b": 99}}'})
        stdout, _ = run_raw(ws, "jq .a.b /disk/f.json")
        result = json.loads(collect(stdout))
        assert result == 99

    def test_array_iteration(self, tmp_path):
        ws = self._disk_ws(tmp_path, {"/f.json": b'{"items": [10, 20, 30]}'})
        stdout, _ = run_raw(ws, "jq '.items[]' /disk/f.json")
        lines = collect(stdout).strip().splitlines()
        result = [json.loads(line) for line in lines]
        assert result == [10, 20, 30]

    def test_pipe_length(self, tmp_path):
        ws = self._disk_ws(tmp_path, {"/f.json": b'[1, 2, 3, 4]'})
        stdout, _ = run_raw(ws, "jq '. | length' /disk/f.json")
        result = json.loads(collect(stdout))
        assert result == 4

    def test_file_not_found(self, tmp_path):
        ws = self._disk_ws(tmp_path, {})
        _, io = run_raw(ws, "jq . /disk/missing.json")
        assert io.exit_code != 0

    def test_spread_iteration(self, tmp_path):
        ws = self._disk_ws(tmp_path, {"/f.json": b'[1, 2, 3]'})
        stdout, _ = run_raw(ws, "jq '.[]' /disk/f.json")
        lines = collect(stdout).strip().splitlines()
        result = [json.loads(line) for line in lines]
        assert result == [1, 2, 3]

    def test_no_spread_dot_array(self, tmp_path):
        ws = self._disk_ws(tmp_path, {"/f.json": b'[1, 2, 3]'})
        stdout, _ = run_raw(ws, "jq '.' /disk/f.json")
        result = json.loads(collect(stdout))
        assert result == [1, 2, 3]

    def test_spread_raw(self, tmp_path):
        ws = self._disk_ws(tmp_path, {"/f.json": b'["aa", "bb"]'})
        stdout, _ = run_raw(ws, "jq -r '.[]' /disk/f.json")
        output = collect(stdout).decode()
        assert output == "aa\nbb\n"


class TestJqS3Backend:

    def _s3_ws(self):
        from mirage.resource.s3.s3 import S3Config, S3Resource
        from mirage.workspace import Workspace
        config = S3Config(bucket="test-bucket", region="us-east-1")
        s3 = S3Resource(config)
        return Workspace(
            {"/s3": (s3, MountMode.READ)},
            mode=MountMode.READ,
        )

    def test_stdin_bytes(self):
        ws = self._s3_ws()
        stdout, _ = run_raw(ws, "jq .key", stdin=b'{"key": "s3-stdin"}')
        result = json.loads(collect(stdout))
        assert result == "s3-stdin"

    def test_stdin_dot(self):
        ws = self._s3_ws()
        data = {"a": 1, "b": [2, 3]}
        stdout, _ = run_raw(ws, "jq .", stdin=json.dumps(data).encode())
        result = json.loads(collect(stdout))
        assert result == data

    def test_stdin_length(self):
        ws = self._s3_ws()
        stdout, _ = run_raw(ws, "jq length", stdin=b'[1, 2, 3]')
        result = json.loads(collect(stdout))
        assert result == 3

    def _run_jq_path(self, data, expr):
        ops = CommandIO(readdir=None,
                        read_bytes=partial(_const_bytes, data),
                        read_stream=None,
                        stat=None,
                        is_mounted=lambda a: True,
                        local=False)
        path = PathSpec(resource_path="s3/data.json",
                        virtual="/s3/data.json",
                        directory="/s3",
                        resolved=True)
        return asyncio.run(
            _collect_jq(ops, S3Accessor.__new__(S3Accessor), [path], expr))

    def test_path_with_mock(self):
        out = self._run_jq_path(b'{"name": "from-s3"}', ".name")
        assert json.loads(out) == "from-s3"

    def test_path_array_iteration_mock(self):
        out = self._run_jq_path(b'[{"id": 1}, {"id": 2}]', ".[].id")
        result = [json.loads(line) for line in out.strip().splitlines()]
        assert result == [1, 2]

    def test_path_pipe_length_mock(self):
        out = self._run_jq_path(b'{"items": [1, 2, 3]}', ".items | length")
        assert json.loads(out) == 3

    def test_spread_iteration_mock(self):
        out = self._run_jq_path(b'[10, 20, 30]', ".[]")
        result = [json.loads(line) for line in out.strip().splitlines()]
        assert result == [10, 20, 30]

    def test_no_spread_dot_mock(self):
        out = self._run_jq_path(b'[10, 20, 30]', ".")
        assert json.loads(out) == [10, 20, 30]

    def test_spread_stdin(self):
        ws = self._s3_ws()
        stdout, _ = run_raw(ws, "jq '.[]'", stdin=b'[1, 2, 3]')
        lines = collect(stdout).strip().splitlines()
        result = [json.loads(line) for line in lines]
        assert result == [1, 2, 3]

    def test_no_spread_dot_stdin(self):
        ws = self._s3_ws()
        stdout, _ = run_raw(ws, "jq '.'", stdin=b'[1, 2, 3]')
        result = json.loads(collect(stdout))
        assert result == [1, 2, 3]

    def test_disk_multiple_paths(self, tmp_path):
        from mirage.resource.disk.disk import DiskResource
        from mirage.workspace import Workspace
        (tmp_path / "a.json").write_bytes(b'{"x": 1}')
        (tmp_path / "b.json").write_bytes(b'{"x": 2}')
        disk = DiskResource(str(tmp_path))
        ws = Workspace(
            {"/disk": (disk, MountMode.WRITE)},
            mode=MountMode.WRITE,
        )
        stdout, _ = run_raw(ws, "jq .x /disk/a.json /disk/b.json")
        raw = collect(stdout).decode()
        assert "1" in raw
        assert "2" in raw
