#
# Copyright 2012 Michal Čihař
# Copyright 2014 Luca De Petrillo
#
# 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 3 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 <https://www.gnu.org/licenses/>.

"""Module for handling Android String and Plurals resource files."""

from __future__ import annotations

import copy
import os
import re
import warnings
from typing import ClassVar, cast, overload

from lxml import etree

from translate.lang import data
from translate.misc.multistring import multistring
from translate.misc.xml_helpers import XMLTextParser, get_safe_xml_parser
from translate.storage import base, lisa

WHITESPACE = {" ", "\n", "\t"}  # Whitespace that we collapse.
ESCAPE_NEWLINE = {"n", "N"}
ESCAPE_TAB = {"t", "T"}
ESCAPE_PLAIN = {" ", '"', "'", "@", "?", "\\"}
MULTIWHITESPACE = re.compile(r"[ \n\t]{2}(?!\\n)")
QUOTED_STRING = re.compile(r'((?<!\\)"(?:\\"|[^"])*(?<!\\)")')
UNICODE_ESCAPE = re.compile(r"\\u([a-fA-F0-9]{4})")
CHAR_ESCAPE = re.compile(r"\\(.)")
WHITESPACE_RE = re.compile(r"\s+")
XML_NAME = r"[A-Za-z_][A-Za-z0-9_.:-]*"
XML_ATTRIBUTE = rf'\s+{XML_NAME}\s*=\s*(?:"[^"]*"|\'[^\']*\')'
XML_ATTRIBUTE_CAPTURE = re.compile(rf"({XML_NAME})\s*=\s*(\"[^\"]*\"|'[^']*')")
XML_TAG_NAME = re.compile(rf"^</?({XML_NAME})")
MARKUP_TAG = re.compile(rf"</?{XML_NAME}(?:{XML_ATTRIBUTE})*\s*/?>")
ESCAPED_MARKUP_TAG = re.compile(rf"&lt;/?{XML_NAME}(?:{XML_ATTRIBUTE})*\s*/?&gt;")
CDATA_ONLY = re.compile(r"(?:<!\[CDATA\[.*?\]\]>)+", re.DOTALL)


class DecodingXMLParser(XMLTextParser):
    """
    XML parser that processes non CDATA strings.

    - all XML markup from given depth is output as is
    - CDATA elements are output as is
    - process_string() is called on all other textual content
    """

    def __init__(self, text: str, *, escape_all: bool = True) -> None:
        self.inside_quoted = False
        self.escape_all = escape_all
        super().__init__(text)

    @staticmethod
    def decode_unicode_escapes(match: re.Match) -> str:
        escaped = match.group(1)
        # Convert from hex to character
        return chr(int(escaped, 16))

    def decode_escapes(self, match: re.Match) -> str:
        escaped = match.group(1)
        if escaped in ESCAPE_NEWLINE:
            return "\n"  # an actual newline
        if escaped in ESCAPE_TAB:
            return "\t"  # an actual tab
        if escaped == "\\":
            return "\\"
        # Compose Multiplatform Resources handles escaping only for few chars
        if not self.escape_all:
            return rf"\{escaped}"
        if escaped in ESCAPE_PLAIN:
            # Plain string to escape
            return escaped
        # All others, remove, like Android does as well.
        return ""

    def strip_part(self, text: str) -> tuple[str, bool]:
        # Detect leading quote
        if text.startswith('"'):
            text = text[1:]
            self.inside_quoted ^= True

        # Check if whitespace should be stripped
        strip_whitespace = not self.inside_quoted

        # Detect trailing quote
        if text.endswith('"') and not text.endswith('\\"'):
            text = text[:-1]
            self.inside_quoted ^= True

        # Consolidate whitespace if needed
        if strip_whitespace:
            text = WHITESPACE_RE.sub(" ", text)

        return text, strip_whitespace

    def process_string(self, content: str) -> tuple[str, bool, bool]:
        # Skip processing for a blank string
        if not content:
            return "", False, False

        # Extract quoted parts, remove blank ones
        parts = [part for part in QUOTED_STRING.split(content) if part]

        # Detect possible cleanups
        cleanup_start = False
        cleanup_end = False

        # Collapse whitespace
        stripped_parts: list[str] = []
        for i, part in enumerate(parts):
            stripped, was_stripped = self.strip_part(part)
            if i == 0:
                cleanup_start = was_stripped
            cleanup_end = was_stripped and not self.inside_quoted
            stripped_parts.append(stripped)

        # Merge this back to a single string
        text = "".join(stripped_parts)

        # Handle unicode escapes
        unescaped_text = UNICODE_ESCAPE.sub(self.decode_unicode_escapes, text)
        # Handle other escapes
        unescaped_text = CHAR_ESCAPE.sub(self.decode_escapes, unescaped_text)

        # Detect escaping at the start and end of a string (we actually care only for
        # whitespace escapes, but detecting more does not matter)
        if text and unescaped_text and text[0] != unescaped_text[0]:
            cleanup_start = False
        if text and unescaped_text and text[-1] != unescaped_text[-1]:
            cleanup_end = False

        return unescaped_text, cleanup_start, cleanup_end


class EncodingXMLParser(DecodingXMLParser):
    EMIT_DEPTH = 0
    FOREIGN_DTD = False

    def process_string(self, text: str) -> tuple[str, bool, bool]:  # ty:ignore[invalid-method-override]
        return (
            AndroidResourceUnit.escape(text, quote_wrapping_whitespaces=False),
            False,
            False,
        )

    def parse(self) -> str:
        self.emit_start = 0
        return super().parse()


class AndroidResourceUnit(base.TranslationUnit):
    """A single entry in the Android String resource file."""

    SINGULAR_TAG = "string"
    PLURAL_TAG = "plurals"
    ESCAPE_TRANSLATE: ClassVar = str.maketrans(
        {
            "\\": "\\\\",
            "\n": "\\n",
            "\t": "\\t",
            "'": "\\'",
            '"': '\\"',
        }
    )
    ESCAPE_ALL: ClassVar[bool] = True
    TARGET_MARKUP_PLAIN: ClassVar[str] = "plain"
    TARGET_MARKUP_ESCAPED: ClassVar[str] = "escaped-markup"
    TARGET_MARKUP_CDATA: ClassVar[str] = "cdata"
    TARGET_MARKUP_XML: ClassVar[str] = "xml-markup"

    TargetType = str | multistring | list[str] | None

    @classmethod
    def createfromxmlElement(cls, element: etree._Element):
        term = None
        # Actually this class supports only plurals and string tags
        if element.tag in {cls.PLURAL_TAG, cls.SINGULAR_TAG}:
            term = cls(None, xmlelement=element)
        return term

    def __init__(
        self,
        source,
        empty: bool = False,
        xmlelement: etree._Element | None = None,
        **kwargs,
    ) -> None:
        if xmlelement is not None:
            self.xmlelement = xmlelement
            self._target_markup_mode = self.TARGET_MARKUP_PLAIN
            self._init_target_markup_mode()
        elif self.hasplurals(source):
            self.xmlelement = etree.Element(self.PLURAL_TAG)
            self._target_markup_mode = self.TARGET_MARKUP_XML
        else:
            self.xmlelement = etree.Element(self.SINGULAR_TAG)
            self._target_markup_mode = self.TARGET_MARKUP_XML
        if source is not None:
            self.setid(source)
        super().__init__(source)

    @staticmethod
    def _contains_markup_text(text: str | None) -> bool:
        return bool(text) and bool(MARKUP_TAG.search(text))

    @staticmethod
    def _serialize_text(text: str) -> str:
        wrapper = etree.Element("wrapper")
        wrapper.text = text
        serialized = etree.tostring(wrapper, encoding="unicode", with_tail=False)
        return serialized.removeprefix("<wrapper>").removesuffix("</wrapper>")

    @staticmethod
    def _unescape_markup_tag(match: re.Match) -> str:
        return match.group(0).replace("&lt;", "<").replace("&gt;", ">")

    def _render_xml_context(self, xmltarget: etree._Element, target: str) -> str:
        probe_marker = "__translate_toolkit_markup_probe__"
        probe_target = copy.deepcopy(xmltarget)
        for child in list(probe_target):
            probe_target.remove(child)
        probe_target.text = probe_marker
        probe_target.tail = None

        source_document = self._store.document if self._store is not None else None
        if source_document is None:
            source_document = xmltarget.getroottree()

        source_root = source_document.getroot()
        if source_root is xmltarget:
            template = etree.tostring(probe_target, encoding="unicode", with_tail=False)
            return template.replace(probe_marker, target)

        cloned_doc = copy.deepcopy(source_document)
        cloned_root = cloned_doc.getroot()
        cloned_root.clear()
        cloned_root.text = " "
        cloned_root.append(probe_target)
        template = etree.tostring(
            cloned_doc,
            encoding="unicode",
            doctype=copy.copy(self._store.XMLdoctype)
            if self._store is not None
            else None,
        )
        return template.replace(probe_marker, target)

    def _get_markup_scope(self, xmltarget: etree._Element) -> dict[str | None, str]:
        source_document = self._store.document if self._store is not None else None
        if source_document is None:
            source_document = xmltarget.getroottree()
        source_root = source_document.getroot()
        scope = {
            prefix: namespace
            for prefix, namespace in source_root.nsmap.items()
            if namespace is not None
        }
        scope.update(
            {
                prefix: namespace
                for prefix, namespace in xmltarget.nsmap.items()
                if namespace is not None
            }
        )
        return scope

    @staticmethod
    def _get_markup_name(tag: str) -> str | None:
        match = XML_TAG_NAME.match(tag)
        if match is None:
            return None
        return match.group(1)

    @staticmethod
    def _update_markup_scope(
        scope: dict[str | None, str],
        tag: str,
    ) -> dict[str | None, str]:
        tag_scope = scope.copy()
        for attribute, value in XML_ATTRIBUTE_CAPTURE.findall(tag):
            namespace = value[1:-1]
            if attribute == "xmlns":
                tag_scope[None] = namespace
            elif attribute.startswith("xmlns:"):
                tag_scope[attribute.split(":", 1)[1]] = namespace
        return tag_scope

    @staticmethod
    def _is_resolved_markup_name(
        markup_name: str,
        scope: dict[str | None, str],
    ) -> bool:
        if ":" not in markup_name:
            return True
        prefix = markup_name.split(":", 1)[0]
        return prefix in scope

    def _has_escaped_markup_structure(
        self, xmltarget: etree._Element, text: str
    ) -> bool:
        scope_stack = [self._get_markup_scope(xmltarget)]
        open_tags: list[str] = []
        saw_tag = False

        for match in ESCAPED_MARKUP_TAG.finditer(text):
            tag = self._unescape_markup_tag(match)
            markup_name = self._get_markup_name(tag)
            if markup_name is None:
                return False

            if tag.startswith("</"):
                if not open_tags or open_tags[-1] != markup_name:
                    return False
                if not self._is_resolved_markup_name(markup_name, scope_stack[-1]):
                    return False
                open_tags.pop()
                scope_stack.pop()
                saw_tag = True
                continue

            tag_scope = self._update_markup_scope(scope_stack[-1], tag)
            if not self._is_resolved_markup_name(markup_name, tag_scope):
                return False

            saw_tag = True
            if tag.endswith("/>"):
                continue

            open_tags.append(markup_name)
            scope_stack.append(tag_scope)

        return saw_tag and not open_tags

    def _uses_plain_markup_roundtrip(self, xmltarget: etree._Element) -> bool:
        raw_xml = etree.tostring(xmltarget, encoding="unicode", with_tail=False)
        if len(xmltarget) != 0 or "<![CDATA[" in raw_xml:
            return False
        text = xmltarget.text
        if text is None or not self._contains_markup_text(text):
            return False
        serialized_text = self._serialize_text(text)
        parsed_text = ESCAPED_MARKUP_TAG.sub(self._unescape_markup_tag, serialized_text)
        if parsed_text == serialized_text:
            return False
        return self._has_escaped_markup_structure(xmltarget, serialized_text)

    @staticmethod
    def _has_cdata_text(xmltarget: etree._Element) -> bool:
        raw_xml = etree.tostring(xmltarget, encoding="unicode", with_tail=False)
        return len(xmltarget) == 0 and "<![CDATA[" in raw_xml

    @staticmethod
    def _extract_inner_xml(raw_xml: str) -> str:
        start = raw_xml.find(">")
        end = raw_xml.rfind("<")
        if start == -1 or end == -1 or end <= start:
            return ""
        return raw_xml[start + 1 : end]

    @classmethod
    def _has_only_cdata_sections(cls, raw_xml: str) -> bool:
        return bool(CDATA_ONLY.fullmatch(cls._extract_inner_xml(raw_xml)))

    @staticmethod
    def _has_literal_angle_text(xmltarget: etree._Element) -> bool:
        raw_xml = etree.tostring(xmltarget, encoding="unicode", with_tail=False)
        return (
            len(xmltarget) == 0
            and "<![CDATA[" not in raw_xml
            and xmltarget.text is not None
            and "<" in xmltarget.text
            and ">" in xmltarget.text
        )

    def _get_markup_targets(self) -> list[etree._Element]:
        if self.xmlelement.tag == self.PLURAL_TAG:
            return list(self.xmlelement.iterchildren("item"))
        return [self.xmlelement]

    def _has_xml_markup(self) -> bool:
        return any(len(xmltarget) != 0 for xmltarget in self._get_markup_targets())

    def _init_target_markup_mode(self) -> None:
        if self._has_xml_markup():
            self._target_markup_mode = self.TARGET_MARKUP_XML
        elif any(
            self._has_cdata_text(xmltarget) for xmltarget in self._get_markup_targets()
        ):
            self._target_markup_mode = self.TARGET_MARKUP_CDATA
        elif any(
            self._uses_plain_markup_roundtrip(xmltarget)
            for xmltarget in self._get_markup_targets()
        ):
            self._target_markup_mode = self.TARGET_MARKUP_ESCAPED
        elif any(
            self._has_literal_angle_text(xmltarget)
            for xmltarget in self._get_markup_targets()
        ):
            self._target_markup_mode = self.TARGET_MARKUP_PLAIN
        else:
            self._target_markup_mode = self.TARGET_MARKUP_XML

    def _get_target_markup_mode(self) -> str:
        return self._target_markup_mode

    def _set_target_markup_mode(self, target_markup_mode: str) -> None:
        if target_markup_mode not in {
            self.TARGET_MARKUP_PLAIN,
            self.TARGET_MARKUP_ESCAPED,
            self.TARGET_MARKUP_CDATA,
            self.TARGET_MARKUP_XML,
        }:
            msg = f"Unsupported target markup mode: {target_markup_mode}"
            raise ValueError(msg)
        self._target_markup_mode = target_markup_mode

    def gettargetmarkupmode(self) -> str:
        return self._get_target_markup_mode()

    def settargetmarkupmode(self, target_markup_mode: str) -> None:
        self._set_target_markup_mode(target_markup_mode)

    target_markup_mode = property(gettargetmarkupmode, settargetmarkupmode)

    def gettargetmarkup(self) -> bool:
        warnings.warn(
            "target_markup is deprecated; use target_markup_mode instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        return self._get_target_markup_mode() == self.TARGET_MARKUP_ESCAPED

    def settargetmarkup(self, preserve_markup: bool) -> None:
        warnings.warn(
            "target_markup is deprecated; use target_markup_mode instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        self._set_target_markup_mode(
            self.TARGET_MARKUP_ESCAPED if preserve_markup else self.TARGET_MARKUP_XML
        )

    target_markup = property(gettargetmarkup, settargetmarkup)

    def istranslatable(self):
        return bool(self.getid()) and self.xmlelement.get("translatable") != "false"

    def marktranslatable(self, value: bool) -> None:
        return self.xmlelement.set("translatable", "true" if value else "false")

    def isblank(self) -> bool:
        return not bool(self.getid())

    def getid(self):
        return self.xmlelement.get("name")

    def setid(self, newid):  # ty:ignore[invalid-method-override]
        return self.xmlelement.set("name", newid)

    def getcontext(self):
        return self.xmlelement.get("name")

    @staticmethod
    def xml_escape_space(matchobj):
        return matchobj.group(0).replace("  ", r" \u0020")

    @overload
    @classmethod
    def escape(cls, text: str, quote_wrapping_whitespaces: bool = True) -> str: ...
    @overload
    @classmethod
    def escape(cls, text: None, quote_wrapping_whitespaces: bool = True) -> None: ...
    @classmethod
    def escape(cls, text, quote_wrapping_whitespaces=True):
        """
        Escape all the characters which need to be escaped in an Android XML
        file.

        :param text: Text to escape
        :param quote_wrapping_whitespaces: If True, heading and trailing
               whitespaces will be quoted placing the entire resulting text in
               double quotes.
        """
        if text is None:
            return None
        if len(text) == 0:
            return ""

        # Escape XML chars and whitespace
        text = text.translate(cls.ESCAPE_TRANSLATE)

        # @ and ? needs to be escaped at start as this would be interpreted
        # as string/style references
        if text.startswith(("@", "?")):
            text = f"\\{text}"

        # Quote strings with more whitespace
        multispace = MULTIWHITESPACE.search(text)
        if quote_wrapping_whitespaces and (
            text[0] in WHITESPACE or text[-1] in WHITESPACE or multispace
        ):
            return f'"{text}"'
        # In xml multispace
        if not quote_wrapping_whitespaces and multispace:
            return MULTIWHITESPACE.sub(cls.xml_escape_space, text)

        return text

    @base.TranslationUnit.source.getter
    def source(self):
        if super().source is None:
            return self.target
        return super().source

    def get_xml_text_value(self, xmltarget: etree._Element) -> str:
        raw_xml = etree.tostring(xmltarget, encoding="unicode", with_tail=False)
        # Use decoding parser to keep XML entities, CDATA while processing Android escaping
        parser = DecodingXMLParser(raw_xml, escape_all=self.ESCAPE_ALL)

        # Unescape as plain text in case there is no XML markup. Ufortunately
        # CDATA elements are not listed as children in lxml, so test for it in raw XML.
        if len(xmltarget) == 0:
            if "<![CDATA[" not in raw_xml:
                if xmltarget.text is None:
                    return ""
                text, cleanup_start, cleanup_end = parser.process_string(xmltarget.text)
                if cleanup_start:
                    text = text.lstrip()
                if cleanup_end:
                    text = text.rstrip()
                return text
            if self._has_only_cdata_sections(raw_xml):
                text = xmltarget.text or ""
                if self._contains_markup_text(text):
                    unescaped = UNICODE_ESCAPE.sub(parser.decode_unicode_escapes, text)
                    return CHAR_ESCAPE.sub(parser.decode_escapes, unescaped)
                text, cleanup_start, cleanup_end = parser.process_string(text)
                if cleanup_start:
                    text = text.lstrip()
                if cleanup_end:
                    text = text.rstrip()
                return text

        return parser.parse()

    def set_xml_text_plain(
        self,
        target: str | None,
        xmltarget: etree._Element,
        *,
        target_markup_mode: str = TARGET_MARKUP_PLAIN,
    ) -> None:
        # Remove possible old elements
        for x in xmltarget.iterchildren():
            xmltarget.remove(x)
        # Handle text only
        xmltarget.text = self.escape(target)
        self._set_target_markup_mode(target_markup_mode)

    def _parse_target_xml(
        self,
        xmltarget: etree._Element,
        target: str,
        *,
        escape_text_parts: bool = True,
    ) -> etree._Element | None:
        parser = get_safe_xml_parser(strip_cdata=False)
        newstring = self._render_xml_context(xmltarget, target)
        try:
            etree.fromstring(newstring, parser)
        except Exception:
            return None
        if escape_text_parts:
            newstring = EncodingXMLParser(newstring).parse()
        parsed_root = etree.fromstring(newstring, parser)
        return parsed_root if parsed_root.tag == xmltarget.tag else parsed_root[0]

    def _replace_xml_target(
        self,
        xmltarget: etree._Element,
        newtree: etree._Element,
    ) -> None:
        # Copy existing attributes
        for attribute, value in xmltarget.items():
            newtree.attrib[attribute] = value
        parent = xmltarget.getparent()
        # Parent can be none when operating on Unit only without a storage
        if parent is not None:
            parent.replace(xmltarget, newtree)
        # Update unit xmlelement if needed (plurals are inner elements here)
        if xmltarget == self.xmlelement:
            self.xmlelement = newtree

    def set_xml_text_cdata(self, target: str | None, xmltarget: etree._Element) -> None:
        if target is not None and "<![CDATA[" in target:
            newtree = self._parse_target_xml(
                xmltarget,
                target,
                escape_text_parts=True,
            )
            if newtree is not None and len(newtree) == 0:
                self._replace_xml_target(xmltarget, newtree)
                self._set_target_markup_mode(self.TARGET_MARKUP_CDATA)
                return

        # Remove possible old elements
        for x in xmltarget.iterchildren():
            xmltarget.remove(x)
        # Handle text only
        if target is None:
            xmltarget.text = None
        else:
            newtree = self._parse_target_xml(xmltarget, target)
            cdata_text = (
                self._extract_inner_xml(
                    etree.tostring(newtree, encoding="unicode", with_tail=False)
                )
                if newtree is not None
                else self.escape(target)
            )
            xmltarget.text = etree.CDATA(cdata_text)
        self._set_target_markup_mode(self.TARGET_MARKUP_CDATA)

    def set_xml_text_value(
        self,
        target: str | None,
        xmltarget: etree._Element,
        target_markup_mode: str | None = None,
    ) -> str:
        if target_markup_mode is None:
            target_markup_mode = self._get_target_markup_mode()
        if target is None:
            resulting_mode = (
                target_markup_mode
                if target_markup_mode
                in {self.TARGET_MARKUP_ESCAPED, self.TARGET_MARKUP_CDATA}
                else self.TARGET_MARKUP_PLAIN
            )
            if resulting_mode == self.TARGET_MARKUP_CDATA:
                self.set_xml_text_cdata(target, xmltarget)
            else:
                self.set_xml_text_plain(
                    target,
                    xmltarget,
                    target_markup_mode=resulting_mode,
                )
            return resulting_mode
        if target_markup_mode == self.TARGET_MARKUP_CDATA:
            self.set_xml_text_cdata(target, xmltarget)
            return target_markup_mode
        if target_markup_mode != self.TARGET_MARKUP_XML:
            self.set_xml_text_plain(
                target,
                xmltarget,
                target_markup_mode=target_markup_mode,
            )
            return target_markup_mode

        if "<" in target or "&" in target:
            # Try to handle it as legacy XML
            newtree = self._parse_target_xml(xmltarget, target)
            if newtree is None:
                self.set_xml_text_plain(
                    target,
                    xmltarget,
                    target_markup_mode=self.TARGET_MARKUP_PLAIN,
                )
                return self.TARGET_MARKUP_PLAIN
            self._replace_xml_target(xmltarget, newtree)
            resulting_mode = (
                self.TARGET_MARKUP_XML
                if len(newtree) != 0 or "<![CDATA[" in target
                else self.TARGET_MARKUP_PLAIN
            )
            self._set_target_markup_mode(resulting_mode)
            return resulting_mode
        self.set_xml_text_plain(
            target,
            xmltarget,
            target_markup_mode=self.TARGET_MARKUP_PLAIN,
        )
        return self.TARGET_MARKUP_PLAIN

    def gettargetlanguage(self):
        if self._store is None:
            return "en"
        return super().gettargetlanguage()

    @property
    def target(self):
        if self.xmlelement.tag != self.PLURAL_TAG:
            return self.get_xml_text_value(self.xmlelement)
        plurals = {
            entry.get("quantity"): self.get_xml_text_value(entry)
            for entry in self.xmlelement.iterchildren("item")
        }
        plural_tags = (
            self._store.get_plural_tags() if self._store else data.plural_tags["en"]
        )
        return multistring([plurals.get(tag, "") for tag in plural_tags])

    def fixup_tag(self, expected: str) -> None:
        # Fix the root tag if mismatching
        if self.xmlelement.tag != expected:
            old_id = self.getid()
            newelement = etree.Element(expected)
            parent = self.xmlelement.getparent()
            if parent is not None:
                parent.replace(self.xmlelement, newelement)
            self.xmlelement = newelement
            self.setid(old_id)

    @target.setter
    def target(self, target: TargetType) -> None:
        if self.hasplurals(self.source) or self.hasplurals(target):
            # Fix the root tag if mismatching
            self.fixup_tag(self.PLURAL_TAG)
            target_markup_mode = self._get_target_markup_mode()
            resulting_mode = (
                self.TARGET_MARKUP_PLAIN
                if target_markup_mode == self.TARGET_MARKUP_XML
                else target_markup_mode
            )

            # Sync plural_strings elements to plural_tags count.
            plural_target: str | multistring | list[str] = (
                "" if target is None else target
            )
            plural_tags = self._store.get_plural_tags(plural_target)  # ty:ignore[unresolved-attribute]
            plural_strings = self.sync_plural_count(plural_target, plural_tags)

            # Rebuild plurals.
            for entry in self.xmlelement.iterchildren():
                self.xmlelement.remove(entry)

            self.xmlelement.text = "\n    "

            # Include additional plural for decimal numbers if not present. This is
            # enforced by Android lint but translate-toolkit currently does not support
            # editing this.
            locale = self._store.get_base_locale_code()  # ty:ignore[unresolved-attribute]
            if locale in data.DECIMAL_EXTRA_TAGS:
                for extra in data.DECIMAL_EXTRA_TAGS[locale]:
                    if extra not in plural_tags:
                        # Create copy here to avoid modifications to language.data
                        plural_tags = [*plural_tags, extra]
                        plural_strings.append(plural_strings[-1])

            # Include "other" as copy of "many" if "other" is not present. This avoids crashes
            # of Android builds with broken plurals handling.
            if "other" not in plural_tags and "many" in plural_tags:
                # Create copy here to avoid modifications to language.data
                plural_tags = [*plural_tags, "other"]
                plural_strings.append(plural_strings[-1])

            for plural_tag, plural_string in sorted(
                zip(plural_tags, plural_strings, strict=True),
                key=lambda item: data.cldr_plural_categories.index(item[0]),
            ):
                if not plural_string:
                    continue
                item = etree.Element("item")
                item.set("quantity", plural_tag)
                self.xmlelement.append(item)
                item_markup_mode = self.set_xml_text_value(
                    plural_string,
                    item,
                    target_markup_mode=target_markup_mode,
                )
                if (
                    target_markup_mode == self.TARGET_MARKUP_XML
                    and item_markup_mode == self.TARGET_MARKUP_XML
                ):
                    resulting_mode = self.TARGET_MARKUP_XML
            if target_markup_mode == self.TARGET_MARKUP_XML:
                self._set_target_markup_mode(resulting_mode)
            else:
                self._set_target_markup_mode(target_markup_mode)
        else:
            # Fix the root tag if mismatching
            self.fixup_tag(self.SINGULAR_TAG)

            self.set_xml_text_value(cast("str | None", target), self.xmlelement)

        self._rich_target = None
        self._target = target

    def getlanguageNode(self, lang=None, index=None):
        return self.xmlelement

    # Notes are handled as previous sibling comments.
    def addnote(self, text, origin=None, position="append") -> None:
        if origin in {"programmer", "developer", "source code", None}:
            self.xmlelement.addprevious(etree.Comment(text))
        else:
            super().addnote(text, origin=origin, position=position)

    def getnotes(self, origin=None):
        if origin in {"programmer", "developer", "source code", None}:
            comments = []
            if self.xmlelement is not None:
                prevSibling = self.xmlelement.getprevious()
                while (prevSibling is not None) and (prevSibling.tag is etree.Comment):
                    comments.insert(0, prevSibling.text)
                    prevSibling = prevSibling.getprevious()

            return "\n".join(comments)
        return super().getnotes(origin)

    def removenotes(self, origin=None) -> None:
        if (self.xmlelement is not None) and (self.xmlelement.getparent is not None):
            prevSibling = self.xmlelement.getprevious()
            while (prevSibling is not None) and (prevSibling.tag is etree.Comment):
                prevSibling.getparent().remove(prevSibling)
                prevSibling = self.xmlelement.getprevious()

        super().removenotes()

    def __str__(self) -> str:
        return etree.tostring(self.xmlelement, pretty_print=True, encoding="unicode")

    def __eq__(self, other):
        return str(self) == str(other)

    @staticmethod
    def hasplurals(thing):
        return isinstance(thing, (multistring, list))


class AndroidResourceFile(lisa.LISAfile):
    """Class representing an Android String resource file store."""

    UnitClass = AndroidResourceUnit
    Name = "Android String Resource"
    Mimetypes = ["application/xml"]
    Extensions = ["xml"]
    rootNode = "resources"
    bodyNode = "resources"
    XMLskeleton = """<?xml version="1.0" encoding="utf-8"?>
<resources></resources>"""
    XMLindent = {"indent": "    ", "leaves": {"string", "item"}, "trailing_eol": True}
    XMLuppercaseEncoding = False

    def initbody(self) -> None:
        """
        Initialises self.body so it never needs to be retrieved from the XML
        again.
        """
        self.namespace = self.document.getroot().nsmap.get(None, None)
        self.body = self.document.getroot()

    def parse(self, xml) -> None:
        """Populates this object from the given xml string."""
        if not hasattr(self, "filename"):
            self.filename = getattr(xml, "name", "")
        if hasattr(xml, "read"):
            xml.seek(0)
            posrc = xml.read()
            xml = posrc
        parser = get_safe_xml_parser(strip_cdata=False)
        self.document = etree.fromstring(xml, parser).getroottree()
        self._encoding = self.document.docinfo.encoding
        self.initbody()
        assert self.document.getroot().tag == self.namespaced(self.rootNode)

        for entry in self.document.getroot().iterchildren():
            term = self.UnitClass.createfromxmlElement(entry)
            if term is not None:
                self.addunit(term, new=False)

    def gettargetlanguage(self):
        target_lang = super().gettargetlanguage()

        # If targetlanguage isn't set, we try to extract it from the filename path (if any).
        if target_lang is None and hasattr(self, "filename") and self.filename:
            # Android standards expect resource files to be in a directory named "values[-<lang>[-r<region>]]".
            parent_dir = os.path.split(os.path.dirname(self.filename))[1]
            match = re.search(r"^values-(\w*)", parent_dir)
            if match is not None:
                target_lang = match.group(1)
            elif parent_dir == "values":
                # If the resource file is inside the "values" directory, then it is the default/source language.
                target_lang = self.sourcelanguage

            # Cache it
            self.settargetlanguage(target_lang)

        return target_lang

    def addunit(self, unit, new=True) -> None:
        """
        Adds unit to the document.

        In addition to the standard addunit, it also tries to move
        namespace definitions to the top <resources> element.
        """
        newns = {}
        do_cleanup = False
        if new:
            # Include any possible new namespaces
            newns = self.body.nsmap  # ty:ignore[unresolved-attribute]
            for ns in unit.xmlelement.nsmap:
                if ns not in newns:
                    do_cleanup = True
                    newns[ns] = unit.xmlelement.nsmap[ns]

            # Detect if unit is using XML entities
            hasentity = False
            for xmlelement in unit.xmlelement.iterdescendants():
                if xmlelement.tag is etree.Entity:
                    hasentity = True
                    break

            # Copy doctype to include possibly new entities
            if hasentity:
                cloned_doc = copy.deepcopy(unit._store.document)
                cloned_doc.getroot().clear()
                self.XMLdoctype = etree.tostring(
                    cloned_doc, xml_declaration=False, encoding="unicode"
                ).rsplit("\n", 1)[0]  # ty:ignore[invalid-argument-type]

        super().addunit(unit, new)
        # Move aliased namespaces to the <resources> tag
        # The top_nsmap was introduced in LXML 3.5.0
        if do_cleanup:
            etree.cleanup_namespaces(self.body, top_nsmap=newns)  # ty:ignore[invalid-argument-type]

    def removeunit(self, unit) -> None:
        unit.removenotes()
        super().removeunit(unit)


class CMPResourceUnit(AndroidResourceUnit):
    # Only \n and \t are escaped here (+ Unicode escape sequences), see
    # https://github.com/JetBrains/compose-multiplatform/blob/754dc253964e5aeab63581ed2a6a4ac376af1c32/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/PrepareComposeResources.kt#L270-L298
    ESCAPE_TRANSLATE = str.maketrans(
        {
            "\\": "\\\\",
            "\n": "\\n",
            "\t": "\\t",
        }
    )
    ESCAPE_ALL: ClassVar[bool] = False


class CMPResourceFile(AndroidResourceFile):
    UnitClass = CMPResourceUnit
    Name = "Compose Multiplatform Resources"


class MOKOResourceUnit(CMPResourceUnit):
    PLURAL_TAG = "plural"


class MOKOResourceFile(AndroidResourceFile):
    UnitClass = MOKOResourceUnit
    Name = "Mobile Kotlin Resources"
