# coding=utf-8
"""
pytest port of the original test_data.py
Run from the src/invesytoolbox directory:
python -m pytest ../tests/test_data.py -v
"""

import datetime

import DateTime
import pendulum
import pytest
from dateutil.parser import parse
from invesytoolbox.itb_data import (
    any_2boolean,
    create_vcard,
    dict_2datatypes,
    dict_2unicode,
    dict_from_dict_list,
    dictlist_2datatypes,
    sort_dictlist,
    value_2datatype,
)
from testdata.data import (
    dict_for_testing,
    dict_for_testing_datatyped,
    dict_for_testing_datatyped_unicoded,
    dict_for_testing_unicoded,
    dict_list,
    dict_list_sorted_by_c,
    dicts_from_dict_list,
    kwargs_list,
    sample_vcard,
    test_boolean,
    vcard_data,
)


# ============================================================
# any_2boolean
# ============================================================

@pytest.mark.parametrize("value, expected", test_boolean.items())
def test_any_2boolean(value, expected):
    assert any_2boolean(value) == expected


# ============================================================
# dict_from_dict_list
# ============================================================

@pytest.mark.parametrize("kwargs, expected", zip(kwargs_list, dicts_from_dict_list))
def test_dict_from_dict_list(kwargs, expected):
    result = dict_from_dict_list(**kwargs)
    assert isinstance(result, dict)
    assert result == expected


def test_dict_from_dict_list_empty():
    """Empty list returns empty dict."""
    assert dict_from_dict_list(dict_list=[], key='a') == {}


def test_dict_from_dict_list_not_a_list():
    with pytest.raises(ValueError, match='dict_list must be a list'):
        dict_from_dict_list(dict_list='not a list', key='a')


def test_dict_from_dict_list_non_dict_item():
    with pytest.raises(ValueError, match='must be dicts'):
        dict_from_dict_list(dict_list=[{'a': 1}, 'not a dict'], key='a')


def test_dict_from_dict_list_missing_key():
    with pytest.raises(ValueError, match='missing in item'):
        dict_from_dict_list(
            dict_list=[{'a': 1, 'b': 2}, {'b': 3}],
            key='a'
        )


def test_dict_from_dict_list_duplicate_keys_allowed():
    """Duplicates are allowed by default — last entry wins."""
    result = dict_from_dict_list(
        dict_list=[{'a': 1, 'b': 2}, {'a': 1, 'b': 3}],
        key='a'
    )
    assert result == {1: {'b': 3}}


def test_dict_from_dict_list_duplicate_keys_forbidden():
    """With allow_duplicates=False, duplicates raise ValueError."""
    with pytest.raises(ValueError, match='duplicated'):
        dict_from_dict_list(
            dict_list=[{'a': 1, 'b': 2}, {'a': 1, 'b': 3}],
            key='a',
            allow_duplicates=False
        )


def test_dict_from_dict_list_empty_key():
    with pytest.raises(ValueError, match='non-empty string'):
        dict_from_dict_list(dict_list=[{'a': 1}], key='')


def test_dict_from_dict_list_empty_single_value():
    with pytest.raises(ValueError, match='non-empty string'):
        dict_from_dict_list(dict_list=[{'a': 1}], key='a', single_value='')


def test_dict_from_dict_list_missing_single_value():
    with pytest.raises(ValueError, match="single_value 'c'"):
        dict_from_dict_list(
            dict_list=[{'a': 1, 'b': 2}],
            key='a',
            single_value='c'
        )


def test_dict_from_dict_list_does_not_mutate_input():
    """Original dicts must not be modified."""
    original = [{'id': 1, 'name': 'Alice'}, {'id': 2, 'name': 'Bob'}]
    original_copy = [dict(d) for d in original]
    dict_from_dict_list(dict_list=original, key='id')
    assert original == original_copy


# ============================================================
# dict_2unicode
# ============================================================

def test_dict_2unicode():
    result = dict_2unicode(dict_for_testing)
    assert result == dict_for_testing_unicoded


def test_dict_2unicode_all_strings():
    """Already-unicode dict passes through unchanged."""
    d = {'a': 'hello', 'b': 'world'}
    assert dict_2unicode(d) == d


def test_dict_2unicode_all_bytes():
    d = {b'key1': b'val1', b'key2': b'val2'}
    assert dict_2unicode(d) == {'key1': 'val1', 'key2': 'val2'}


def test_dict_2unicode_non_string_values():
    """Non-string values (int, list, etc.) are left unchanged."""
    d = {'a': 42, b'b': [1, 2], 'c': None}
    result = dict_2unicode(d)
    assert result == {'a': 42, 'b': [1, 2], 'c': None}


def test_dict_2unicode_latin1():
    d = {b'\xe4': b'\xf6'}  # ä: ö in latin-1
    result = dict_2unicode(d, encoding='latin-1')
    assert result == {'ä': 'ö'}


# ============================================================
# dict_2datatypes
# ============================================================

def test_dict_2datatypes_default():
    result = dict_2datatypes(
        d=dict_for_testing,
        convert_keys=True,
        dt='datetime'
    )
    assert result == dict_for_testing_datatyped


def test_dict_2datatypes_metadata():
    result = dict_2datatypes(
        d=dict_for_testing,
        metadata={
            'x': 'date',
            'y': 'time'
        },
        convert_keys=True,
        dt='datetime'
    )
    assert result == dict_for_testing_datatyped


def test_dict_2datatypes_unicode_dt():
    result = dict_2datatypes(
        d=dict_for_testing,
        convert_keys=True,
        convert_to_unicode=True,
        dt='DateTime'
    )
    assert result == dict_for_testing_datatyped_unicoded


def test_dict_2datatypes_no_metadata():
    """Calling without metadata (None default) works."""
    result = dict_2datatypes(d={'a': '42'}, dt='ignore')
    assert result == {'a': 42}  # '42' is still guessed as int


def test_dict_2datatypes_ignore_mode():
    """dt='ignore' leaves date/time strings as strings, but other types are still guessed."""
    result = dict_2datatypes(
        d={'date': '01.04.2022', 'num': '42'},
        dt='ignore'
    )
    assert result == {'date': '01.04.2022', 'num': 42}


# ============================================================
# dictlist_2datatypes
# ============================================================

def test_dictlist_2datatypes():
    dictlist_for_testing = [
        dict_for_testing,
        dict_for_testing,
        dict_for_testing
    ]
    dictlist_datatyped = [
        dict_for_testing_datatyped_unicoded,
        dict_for_testing_datatyped_unicoded,
        dict_for_testing_datatyped_unicoded
    ]
    result = dictlist_2datatypes(
        dictlist=dictlist_for_testing,
        convert_keys=True,
        convert_to_unicode=True,
        dt='DateTime'
    )
    assert result == dictlist_datatyped


def test_dictlist_2datatypes_empty():
    assert dictlist_2datatypes(dictlist=[]) == []


# ============================================================
# sort_dictlist
# ============================================================

def test_sort_dictlist_single():
    result = sort_dictlist(
        dictlist=dict_list,
        keys='c'
    )
    assert result == dict_list_sorted_by_c


def test_sort_dictlist_multiple():
    result = sort_dictlist(
        dictlist=dict_list,
        keys=['c', 'a']
    )
    assert result == dict_list_sorted_by_c


def test_sort_dictlist_reverse():
    result = sort_dictlist(
        dictlist=dict_list,
        keys='c',
        reverse=True
    )
    assert result == list(reversed(dict_list_sorted_by_c))


def test_sort_dictlist_comma_string():
    """Keys as comma-separated string."""
    result = sort_dictlist(
        dictlist=dict_list,
        keys='c, a'
    )
    assert result == dict_list_sorted_by_c


def test_sort_dictlist_reverse_multiple():
    """Reverse with multiple keys."""
    result = sort_dictlist(
        dictlist=dict_list,
        keys=['c', 'a'],
        reverse=True
    )
    assert result == list(reversed(dict_list_sorted_by_c))


def test_sort_dictlist_does_not_mutate():
    """Original list is not modified."""
    original = [{'a': 3}, {'a': 1}, {'a': 2}]
    original_copy = list(original)
    sort_dictlist(dictlist=original, keys='a')
    assert original == original_copy


# ============================================================
# value_2datatype — with explicit typ
# ============================================================

def test_value_2datatype_ignore():
    assert value_2datatype('hello', typ='ignore') == 'hello'
    assert value_2datatype(42, typ='ignore') == 42


def test_value_2datatype_string():
    assert value_2datatype(42, typ='string') == '42'
    assert value_2datatype(True, typ='string') == 'True'


def test_value_2datatype_boolean():
    assert value_2datatype('True', typ='boolean') is True
    assert value_2datatype('False', typ='boolean') is False
    assert value_2datatype('0', typ='boolean') is False
    assert value_2datatype('yes', typ='boolean') is True
    assert value_2datatype('', typ='boolean') is False


def test_value_2datatype_int():
    assert value_2datatype('42', typ='int') == 42
    assert value_2datatype('0', typ='int') == 0
    assert value_2datatype('-7', typ='int') == -7


def test_value_2datatype_int_invalid():
    """Non-numeric string returned as-is."""
    assert value_2datatype('abc', typ='int') == 'abc'


def test_value_2datatype_float():
    assert value_2datatype('3.14', typ='float') == 3.14
    assert value_2datatype('3,14', typ='float') == 3.14  # comma → dot


def test_value_2datatype_float_non_string():
    """Non-string values should be converted to float without crashing."""
    assert value_2datatype(42, typ='float') == 42.0
    assert value_2datatype(3.14, typ='float') == 3.14
    assert value_2datatype(0, typ='float') == 0.0
    assert value_2datatype(True, typ='float') == 1.0


def test_value_2datatype_float_invalid():
    assert value_2datatype('abc', typ='float') == 'abc'


def test_value_2datatype_date_datetime():
    result = value_2datatype('01.04.2022', typ='date', dt='datetime')
    assert isinstance(result, datetime.date)
    assert result == datetime.date(2022, 4, 1)


def test_value_2datatype_date_DateTime():
    result = value_2datatype('01.04.2022', typ='date', dt='DateTime')
    assert isinstance(result, DateTime.DateTime)


def test_value_2datatype_date_ignore():
    result = value_2datatype('01.04.2022', typ='date', dt='ignore')
    assert result == '01.04.2022'


def test_value_2datatype_date_invalid_dt():
    with pytest.raises(ValueError, match='invalid value'):
        value_2datatype('01.04.2022', typ='date', dt='nonsense')


def test_value_2datatype_datetime_datetime():
    result = value_2datatype(
        '01.04.2022 10:32',
        typ='datetime',
        dt='datetime',
        fmt='%d.%m.%Y %H:%M'
    )
    assert isinstance(result, datetime.datetime)
    assert result == datetime.datetime(2022, 4, 1, 10, 32)


def test_value_2datatype_datetime_datetime_no_fmt():
    """Without fmt, convert_datetime should auto-detect the format."""
    result = value_2datatype(
        '01.04.2022 10:32',
        typ='datetime',
        dt='datetime',
    )
    assert isinstance(result, datetime.datetime)
    assert result.year == 2022
    assert result.month == 4
    assert result.day == 1
    assert result.hour == 10
    assert result.minute == 32


def test_value_2datatype_datetime_DateTime():
    result = value_2datatype('2022/04/01 10:32', typ='datetime', dt='DateTime')
    assert isinstance(result, DateTime.DateTime)


def test_value_2datatype_datetime_DateTime_invalid():
    """Invalid datetime string returns the original value."""
    result = value_2datatype('not-a-date', typ='datetime', dt='DateTime')
    assert result == 'not-a-date'


def test_value_2datatype_datetime_ignore():
    result = value_2datatype('01.04.2022 10:32', typ='datetime', dt='ignore')
    assert result == '01.04.2022 10:32'


def test_value_2datatype_datetime_invalid_dt():
    with pytest.raises(ValueError, match='invalid value'):
        value_2datatype('01.04.2022', typ='datetime', dt='nonsense')


def test_value_2datatype_time():
    result = value_2datatype('10:32', typ='time', dt='datetime')
    assert isinstance(result, datetime.time)
    assert result == datetime.time(10, 32)


def test_value_2datatype_time_DT():
    """With dt='DT', time is returned as string."""
    result = value_2datatype('10:32', typ='time', dt='DT')
    assert result == '10:32'


def test_value_2datatype_pendulum_fmt():
    value = '01.04.2022 10:32'
    fmt = 'DD.MM.YYYY HH:mm'
    p = value_2datatype(
        value=value,
        typ='datetime',
        dt='pendulum',
        fmt=fmt
    )
    assert isinstance(p, pendulum.DateTime)


def test_value_2datatype_pendulum_iso():
    value = '2024-04-01T10:32:00'
    p = value_2datatype(
        value=value,
        dt='pendulum'
    )
    assert isinstance(p, pendulum.DateTime)


def test_value_2datatype_pendulum_utc_to_local():
    """UTC pendulum datetime is converted to local timezone when timezone='local'."""
    value = '2024-04-01T10:00:00+00:00'  # explicit UTC
    p = value_2datatype(
        value=value,
        typ='datetime',
        dt='pendulum',
        timezone='local',
    )
    assert isinstance(p, pendulum.DateTime)
    # must be converted to local timezone (on UTC systems, local IS UTC)
    assert p.timezone_name == pendulum.local_timezone().name
    # the instant in time must be the same (only representation changes)
    assert p.int_timestamp == pendulum.parse('2024-04-01T10:00:00+00:00').int_timestamp


def test_value_2datatype_pendulum_date():
    value = '2022-04-01'
    p = value_2datatype(
        value=value,
        typ='date',
        dt='pendulum'
    )
    assert isinstance(p, (pendulum.DateTime, pendulum.Date))


# ============================================================
# value_2datatype — guessing mode (no typ)
# ============================================================

def test_value_2datatype_guess_bool():
    assert value_2datatype(True) is True
    assert value_2datatype(False) is False


def test_value_2datatype_guess_int():
    assert value_2datatype(42) == 42
    assert value_2datatype('42') == 42
    assert value_2datatype('-7') == -7


def test_value_2datatype_guess_float():
    assert value_2datatype(3.14) == 3.14
    assert value_2datatype('3,14') == 3.14


def test_value_2datatype_guess_string_true_false():
    assert value_2datatype('True') is True
    assert value_2datatype('False') is False


def test_value_2datatype_guess_time():
    result = value_2datatype('10:32', dt='datetime')
    assert isinstance(result, datetime.time)
    assert result == datetime.time(10, 32)

    result = value_2datatype('10:32:45', dt='datetime')
    assert isinstance(result, datetime.time)
    assert result == datetime.time(10, 32, 45)


def test_value_2datatype_guess_time_non_datetime():
    """With dt='ignore', time string stays as string."""
    result = value_2datatype('10:32', dt='ignore')
    assert result == '10:32'


def test_value_2datatype_guess_date():
    result = value_2datatype('01.04.2022', dt='datetime')
    assert isinstance(result, datetime.date)
    assert result == datetime.date(2022, 4, 1)


def test_value_2datatype_guess_date_DateTime():
    result = value_2datatype('01.04.2022', dt='DateTime')
    assert isinstance(result, DateTime.DateTime)


def test_value_2datatype_guess_plain_string():
    """Non-numeric, non-date strings stay as strings."""
    assert value_2datatype('hello world') == 'hello world'
    assert value_2datatype('abc') == 'abc'


def test_value_2datatype_guess_bytes():
    """Bytes are decoded and then guessed."""
    assert value_2datatype(b'42') == 42
    assert value_2datatype(b'3,14') == 3.14
    assert value_2datatype(b'hello') == b'hello'  # restored to bytes when convert_to_unicode=False


def test_value_2datatype_guess_bytes_unicode():
    """With convert_to_unicode=True, bytes stay as unicode."""
    result = value_2datatype(b'hello', convert_to_unicode=True)
    assert result == 'hello'


def test_value_2datatype_guess_whitespace():
    """Strings with leading/trailing whitespace are stripped."""
    assert value_2datatype('  42  ') == 42
    assert value_2datatype('  True  ') is True


def test_value_2datatype_guess_comma_float():
    """Comma-delimited floats like '1,5' are parsed."""
    assert value_2datatype('1,5') == 1.5
    assert value_2datatype('100,00') == 100.0


def test_value_2datatype_guess_none():
    """None stays None (falsy, non-str)."""
    assert value_2datatype(None) is None


def test_value_2datatype_guess_list():
    """Lists pass through unchanged."""
    val = [1, 2, 3]
    assert value_2datatype(val) == [1, 2, 3]


# ============================================================
# create_vcard
# ============================================================

def test_create_vcard_full():
    """Full vcard with all fields populated."""
    vcard = create_vcard(vcard_data)
    assert vcard is not None
    assert 'BEGIN:VCARD' in vcard
    assert 'VERSION:3.0' in vcard
    assert 'END:VCARD' in vcard
    assert 'N:Tester;Georg;;;' in vcard
    assert 'FN:Georg Tester' in vcard
    assert 'EMAIL;TYPE=INTERNET:georg.tester@test.at' in vcard
    assert 'TEL;TYPE=CELL,VOICE:+43 699 123123123' in vcard
    assert 'ADR;TYPE=HOME,pref:;;Teststraße 34;Völkermarkt;;4321;Österreich' in vcard
    assert 'NOTE:Eine hinzugefügte Notiz: Mit colon!' in vcard


def test_create_vcard_serialize_matches_sample():
    """Serialized output matches the expected sample_vcard (after normalizing line endings)."""
    vcard = create_vcard(vcard_data)
    vcard_normalized = vcard.replace('\r\n', '\n')
    assert vcard_normalized == sample_vcard


def test_create_vcard_no_name_returns_none():
    """No prename and no surname returns None."""
    assert create_vcard({}) is None
    assert create_vcard({'email': 'x@y.com', 'phone': '+43 1 234567'}) is None


def test_create_vcard_prename_only():
    """Only prename given — surname should be empty."""
    vcard = create_vcard({'prename': 'Maria'})
    assert vcard is not None
    assert 'N:;Maria;;;' in vcard
    assert 'FN:Maria\r\n' in vcard


def test_create_vcard_surname_only():
    """Only surname given — prename should be empty."""
    vcard = create_vcard({'surname': 'Müller'})
    assert vcard is not None
    assert 'N:Müller;;;;' in vcard
    assert 'FN:Müller' in vcard


def test_create_vcard_invalid_phone():
    """Invalid phone number — TEL field still present but with error marker."""
    vcard = create_vcard({
        'prename': 'Hans',
        'surname': 'Test',
        'phone': 'abc'
    })
    assert vcard is not None
    assert 'N:Test;Hans;;;' in vcard
    assert 'TEL' in vcard


def test_create_vcard_minimal_with_email():
    """Minimal data with just name and email."""
    vcard = create_vcard({
        'prename': 'Lisa',
        'surname': 'Muster',
        'email': 'lisa@example.com'
    })
    assert vcard is not None
    assert 'FN:Lisa Muster' in vcard
    assert 'N:Muster;Lisa;;;' in vcard
    assert 'EMAIL;TYPE=INTERNET:lisa@example.com' in vcard


def test_create_vcard_special_chars_in_note():
    """Notes with special characters (colons, umlauts) are preserved."""
    vcard = create_vcard({
        'prename': 'Test',
        'surname': 'User',
        'note': 'Spezial: Ä Ö Ü ß & "Anführungszeichen"'
    })
    assert vcard is not None
    assert 'Spezial' in vcard
    assert 'Ä Ö Ü ß' in vcard


def test_create_vcard_pretty_returns_none():
    """prettyPrint() prints to stdout and returns None."""
    result = create_vcard(vcard_data, returning='pretty')
    assert result is None


def test_create_vcard_line_endings():
    """Serialized vcard uses Windows-style \\r\\n line endings."""
    vcard = create_vcard(vcard_data)
    assert '\r\n' in vcard
