from __future__ import annotations

import copy
import ctypes
import math
import pickle
import sys

from collections.abc import Callable
from datetime import date
from datetime import datetime
from datetime import time
from datetime import timedelta
from datetime import timezone
from datetime import tzinfo
from typing import Any

import pytest

from tests.util import assert_is_ppo
from tests.util import elementary_test
from tomlkit import api
from tomlkit import parse
from tomlkit.container import OutOfOrderTableProxy
from tomlkit.exceptions import NonExistentKey
from tomlkit.items import Array
from tomlkit.items import Bool
from tomlkit.items import Comment
from tomlkit.items import InlineTable
from tomlkit.items import Integer
from tomlkit.items import Item
from tomlkit.items import KeyType
from tomlkit.items import Null
from tomlkit.items import SingleKey as Key
from tomlkit.items import String
from tomlkit.items import StringType
from tomlkit.items import Table
from tomlkit.items import Trivia
from tomlkit.items import item
from tomlkit.parser import Parser


@pytest.fixture()
def tz_pst() -> tzinfo:
    try:
        from datetime import timezone

        return timezone(timedelta(hours=-8), "PST")
    except ImportError:
        from datetime import tzinfo as _tzinfo

        class PST(_tzinfo):
            def utcoffset(self, dt: datetime | None) -> timedelta:
                return timedelta(hours=-8)

            def tzname(self, dt: datetime | None) -> str:
                return "PST"

            def dst(self, dt: datetime | None) -> timedelta:
                return timedelta(0)

        return PST()


@pytest.fixture()
def tz_utc() -> tzinfo:
    try:
        from datetime import timezone

        return timezone.utc
    except ImportError:
        from datetime import tzinfo as _tzinfo

        class UTC(_tzinfo):
            def utcoffset(self, dt: datetime | None) -> timedelta:
                return timedelta(hours=0)

            def tzname(self, dt: datetime | None) -> str:
                return "UTC"

            def dst(self, dt: datetime | None) -> timedelta:
                return timedelta(0)

        return UTC()


def test_item_base_has_no_unwrap() -> None:
    trivia = Trivia(indent="\t", comment_ws=" ", comment="For unit test")
    item = Item(trivia)
    try:
        item.unwrap()
    except NotImplementedError:
        pass
    else:
        raise AssertionError("`items.Item` should not implement `unwrap`")


def test_integer_unwrap() -> None:
    elementary_test(item(666), int)


def test_float_unwrap() -> None:
    elementary_test(item(2.78), float)


@pytest.mark.skipif(
    sys.implementation.name != "cpython", reason="PySequence_Check is CPython-specific"
)
def test_float_is_not_a_sequence() -> None:
    value = parse("a = [1.0, 2.0, 3.0]")["a"][0]
    py_sequence_check = ctypes.pythonapi.PySequence_Check
    py_sequence_check.argtypes = [ctypes.py_object]
    py_sequence_check.restype = ctypes.c_int

    assert not py_sequence_check(value)


def test_false_unwrap() -> None:
    elementary_test(item(False), bool)


def test_true_unwrap() -> None:
    elementary_test(item(True), bool)


def test_datetime_unwrap() -> None:
    dt = datetime.now(tz=timezone.utc)
    elementary_test(item(dt), datetime)


def test_string_unwrap() -> None:
    elementary_test(item("hello"), str)


def test_null_unwrap() -> None:
    n = Null()
    elementary_test(n, type(None))


def test_aot_unwrap() -> None:
    d = item([{"a": "A"}, {"b": "B"}])
    unwrapped = d.unwrap()
    assert_is_ppo(unwrapped, list)
    for du, _ in zip(unwrapped, d):  # noqa: B905
        assert_is_ppo(du, dict)
        for ku in du:
            vu = du[ku]
            assert_is_ppo(ku, str)
            assert_is_ppo(vu, str)


def test_aot_set_item() -> None:
    d = item(["A", {"b": "B"}, ["c", "D"]])
    d[0] = "C"
    assert isinstance(d[0], String)
    assert d[0] == "C"
    d[1]["b"] = "D"
    assert isinstance(d[1], InlineTable)
    assert d[1]["b"] == "D"
    d[0] = ["c", "C"]
    assert isinstance(d[0], Array)
    assert d[0][1] == "C"


def test_time_unwrap() -> None:
    t = time(3, 8, 14)
    elementary_test(item(t), time)


def test_date_unwrap() -> None:
    d = date.today()
    elementary_test(item(d), date)


def test_array_unwrap() -> None:
    trivia = Trivia(indent="\t", comment_ws=" ", comment="For unit test")
    i = item(666)
    f = item(2.78)
    b = item(False)
    a = Array([i, f, b], trivia)
    a_unwrapped = a.unwrap()
    assert_is_ppo(a_unwrapped, list)
    assert_is_ppo(a_unwrapped[0], int)
    assert_is_ppo(a_unwrapped[1], float)
    assert_is_ppo(a_unwrapped[2], bool)


def test_abstract_table_unwrap() -> None:
    table = item({"foo": "bar"})
    super_table = item({"table": table, "baz": "borg"})

    table_unwrapped = super_table.unwrap()
    sub_table = table_unwrapped["table"]
    assert_is_ppo(table_unwrapped, dict)
    assert_is_ppo(sub_table, dict)
    for ku in sub_table:
        vu = sub_table[ku]
        assert_is_ppo(ku, str)
        assert_is_ppo(vu, str)


def test_key_comparison() -> None:
    k = Key("foo")

    assert k == Key("foo")
    assert k == "foo"
    assert k != "bar"
    assert k != 5


def test_items_can_be_appended_to_and_removed_from_a_table() -> None:
    string = """[table]
"""

    parser = Parser(string)
    _, table = parser._parse_table()

    assert isinstance(table, Table)
    assert table.as_string() == ""

    table.append(Key("foo"), String(StringType.SLB, "bar", "bar", Trivia(trail="\n")))

    assert table.as_string() == 'foo = "bar"\n'

    table.append(
        Key("baz"),
        Integer(34, Trivia(comment_ws="   ", comment="# Integer", trail=""), "34"),
    )

    assert table.as_string() == 'foo = "bar"\nbaz = 34   # Integer'

    table.remove(Key("baz"))

    assert table.as_string() == 'foo = "bar"\n'

    table.remove(Key("foo"))

    assert table.as_string() == ""

    with pytest.raises(NonExistentKey):
        table.remove(Key("foo"))


def test_items_can_be_appended_to_and_removed_from_an_inline_table() -> None:
    string = """table = {}
"""

    parser = Parser(string)
    result = parser._parse_item()
    assert result is not None
    _, table = result

    assert isinstance(table, InlineTable)
    assert table.as_string() == "{}"

    table.append(Key("foo"), String(StringType.SLB, "bar", "bar", Trivia(trail="")))

    assert table.as_string() == '{foo = "bar"}'

    table.append(Key("baz"), Integer(34, Trivia(trail=""), "34"))

    assert table.as_string() == '{foo = "bar", baz = 34}'

    table.remove(Key("baz"))

    assert table.as_string() == '{foo = "bar"}'

    table.remove(Key("foo"))

    assert table.as_string() == "{}"

    with pytest.raises(NonExistentKey):
        table.remove(Key("foo"))


def test_inf_and_nan_are_supported(example: Callable[[str], str]) -> None:
    content = example("0.5.0")
    doc = parse(content)

    assert doc["sf1"] == float("inf")
    assert doc["sf2"] == float("inf")
    assert doc["sf3"] == float("-inf")

    assert math.isnan(doc["sf4"])
    assert math.isnan(doc["sf5"])
    assert math.isnan(doc["sf6"])


def test_hex_octal_and_bin_integers_are_supported(
    example: Callable[[str], str],
) -> None:
    content = example("0.5.0")
    doc = parse(content)

    assert doc["hex1"] == 3735928559
    assert doc["hex2"] == 3735928559
    assert doc["hex3"] == 3735928559

    assert doc["oct1"] == 342391
    assert doc["oct2"] == 493

    assert doc["bin1"] == 214


def test_key_automatically_sets_proper_string_type_if_not_bare() -> None:
    key = Key("foo.bar")

    assert key.t == KeyType.Basic

    key = Key("")
    assert key.t == KeyType.Basic


def test_array_behaves_like_a_list() -> None:
    a = item([1, 2])

    assert a == [1, 2]
    assert a.as_string() == "[1, 2]"

    a += [3, 4]
    assert a == [1, 2, 3, 4]
    assert a.as_string() == "[1, 2, 3, 4]"

    del a[2]
    assert a == [1, 2, 4]
    assert a.as_string() == "[1, 2, 4]"

    assert a.pop() == 4
    assert a == [1, 2]
    assert a.as_string() == "[1, 2]"

    a[0] = 4
    assert a == [4, 2]
    a[-2] = 0
    assert a == [0, 2]

    del a[-2]
    assert a == [2]
    assert a.as_string() == "[2]"

    a.clear()
    assert a == []
    assert a.as_string() == "[]"

    content = """a = [1, 2,] # Comment
"""
    doc = parse(content)
    assert str(doc["a"]) == "[1, 2]"

    assert doc["a"] == [1, 2]
    doc["a"] += [3, 4]
    assert doc["a"] == [1, 2, 3, 4]
    assert (
        doc.as_string()
        == """a = [1, 2, 3, 4] # Comment
"""
    )


def test_array_multiline() -> None:
    t = item([1, 2, 3, 4, 5, 6, 7, 8])
    t.multiline(True)

    expected = """\
[
    1,
    2,
    3,
    4,
    5,
    6,
    7,
    8,
]"""

    assert expected == t.as_string()

    t2: Any = item([])
    t2.multiline(True)

    assert t2.as_string() == "[]"


def test_array_multiline_modify() -> None:
    doc = parse(
        """\
a = [
    "abc"
]"""
    )
    doc["a"].append("def")
    expected = """\
a = [
    "abc",
    "def"
]"""
    assert expected == doc.as_string()
    doc["a"].insert(1, "ghi")
    expected = """\
a = [
    "abc",
    "ghi",
    "def"
]"""
    assert expected == doc.as_string()


def test_append_to_empty_array() -> None:
    doc = parse("x = [ ]")
    doc["x"].append("a")
    assert doc.as_string() == 'x = ["a" ]'
    doc = parse("x = [\n]")
    doc["x"].append("a")
    assert doc.as_string() == 'x = [\n    "a"\n]'


def test_modify_array_with_comment() -> None:
    doc = parse("x = [ # comment\n]")
    doc["x"].append("a")
    assert doc.as_string() == 'x = [ # comment\n    "a"\n]'
    doc = parse(
        """\
x = [
    "a",
    # comment
    "b"
]"""
    )
    doc["x"].insert(1, "c")
    expected = """\
x = [
    "a",
    # comment
    "c",
    "b"
]"""
    assert doc.as_string() == expected
    doc = parse(
        """\
x = [
    1  # comment
]"""
    )
    doc["x"].append(2)
    assert (
        doc.as_string()
        == """\
x = [
    1,  # comment
    2
]"""
    )
    doc["x"].pop(0)
    assert doc.as_string() == "x = [\n    2\n]"


def test_append_to_multiline_array_with_comment() -> None:
    doc = parse(
        """\
x = [
    # Here is a comment
    1,
    2
]
"""
    )
    doc["x"].multiline(True).append(3)
    assert (
        doc.as_string()
        == """\
x = [
    # Here is a comment
    1,
    2,
    3,
]
"""
    )
    assert doc["x"].pop() == 3
    assert (
        doc.as_string()
        == """\
x = [
    # Here is a comment
    1,
    2,
]
"""
    )


def test_append_dict_to_array() -> None:
    doc = parse("x = []")
    doc["x"].append({"name": "John Doe", "email": "john@doe.com"})
    expected = 'x = [{name = "John Doe",email = "john@doe.com"}]'
    assert doc.as_string() == expected
    # Make sure the produced string is valid
    assert parse(doc.as_string()) == doc


def test_dicts_are_converted_to_tables() -> None:
    t = item({"foo": {"bar": "baz"}})

    assert (
        t.as_string()
        == """[foo]
bar = "baz"
"""
    )


def test_array_add_line() -> None:
    t = api.array()
    t.add_line(1, 2, 3, comment="Line 1")
    t.add_line(4, 5, 6, comment="Line 2")
    t.add_line(7, api.ws(","), api.ws(" "), 8, add_comma=False)
    t.add_line(comment="Line 4")
    t.add_line(indent="")
    assert len(t) == 8
    assert list(t) == [1, 2, 3, 4, 5, 6, 7, 8]
    assert (
        t.as_string()
        == """[
    1, 2, 3, # Line 1
    4, 5, 6, # Line 2
    7, 8,
    # Line 4
]"""
    )


def test_array_add_line_multiline_comment_is_rejected() -> None:
    t = api.array()
    with pytest.raises(ValueError, match="line breaks"):
        t.add_line(1, 2, 3, comment="first line\nsecond line")
    assert t.as_string() == "[]"


def test_array_add_line_invalid_value() -> None:
    t = api.array()
    with pytest.raises(ValueError, match="is not allowed"):
        t.add_line(1, api.ws(" "))
    with pytest.raises(ValueError, match="is not allowed"):
        t.add_line(Comment(Trivia("  ", comment="test")))
    assert len(t) == 0


def test_dicts_are_converted_to_tables_and_keep_order() -> None:
    t = item(
        {
            "foo": {
                "bar": "baz",
                "abc": 123,
                "baz": [{"c": 3, "b": 2, "a": 1}],
            },
        }
    )

    assert (
        t.as_string()
        == """[foo]
bar = "baz"
abc = 123

[[foo.baz]]
c = 3
b = 2
a = 1
"""
    )


def test_dicts_are_converted_to_tables_and_are_sorted_if_requested() -> None:
    t = item(
        {
            "foo": {
                "bar": "baz",
                "abc": 123,
                "baz": [{"c": 3, "b": 2, "a": 1}],
            },
        },
        _sort_keys=True,
    )

    assert (
        t.as_string()
        == """[foo]
abc = 123
bar = "baz"

[[foo.baz]]
a = 1
b = 2
c = 3
"""
    )


def test_dicts_with_sub_dicts_are_properly_converted() -> None:
    t = item(
        {"foo": {"bar": {"string": "baz"}, "int": 34, "float": 3.14}}, _sort_keys=True
    )

    assert (
        t.as_string()
        == """[foo]
float = 3.14
int = 34

[foo.bar]
string = "baz"
"""
    )


def test_item_array_of_dicts_converted_to_aot() -> None:
    a = item({"foo": [{"bar": "baz"}]})

    assert (
        a.as_string()
        == """[[foo]]
bar = "baz"
"""
    )


def test_add_float_to_int() -> None:
    content = "[table]\nmy_int = 2043"
    doc = parse(content)
    doc["table"]["my_int"] += 5.0
    assert doc["table"]["my_int"] == 2048.0
    assert isinstance(doc["table"]["my_int"], float)


def test_sub_float_from_int() -> None:
    content = "[table]\nmy_int = 2048"
    doc = parse(content)
    doc["table"]["my_int"] -= 5.0
    assert doc["table"]["my_int"] == 2043.0
    assert isinstance(doc["table"]["my_int"], float)


def test_sub_int_from_float() -> None:
    content = "[table]\nmy_int = 2048.0"
    doc = parse(content)
    doc["table"]["my_int"] -= 5
    assert doc["table"]["my_int"] == 2043.0


def test_add_sum_int_with_float() -> None:
    content = "[table]\nmy_int = 2048.3"
    doc = parse(content)
    doc["table"]["my_int"] += 5
    assert doc["table"]["my_int"] == 2053.3


def test_integers_behave_like_ints() -> None:
    i = item(34)

    assert i == 34
    assert i.as_string() == "34"

    i += 1
    assert i == 35
    assert i.as_string() == "35"

    i -= 2
    assert i == 33
    assert i.as_string() == "33"

    f = i / 2
    assert f == 16.5
    assert f.as_string() == "16.5"

    doc = parse("int = +34")
    doc["int"] += 1

    assert doc.as_string() == "int = +35"


def test_floats_behave_like_floats() -> None:
    i = item(34.12)

    assert i == 34.12
    assert i.as_string() == "34.12"

    i += 1
    assert i == 35.12
    assert i.as_string() == "35.12"

    i -= 2
    assert i == 33.12
    assert i.as_string() == "33.12"

    doc = parse("float = +34.12")
    doc["float"] += 1

    assert doc.as_string() == "float = +35.12"


def test_datetimes_behave_like_datetimes(tz_utc: tzinfo, tz_pst: tzinfo) -> None:
    i = item(datetime(2018, 7, 22, 12, 34, 56))

    assert i == datetime(2018, 7, 22, 12, 34, 56)
    assert i.as_string() == "2018-07-22T12:34:56"

    i += timedelta(days=1)
    assert i == datetime(2018, 7, 23, 12, 34, 56)
    assert i.as_string() == "2018-07-23T12:34:56"

    i -= timedelta(days=2)
    assert i == datetime(2018, 7, 21, 12, 34, 56)
    assert i.as_string() == "2018-07-21T12:34:56"

    i = i.replace(year=2019, tzinfo=tz_utc)
    assert i == datetime(2019, 7, 21, 12, 34, 56, tzinfo=tz_utc)
    assert i.as_string() == "2019-07-21T12:34:56+00:00"

    i = i.astimezone(tz_pst)
    assert i == datetime(2019, 7, 21, 4, 34, 56, tzinfo=tz_pst)
    assert i.as_string() == "2019-07-21T04:34:56-08:00"

    doc = parse("dt = 2018-07-22T12:34:56-05:00")
    doc["dt"] += timedelta(days=1)

    assert doc.as_string() == "dt = 2018-07-23T12:34:56-05:00"


def test_dates_behave_like_dates() -> None:
    i = item(date(2018, 7, 22))

    assert i == date(2018, 7, 22)
    assert i.as_string() == "2018-07-22"

    i += timedelta(days=1)
    assert i == date(2018, 7, 23)
    assert i.as_string() == "2018-07-23"

    i -= timedelta(days=2)
    assert i == date(2018, 7, 21)
    assert i.as_string() == "2018-07-21"

    i = i.replace(year=2019)
    assert i == date(2019, 7, 21)
    assert i.as_string() == "2019-07-21"

    doc = parse("dt = 2018-07-22 # Comment")
    doc["dt"] += timedelta(days=1)

    assert doc.as_string() == "dt = 2018-07-23 # Comment"


def test_parse_datetime_followed_by_space() -> None:
    # issue #260
    doc = parse("dt = 2018-07-22 ")
    assert doc["dt"] == date(2018, 7, 22)
    assert doc.as_string() == "dt = 2018-07-22 "

    doc = parse("dt = 2013-01-24 13:48:01.123456 ")
    assert doc["dt"] == datetime(2013, 1, 24, 13, 48, 1, 123456)
    assert doc.as_string() == "dt = 2013-01-24 13:48:01.123456 "


def test_times_behave_like_times() -> None:
    i = item(time(12, 34, 56))

    assert i == time(12, 34, 56)
    assert i.as_string() == "12:34:56"

    i = i.replace(hour=13)
    assert i == time(13, 34, 56)
    assert i.as_string() == "13:34:56"


def test_strings_behave_like_strs() -> None:
    i = item("foo")

    assert i == "foo"
    assert i.as_string() == '"foo"'

    i += " bar"
    assert i == "foo bar"
    assert i.as_string() == '"foo bar"'

    i += " é"
    assert i == "foo bar é"
    assert i.as_string() == '"foo bar é"'

    doc = parse('str = "foo" # Comment')
    doc["str"] += " bar"

    assert doc.as_string() == 'str = "foo bar" # Comment'


def test_string_add_preserve_escapes() -> None:
    i: Any = api.value('"foo\\"bar"')
    i += " baz"

    assert i == 'foo"bar baz'
    assert i.as_string() == '"foo\\"bar baz"'


def test_tables_behave_like_dicts() -> None:
    t = item({"foo": "bar"})

    assert (
        t.as_string()
        == """foo = "bar"
"""
    )

    t.update({"bar": "baz"})

    assert (
        t.as_string()
        == """foo = "bar"
bar = "baz"
"""
    )

    t.update({"bar": "boom"})

    assert (
        t.as_string()
        == """foo = "bar"
bar = "boom"
"""
    )

    assert t.get("bar") == "boom"
    assert t.setdefault("foobar", "fuzz") == "fuzz"
    assert (
        t.as_string()
        == """foo = "bar"
bar = "boom"
foobar = "fuzz"
"""
    )


def test_items_are_pickable() -> None:
    n: Item = item(12)

    s = pickle.dumps(n)
    assert pickle.loads(s).as_string() == "12"

    n = item(12.34)

    s = pickle.dumps(n)
    assert pickle.loads(s).as_string() == "12.34"

    n = item(True)

    s = pickle.dumps(n)
    assert pickle.loads(s).as_string() == "true"

    n = item(datetime(2018, 10, 11, 12, 34, 56, 123456))

    s = pickle.dumps(n)
    assert pickle.loads(s).as_string() == "2018-10-11T12:34:56.123456"

    n = item(date(2018, 10, 11))

    s = pickle.dumps(n)
    assert pickle.loads(s).as_string() == "2018-10-11"

    n = item(time(12, 34, 56, 123456))

    s = pickle.dumps(n)
    assert pickle.loads(s).as_string() == "12:34:56.123456"

    n = item([1, 2, 3])

    s = pickle.dumps(n)
    assert pickle.loads(s).as_string() == "[1, 2, 3]"

    n = item({"foo": "bar"})

    s = pickle.dumps(n)
    assert pickle.loads(s).as_string() == 'foo = "bar"\n'

    n = api.inline_table()
    n["foo"] = "bar"

    s = pickle.dumps(n)
    assert pickle.loads(s).as_string() == '{foo = "bar"}'

    n = item("foo")

    s = pickle.dumps(n)
    assert pickle.loads(s).as_string() == '"foo"'

    n = item([{"foo": "bar"}])

    s = pickle.dumps(n)
    assert pickle.loads(s).as_string() == 'foo = "bar"\n'


def test_trim_comments_when_building_inline_table() -> None:
    table = api.inline_table()
    row = parse('foo = "bar"  # Comment')
    table.update(row)
    assert table.as_string() == '{foo = "bar"}'
    value = item("foobaz")
    value.comment("Another comment")
    table.append("baz", value)
    assert "# Another comment" not in table.as_string()
    assert table.as_string() == '{foo = "bar", baz = "foobaz"}'


def test_comment_method_multiline_comment_is_rejected() -> None:
    doc = api.document()
    doc["x"] = 1
    with pytest.raises(ValueError, match="line breaks"):
        doc["x"].comment("first line\nsecond line")
    assert doc.as_string() == "x = 1\n"


def test_comment_method_keeps_existing_hash_prefix() -> None:
    doc = api.document()
    doc["x"] = 1
    doc["x"].comment("# already a comment")
    assert doc.as_string() == "x = 1 # already a comment\n"


def test_append_table_to_inline_table_raises() -> None:
    table = api.table()
    table.append("a", 1)
    inline_table = api.inline_table()

    with pytest.raises(ValueError, match="cannot contain a table"):
        inline_table.append("table", table)
    with pytest.raises(ValueError, match="cannot contain a table"):
        inline_table["table"] = table


def test_deleting_inline_table_element_does_not_leave_trailing_separator() -> None:
    table = api.inline_table()
    table["foo"] = "bar"
    table["baz"] = "boom"

    assert table.as_string() == '{foo = "bar", baz = "boom"}'

    del table["baz"]

    assert table.as_string() == '{foo = "bar"}'

    table = api.inline_table()
    table["foo"] = "bar"

    del table["foo"]

    table["baz"] = "boom"

    assert table.as_string() == '{baz = "boom"}'


def test_deleting_inline_table_element_does_not_leave_trailing_separator2() -> None:
    doc = parse('a = {foo = "bar", baz = "boom"}')
    table = doc["a"]
    assert table.as_string() == '{foo = "bar", baz = "boom"}'

    del table["baz"]
    assert table.as_string() == '{foo = "bar" }'

    del table["foo"]
    assert table.as_string() == "{ }"

    table["baz"] = "boom"

    assert table.as_string() == '{ baz = "boom"}'


def test_appending_to_parsed_inline_table_preserves_separator() -> None:
    doc = parse("a = { foo = 1, bar = 2 }\n")
    doc["a"]["baz"] = 3

    assert doc.as_string() == "a = { foo = 1, bar = 2, baz = 3}\n"
    parse(doc.as_string())


def test_append_key_after_inline_table_trailing_comment() -> None:
    doc = parse("tbl = {\n    p = { k = 1 },\n    q = { k = 2 }  # comment\n}\n")
    doc["tbl"]["added"] = 3

    # The separator for the new key must be placed after the previous value,
    # not after the trailing comment -- otherwise the comma becomes part of the
    # comment and the result no longer round-trips. See #512.
    rendered = doc.as_string()
    assert "# comment," not in rendered
    assert parse(rendered) == {"tbl": {"p": {"k": 1}, "q": {"k": 2}, "added": 3}}
    assert parse(rendered).as_string() == rendered


def test_deleting_inline_table_middle_element_does_not_leave_double_separator() -> None:
    doc = parse("a = {foo = 1, bar = 2, baz = 3}\n")
    del doc["a"]["bar"]

    # The dangling separator left by the removed key must not produce an
    # invalid ``, ,`` sequence; the result must round-trip.
    rendered = doc.as_string()
    assert ", ," not in rendered
    assert ",," not in rendered
    assert parse(rendered) == {"a": {"foo": 1, "baz": 3}}
    assert parse(rendered).as_string() == rendered


def test_inline_table_render_after_edits() -> None:
    # InlineTable.as_string() precomputes the last-key / last-Null indices in a
    # single pass instead of rescanning the tail on every separator comma.
    # Deleting keys (which leaves Null placeholders and dangling separators) is
    # the path that exercises those lookups, so pin the exact rendered output.
    def edited(src: str, *dels: str) -> str:
        doc = parse(src)
        for key in dels:
            del doc["t"][key]
        out = doc.as_string()
        # whatever the spacing, the result must be valid and round-trip
        assert ",," not in out and ", ," not in out
        assert parse(out).as_string() == out
        return out

    assert edited("t = {a = 1, b = 2, c = 3}", "c") == "t = {a = 1, b = 2 }"
    assert edited("t = {a = 1, b = 2, c = 3}", "b") == "t = {a = 1,  c = 3}"
    assert edited("t = {a = 1, b = 2}", "b") == "t = {a = 1 }"
    assert edited("t = {a = 1, b = 2}", "a") == "t = { b = 2}"
    assert edited("t = {a = 1, b = 2, c = 3}", "b", "c") == "t = {a = 1  }"


def test_adding_to_dotted_key_inside_inline_table() -> None:
    doc = parse("a = {b.c = 1}\n")
    doc["a"]["b"]["d"] = 2

    # The added key must stay attached to the ``b.`` prefix and be separated
    # from the existing pair; the result must round-trip.
    rendered = doc.as_string()
    assert rendered == "a = {b.c = 1, b.d = 2}\n"
    assert parse(rendered) == {"a": {"b": {"c": 1, "d": 2}}}
    assert parse(rendered).as_string() == rendered


def test_adding_to_nested_dotted_key_inside_inline_table() -> None:
    doc = parse("a = {b.c.e = 1}\n")
    doc["a"]["b"]["c"]["g"] = 2

    rendered = doc.as_string()
    assert rendered == "a = {b.c.e = 1, b.c.g = 2}\n"
    assert parse(rendered) == {"a": {"b": {"c": {"e": 1, "g": 2}}}}
    assert parse(rendered).as_string() == rendered


def test_appending_to_comma_first_array_does_not_double_separator() -> None:
    doc = parse(
        """\
a = [
      1 # one
     ,2
    ]
"""
    )
    doc["a"].append(99)

    # The comma separating ``2`` from ``99`` is carried by the new item's
    # comma-first indent; adding a trailing comma to ``2`` as well would
    # produce an invalid ``2,\\n,99`` sequence.
    rendered = doc.as_string()
    assert parse(rendered)["a"] == [1, 2, 99]
    assert parse(rendered).as_string() == rendered


def test_inserting_into_comma_first_array_does_not_double_separator() -> None:
    doc = parse(
        """\
a = [
      1 # one
     ,2
    ]
"""
    )
    doc["a"].insert(1, 99)

    rendered = doc.as_string()
    assert parse(rendered)["a"] == [1, 99, 2]
    assert parse(rendered).as_string() == rendered

    # Inserting at the front copies the first item's comma-less indent and
    # must keep its usual trailing comma.
    doc["a"].insert(0, 0)
    rendered = doc.as_string()
    assert parse(rendered)["a"] == [0, 1, 99, 2]
    assert parse(rendered).as_string() == rendered


def test_booleans_comparison() -> None:
    boolean = Bool(True, Trivia())

    assert boolean

    boolean = Bool(False, Trivia())

    assert not boolean

    s = """[foo]
value = false
"""

    content = parse(s)
    assert content["foo"]["value"] is False
    assert isinstance(content["foo"].item("value"), Bool)

    assert {"foo": {"value": False}} == content
    assert {"value": False} == content["foo"]


def test_table_copy() -> None:
    table = item({"foo": "bar"})
    table_copy = table.copy()
    assert isinstance(table_copy, Table)
    table["foo"] = "baz"
    assert table_copy["foo"] == "bar"
    assert table_copy.as_string() == 'foo = "bar"\n'


def test_copy_copy() -> None:
    result = parse(
        """
    [tool.poetry]
    classifiers = [
    # comment
        "a",
        "b",
    ]
    """
    )
    classifiers = result["tool"]["poetry"]["classifiers"]
    new = copy.copy(classifiers)
    assert new == classifiers


@pytest.mark.parametrize(
    "key_str,escaped",
    [("\\", '"\\\\"'), ('"', '"\\""'), ("\t", '"\\t"'), ("\x10", '"\\u0010"')],
)
def test_escape_key(key_str: str, escaped: str) -> None:
    assert api.key(key_str).as_string() == escaped


def test_custom_encoders() -> None:
    import decimal

    @api.register_encoder
    def encode_decimal(obj: Any) -> Item:
        if isinstance(obj, decimal.Decimal):
            return api.float_(str(obj))
        raise TypeError

    assert api.item(decimal.Decimal("1.23")).as_string() == "1.23"

    with pytest.raises(TypeError):
        api.item(object())

    assert api.dumps({"foo": decimal.Decimal("1.23")}) == "foo = 1.23\n"
    api.unregister_encoder(encode_decimal)


def test_custom_encoders_with_parent_and_sort_keys() -> None:
    """Test that custom encoders can receive _parent and _sort_keys parameters."""
    import decimal

    parent_captured = None
    sort_keys_captured = None

    @api.register_encoder
    def encode_decimal_with_context(
        obj: Any, _parent: Item | None = None, _sort_keys: bool = False
    ) -> Item:
        nonlocal parent_captured, sort_keys_captured
        if isinstance(obj, decimal.Decimal):
            parent_captured = _parent
            sort_keys_captured = _sort_keys
            return api.float_(str(obj))
        raise TypeError

    # Test with default parameters
    result = api.item(decimal.Decimal("1.23"))
    assert result.as_string() == "1.23"
    assert parent_captured is None
    assert sort_keys_captured is False

    # Test with custom parent and sort_keys
    parent_captured = None
    sort_keys_captured = None
    table = api.table()
    result = item(decimal.Decimal("4.56"), _parent=table, _sort_keys=True)
    assert result.as_string() == "4.56"
    assert parent_captured is table
    assert sort_keys_captured is True

    api.unregister_encoder(encode_decimal_with_context)


def test_custom_encoders_backward_compatibility() -> None:
    """Test that old-style custom encoders still work without modification."""
    import decimal

    @api.register_encoder
    def encode_decimal_old_style(obj: Any) -> Item:
        # Old style encoder - only accepts obj parameter
        if isinstance(obj, decimal.Decimal):
            return api.float_(str(obj))
        raise TypeError

    # Should work exactly as before
    result = api.item(decimal.Decimal("2.34"))
    assert result.as_string() == "2.34"

    # Should work when called from item() with extra parameters
    table = api.table()
    result = item(decimal.Decimal("5.67"), _parent=table, _sort_keys=True)
    assert result.as_string() == "5.67"

    api.unregister_encoder(encode_decimal_old_style)


def test_custom_encoders_with_kwargs() -> None:
    """Test that custom encoders can use **kwargs to accept additional parameters."""
    import decimal

    kwargs_captured = None

    @api.register_encoder
    def encode_decimal_with_kwargs(obj: Any, **kwargs: Any) -> Item:
        nonlocal kwargs_captured
        if isinstance(obj, decimal.Decimal):
            kwargs_captured = kwargs
            return api.float_(str(obj))
        raise TypeError

    # Test with parent and sort_keys passed as kwargs
    table = api.table()
    result = item(decimal.Decimal("7.89"), _parent=table, _sort_keys=True)
    assert result.as_string() == "7.89"
    assert kwargs_captured == {"_parent": table, "_sort_keys": True}

    api.unregister_encoder(encode_decimal_with_kwargs)


def test_custom_encoders_for_complex_objects() -> None:
    """Test custom encoders that need to encode nested structures."""

    class CustomDict:
        def __init__(self, data: dict[str, Any]) -> None:
            self.data = data

    @api.register_encoder
    def encode_custom_dict(
        obj: Any, _parent: Item | None = None, _sort_keys: bool = False
    ) -> Item:
        if isinstance(obj, CustomDict):
            # Create a table and use item() to convert nested values
            table = api.table()
            for key, value in obj.data.items():
                # Pass along _parent and _sort_keys when converting nested values
                table[key] = item(value, _parent=table, _sort_keys=_sort_keys)
            return table
        raise TypeError

    # Test with nested structure
    custom_obj = CustomDict({"a": 1, "b": {"c": 2, "d": 3}})
    result = item(custom_obj, _sort_keys=True)

    # Should properly format as a table with sorted keys
    expected = """a = 1

[b]
c = 2
d = 3
"""
    assert result.as_string() == expected

    api.unregister_encoder(encode_custom_dict)


def test_no_extra_minus_sign() -> None:
    doc = parse("a = -1")
    assert doc.as_string() == "a = -1"
    doc["a"] *= -1
    assert doc.as_string() == "a = +1"
    doc["a"] *= -1
    assert doc.as_string() == "a = -1"

    doc = parse("a = -1.5")
    assert doc.as_string() == "a = -1.5"
    doc["a"] *= -1
    assert doc.as_string() == "a = +1.5"
    doc["a"] *= -1
    assert doc.as_string() == "a = -1.5"


def test_serialize_table_with_dotted_key() -> None:
    child = api.table()
    child.add(api.key(("b", "c")), 1)
    parent = api.table()
    parent.add("a", child)
    assert parent.as_string() == "[a]\nb.c = 1\n"


def test_not_showing_parent_header_for_super_table() -> None:
    doc = api.document()

    def add_table(parent: Any, name: str) -> Any:
        parent.add(name, api.table())
        return parent[name]

    root = add_table(doc, "root")
    add_table(root, "first")
    add_table(root, "second")
    assert doc.as_string() == "[root.first]\n\n[root.second]\n"


def test_removal_of_arrayitem_with_extra_whitespace() -> None:
    expected = 'x = [\n    "bar",\n]'
    doc = parse('x = [\n    "foo" ,#spam\n    "bar",\n]')
    x = doc["x"]
    assert isinstance(x, Array)
    x.remove("foo")
    docstr = doc.as_string()
    parse(docstr)
    assert docstr == expected


def test_badly_formatted_array_and_item_removal() -> None:
    expected = """
x = [
    '0'#a
,#b

    # comment
'1' #c
    , #d
# another comment
'2'
 # yet another comment
,'3', # f
 # comments here
'4'#g
#    comments there
 ,'5' #h
    #   comments everywhere
 # so many comments
,'6' ,
      # you get a comment
# you get a comment
   '7' ,#j
# everybody gets a comment!!!
 "8"#c
,"9",  \n  "10"
]
"""
    assert expected == parse(expected).as_string()
    for i in range(11):
        doc = parse(expected)
        x = doc["x"]
        assert isinstance(x, Array)
        x.remove(str(i))
        parse(doc.as_string())


def test_array_item_removal_newline_restore_next() -> None:
    expected = "x = [\n  '0',\n    '2'\n]"

    docstr = "x = [\n  '0',\n    '1','2'\n]"
    doc = parse(docstr)
    doc["x"].remove("1")
    assert doc.as_string() == expected
    parse(doc.as_string())

    docstr = "x = [\n  '0',\n    '1',  '2'\n]"
    doc = parse(docstr)
    doc["x"].remove("1")
    assert doc.as_string() == expected
    parse(doc.as_string())


def test_table_membership_matches_inner_container() -> None:
    doc = parse('[server]\nhost = "localhost"\nport = 8080\nenabled = true\n')
    table = doc["server"]
    assert isinstance(table, Table)

    assert "host" in table
    assert "port" in table
    assert "enabled" in table
    assert "missing" not in table

    # ``str`` and ``Key`` arguments behave identically.
    assert Key("host") in table
    assert Key("missing") not in table

    # The native ``__contains__`` must agree with the inner container it delegates to.
    for key in ("host", "port", "enabled", "missing", ""):
        assert (key in table) == (key in table.value)

    # A non-string / non-Key key still raises, as before.
    with pytest.raises(TypeError):
        table.__contains__(42)


def test_inline_table_membership() -> None:
    doc = parse("point = {x = 1, y = 2}")
    inline = doc["point"]
    assert isinstance(inline, InlineTable)

    assert "x" in inline
    assert "y" in inline
    assert "z" not in inline
    assert Key("y") in inline


def test_out_of_order_table_membership() -> None:
    content = '[a.a]\nkey = "value"\n\n[a.b]\n\n[a.a.c]\n'
    doc = parse(content)
    table = doc["a"]

    # ``a`` is stored out of order (a tuple index in the inner container): the
    # only non-trivial ``__contains__`` branch, where an ``OutOfOrderTableProxy``
    # is built. Membership must be correct and must not mutate the document.
    assert "a" in table
    assert "b" in table
    assert "missing" not in table
    assert doc.as_string() == content


def test_out_of_order_table_proxy_membership() -> None:
    # A top-level table split by another table (``a`` interrupted by ``foo``)
    # resolves to an ``OutOfOrderTableProxy``; exercise its native
    # ``__contains__`` directly (the test above goes through ``Table``).
    content = "[a.x]\np = 1\n[foo]\nbar = 2\n[a.y]\nq = 3\n"
    doc = parse(content)
    table = doc["a"]
    assert isinstance(table, OutOfOrderTableProxy)

    assert "x" in table
    assert "y" in table
    assert "missing" not in table
    # a Key is accepted just like __getitem__ does
    assert Key("x") in table
    # a non-str/non-Key argument is rejected like the other mapping types
    with pytest.raises(TypeError):
        _ = 123 in table
    # membership must not resolve values or mutate the document
    assert doc.as_string() == content
