#
# Copyright 2008-2009 Zuza Software Foundation
#
# This file is part of the Translate Toolkit.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see <http://www.gnu.org/licenses/>.

"""tests for storage base classes."""

import json
import os
from io import BytesIO

from pytest import raises

from translate.misc.multistring import multistring
from translate.storage import base, factory
from translate.storage.placeables import general
from translate.storage.placeables import parse as rich_parse


def headerless_len(units):
    """Return count of translatable (non header) units."""
    return len(list(filter(lambda x: not x.isheader(), units)))


def first_translatable(store):
    """Returns first translatable unit, skipping header if present."""
    if store.units[0].isheader() and len(store.units) > 1:
        return store.units[1]
    return store.units[0]


class JsonTranslationStore(base.TranslationStore):
    """Test-only TranslationStore that uses JSON for serialization."""

    def serialize(self, out) -> None:
        """Serialize using JSON (only for testing)."""
        units_data = []
        for unit in self.units:
            unit_dict = {
                "source": str(unit.source) if unit.source else None,
                "target": str(unit.target) if unit.target else None,
            }
            units_data.append(unit_dict)

        store_data = {
            "units": units_data,
            "sourcelanguage": self.sourcelanguage,
            "targetlanguage": self.targetlanguage,
        }
        out.write(json.dumps(store_data, ensure_ascii=False).encode("utf-8"))

    def parse(self, data) -> None:
        """Parse using JSON (only for testing)."""
        store_data = json.loads(data.decode("utf-8"))
        self.units = []
        for unit_dict in store_data.get("units", []):
            unit = self.UnitClass()
            if unit_dict.get("source") is not None:
                unit.source = unit_dict["source"]
            if unit_dict.get("target") is not None:
                unit.target = unit_dict["target"]
            unit._store = self
            self.units.append(unit)
        self.sourcelanguage = store_data.get("sourcelanguage")
        self.targetlanguage = store_data.get("targetlanguage")


def test_unknown_language_single_plural_tags_use_other() -> None:
    store = base.TranslationStore()
    store.settargetlanguage("tok")

    assert store.get_plural_tags(multistring(["the only form"])) == ["other"]


def test_known_language_single_plural_tags_keep_language_mapping() -> None:
    store = base.TranslationStore()
    store.settargetlanguage("en")

    assert store.get_plural_tags(multistring(["one form"])) == ["one", "other"]


class TestTranslationUnit:
    """
    Tests a TranslationUnit.
    Derived classes can reuse these tests by pointing UnitClass to a derived Unit.
    """

    UnitClass = base.TranslationUnit

    def setup_method(self, method) -> None:
        self.unit = self.UnitClass("Test String")

    def normalize_unit_metadata(self, *units) -> None:
        """
        Hook for subclasses to normalize metadata before equality checks.

        This method is called before equality comparisons in test_eq to allow
        format-specific normalization of metadata fields that may vary between
        unit instances (e.g., timestamps).

        :param units: Variable number of units to normalize
        """

    def test_isfuzzy(self) -> None:
        """
        Test that we can call isfuzzy() on a unit.

        The default return value for isfuzzy() should be False.
        """
        assert not self.unit.isfuzzy()

    def test_create(self) -> None:
        """Tests a simple creation with a source string."""
        unit = self.unit
        print("unit.source:", unit.source)
        assert unit.source == "Test String"

    def test_eq(self) -> None:
        """Tests equality comparison."""
        unit1 = self.unit
        unit2 = self.UnitClass("Test String")
        unit3 = self.UnitClass("Test String")
        unit4 = self.UnitClass("Blessed String")
        unit5 = self.UnitClass("Blessed String")
        unit6 = self.UnitClass("Blessed String")
        # Normalize metadata before first set of comparisons
        self.normalize_unit_metadata(unit1, unit2, unit3, unit4, unit5, unit6)
        # pylint: disable-next=comparison-with-itself
        assert unit1 == unit1  # noqa: PLR0124
        assert unit1 == unit2
        assert unit1 != unit4
        unit1.target = "Stressed Ting"
        unit2.target = "Stressed Ting"
        unit5.target = "Stressed Bling"
        unit6.target = "Stressed Ting"
        # Normalize metadata again after setting targets
        self.normalize_unit_metadata(unit1, unit2, unit3, unit4, unit5, unit6)
        assert unit1 == unit2
        assert unit1 != unit3
        assert unit4 != unit5
        if unit1.__class__.__name__ in {"RESXUnit", "dtdunit", "TxtUnit", "iniunit"}:
            # unit1 will generally equal unit6 for monolingual formats (resx, dtd, txt)
            # with the default comparison method which compare units by their
            # target and source properties only.
            # For other monolingual formats:
            # - AndroidResourceUnit is comparing units by their full xml serialization (overridden __eq__)
            # - iniunit, phpunit and propunit (properties) can have different source/target
            #   and are reunited when serializing through `self.translation or self.value`
            assert unit1 == unit6
            assert unit1 == unit6
        else:
            assert unit1 != unit6
            assert unit1 != unit6

    def test_target(self) -> None:
        unit = self.unit
        assert not unit.target
        unit.target = "Stressed Ting"
        assert unit.target == "Stressed Ting"
        unit.target = "Stressed Bling"
        assert unit.target == "Stressed Bling"
        unit.target = ""
        assert unit.target == ""

    def test_escapes(self) -> None:
        """
        Test all sorts of characters that might go wrong in a quoting and
        escaping roundtrip.
        """
        unit = self.unit
        specials = [
            "Fish & chips",
            "five < six",
            "six > five",
            "five &lt; six",
            "Use &nbsp;",
            "Use &amp;nbsp;",
            "Use &amp;amp;nbsp;",
            'A "solution"',
            "skop 'n bal",
            '"""',
            "'''",
            "µ",
            "\n",
            "\t",
            "\r",
            "\r\n",
            "\\r",
            "\\",
            "\\\r",
        ]
        for special in specials:
            unit.source = special
            print("unit.source:", repr(unit.source))
            print("special:", repr(special))
            assert unit.source == special

    def test_difficult_escapes(self) -> None:
        """
        Test difficult characters that might go wrong in a quoting and
        escaping roundtrip.
        """
        unit = self.unit
        specials = [
            "\\n",
            "\\t",
            '\\"',
            "\\ ",
            "\\\n",
            "\\\t",
            "\\\\n",
            "\\\\t",
            "\\\\r",
            '\\\\"',
            "\\r\\n",
            "\\\\r\\n",
            "\\r\\\\n",
            "\\\\n\\\\r",
        ]
        for special in specials:
            unit.source = special
            print("unit.source:", f"{unit.source!r}|")
            print("special:", f"{special!r}|")
            assert unit.source == special

    def test_note_sanity(self) -> None:
        """Tests that all subclasses of the base behaves consistently with regards to notes."""
        unit = self.unit

        unit.addnote("Test note 1", origin="translator")
        unit.addnote("Test note 2", origin="translator")
        unit.addnote("Test note 3", origin="translator")
        expected_notes = "Test note 1\nTest note 2\nTest note 3"
        actual_notes = unit.getnotes(origin="translator")
        assert actual_notes == expected_notes

        # Test with no origin.
        unit.removenotes()
        assert not unit.getnotes()
        unit.addnote("Test note 1")
        unit.addnote("Test note 2")
        unit.addnote("Test note 3")
        expected_notes = "Test note 1\nTest note 2\nTest note 3"
        actual_notes = unit.getnotes()
        assert actual_notes == expected_notes

    def test_line_number_default(self) -> None:
        """Test that line_number is None by default."""
        unit = self.unit
        assert unit.line_number is None

    def test_rich_get(self) -> None:
        """Basic test for converting from multistrings to StringElem trees."""
        target_mstr = multistring(["tėst", "<b>string</b>"])
        unit = self.UnitClass(multistring(["a", "b"]))
        unit.rich_parsers = general.parsers
        unit.target = target_mstr
        elems = unit.rich_target

        if unit.hasplural():
            assert len(elems) == 2
            assert len(elems[0].sub) == 1
            assert len(elems[1].sub) == 3

            assert str(elems[0]) == target_mstr.strings[0]
            assert str(elems[1]) == target_mstr.strings[1]

            assert str(elems[1].sub[0]) == "<b>"
            assert str(elems[1].sub[1]) == "string"
            assert str(elems[1].sub[2]) == "</b>"
        else:
            assert len(elems[0].sub) == 1
            assert str(elems[0]) == target_mstr.strings[0]

    def test_rich_set(self) -> None:
        """Basic test for converting from multistrings to StringElem trees."""
        elems = [
            rich_parse("Tëst <x>string</x>", general.parsers),
            rich_parse("Another test string.", general.parsers),
        ]
        unit = self.UnitClass(multistring(["a", "b"]))
        unit.rich_target = elems

        if unit.hasplural():
            assert unit.target.strings[0] == "Tëst <x>string</x>"
            assert unit.target.strings[1] == "Another test string."
        else:
            assert unit.target == "Tëst <x>string</x>"


class TestTranslationStore:
    """
    Tests a TranslationStore.

    Derived classes can reuse these tests by pointing StoreClass to a derived Store
    """

    StoreClass = JsonTranslationStore

    def setup_method(self, method) -> None:
        """Allocates a unique self.filename for the method, making sure it doesn't exist."""
        self.filename = f"{self.__class__.__name__}_{method.__name__}.test"
        if os.path.exists(self.filename):
            os.remove(self.filename)

    def teardown_method(self, method) -> None:
        """Makes sure that if self.filename was created by the method, it is cleaned up."""
        if os.path.exists(self.filename):
            os.remove(self.filename)

    def test_create_blank(self) -> None:
        """Tests creating a new blank store."""
        store = self.StoreClass()
        assert headerless_len(store.units) == 0

    def test_add(self) -> None:
        """Tests adding a new unit with a source string."""
        store = self.StoreClass()
        unit = store.addsourceunit("Test String")
        print(str(unit))
        print(bytes(store))
        assert headerless_len(store.units) == 1
        assert unit.source == "Test String"

    def test_remove(self) -> None:
        """Tests removing a unit with a source string."""
        store = self.StoreClass()
        unit = store.addsourceunit("Test String")
        # Some storages (MO, OmegaT) serialize only translated units
        unit.target = "Test target"
        assert headerless_len(store.units) == 1
        withunit = bytes(store)
        print(withunit)
        store.removeunit(unit)
        assert headerless_len(store.units) == 0
        withoutunit = bytes(store)
        print(withoutunit)
        assert withoutunit != withunit

    def test_find(self) -> None:
        """Tests searching for a given source string."""
        store = self.StoreClass()
        unit1 = store.addsourceunit("Test String")
        unit2 = store.addsourceunit("Blessed String")
        assert store.findunit("Test String") == unit1
        assert store.findunit("Blessed String") == unit2
        assert store.findunit("Nest String") is None

    def test_translate(self) -> None:
        """Tests the translate method and non-ascii characters."""
        store = self.StoreClass()
        unit = store.addsourceunit("scissor")
        unit.target = "skêr"
        unit = store.addsourceunit("Beziér curve")
        unit.target = "Beziér-kurwe"
        assert store.translate("scissor") == "skêr"
        assert store.translate("Beziér curve") == "Beziér-kurwe"

    def reparse(self, store):
        """Converts the store to a string and back to a store again."""
        storestring = bytes(store)
        return self.StoreClass.parsestring(storestring)

    @staticmethod
    def check_equality(store1, store2) -> None:
        """Asserts that store1 and store2 are the same."""
        assert headerless_len(store1.units) == headerless_len(store2.units)
        for n, store1unit in enumerate(store1.units):
            store2unit = store2.units[n]
            print(
                f"match failed between elements {n + 1} of {headerless_len(store1.units)}"
            )
            print("store1:")
            print(bytes(store1))
            print("store2:")
            print(bytes(store2))
            print(f"store1.units[{n}].__dict__:", store1unit.__dict__)
            print(f"store2.units[{n}].__dict__:", store2unit.__dict__)
            assert store1unit == store2unit

    def test_parse(self) -> None:
        """Tests converting to a string and parsing the resulting string."""
        store = self.StoreClass()
        unit1 = store.addsourceunit("Test String")
        unit1.target = "Test String"
        unit2 = store.addsourceunit("Test String 2")
        unit2.target = "Test String 2"
        newstore = self.reparse(store)
        self.check_equality(store, newstore)

    def test_files(self) -> None:
        """Tests saving to and loading from files."""
        store = self.StoreClass()
        unit1 = store.addsourceunit("Test String")
        unit1.target = "Test String"
        unit2 = store.addsourceunit("Test String 2")
        unit2.target = "Test String 2"
        store.savefile(self.filename)
        newstore = self.StoreClass.parsefile(self.filename)
        self.check_equality(store, newstore)

    def test_save(self) -> None:
        """Tests that we can save directly back to the original file."""
        store = self.StoreClass()
        unit1 = store.addsourceunit("Test String")
        unit1.target = "Test String"
        unit2 = store.addsourceunit("Test String 2")
        unit2.target = "Test String 2"
        store.savefile(self.filename)
        store.save()
        newstore = self.StoreClass.parsefile(self.filename)
        self.check_equality(store, newstore)

    def test_markup(self) -> None:
        """Tests that markup survives the roundtrip. Most useful for xml types."""
        store = self.StoreClass()
        unit = store.addsourceunit("<vark@hok.org> %d keer %2$s")
        assert unit.source == "<vark@hok.org> %d keer %2$s"
        unit.target = "bla"
        assert store.translate("<vark@hok.org> %d keer %2$s") == "bla"

    def test_nonascii(self) -> None:
        store = self.StoreClass()
        unit = store.addsourceunit("Beziér curve")
        unit.target = "Beziér-kurwe"
        answer = store.translate("Beziér curve")
        assert answer == "Beziér-kurwe"
        # Just test that __str__ doesn't raise exception:
        store.serialize(BytesIO())

    def test_extensions(self) -> None:
        """Test that the factory knows the extensions for this class."""
        supported = factory.supported_files()
        supported_dict = {
            name: (extensions, mimetypes) for name, extensions, mimetypes in supported
        }
        if not (self.StoreClass.Name and self.StoreClass.Name in supported_dict):
            return
        detail = supported_dict[
            self.StoreClass.Name
        ]  # will start to get problematic once translated
        print("Factory:", detail[0])
        print("StoreClass:", self.StoreClass.Extensions)
        for ext in detail[0]:
            assert ext in self.StoreClass.Extensions
        for ext in self.StoreClass.Extensions:
            assert ext in detail[0]

    def test_mimetypes(self) -> None:
        """Test that the factory knows the mimetypes for this class."""
        supported = factory.supported_files()
        supported_dict = {
            name: (extensions, mimetypes) for name, extensions, mimetypes in supported
        }
        if not (self.StoreClass.Name and self.StoreClass.Name in supported_dict):
            return
        detail = supported_dict[
            self.StoreClass.Name
        ]  # will start to get problematic once translated
        print("Factory:", detail[1])
        print("StoreClass:", self.StoreClass.Mimetypes)
        for ext in detail[1]:
            assert ext in self.StoreClass.Mimetypes
        for ext in self.StoreClass.Mimetypes:
            assert ext in detail[1]


class TestTranslationStoreParsefileOwnership:
    def test_parsefile_closes_passed_handle(self) -> None:
        store = JsonTranslationStore()
        unit = store.addsourceunit("Test String")
        unit.target = "Test Translation"

        input_handle = BytesIO()
        store.serialize(input_handle)
        input_handle.seek(0)

        newstore = JsonTranslationStore.parsefile(input_handle)

        assert input_handle.closed
        assert newstore.units[0].source == "Test String"
        assert newstore.units[0].target == "Test Translation"


class TestPrepareInputOwnership:
    def test_prepare_input_closes_handle_on_read_error(self) -> None:
        class FailingHandle:
            closed = False

            @staticmethod
            def read() -> bytes:
                raise OSError("boom")

            def close(self) -> None:
                self.closed = True

        handle = FailingHandle()

        with raises(OSError):
            base.prepare_input(handle, close_handle=True)

        assert handle.closed
