#
# 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 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/>.

"""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 xml.parsers.expat import XML_PARAM_ENTITY_PARSING_NEVER, ParserCreate

from lxml import etree

from translate.lang import data
from translate.misc.multistring import multistring
from translate.misc.xml_helpers import 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:
    """
    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
    """

    EMIT_DEPTH = 1
    FOREIGN_DTD = True

    def __init__(self, text: str, *, escape_all: bool = True) -> None:
        self.text = text.encode("utf-8")
        self.output: list[str] = []
        self.emit_start: int | None = None
        self.character_data = False
        self.raw_string = True
        self.in_string = False
        self.inside_quoted = False
        self.do_cleanup = False
        self.depth = 0
        self.escape_all = escape_all
        self.parser = parser = ParserCreate()
        if self.FOREIGN_DTD:
            parser.UseForeignDTD(self.FOREIGN_DTD)
        parser.SetParamEntityParsing(XML_PARAM_ENTITY_PARSING_NEVER)
        parser.StartElementHandler = self.StartElementHandler
        parser.EndElementHandler = self.EndElementHandler
        parser.CharacterDataHandler = self.CharacterDataHandler
        parser.StartCdataSectionHandler = self.StartCdataSectionHandler
        parser.EndCdataSectionHandler = self.EndCdataSectionHandler
        parser.DefaultHandler = self.DefaultHandler

    @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

    def cleanup_text(self, text: str) -> str:
        """Remove leading whitespace from text."""
        if not self.output:
            return text.lstrip()
        return text

    def emit(self) -> None:
        if self.emit_start is not None:
            text = self.text[self.emit_start : self.parser.CurrentByteIndex].decode(
                "utf-8"
            )
            self.do_cleanup = not self.raw_string
            if not self.raw_string:
                text, cleanup_start, self.do_cleanup = self.process_string(text)
                if cleanup_start:
                    text = self.cleanup_text(text)
            self.output.append(text)
            self.emit_start = None
        self.character_data = False
        self.raw_string = True
        self.in_string = False

    def StartElementHandler(self, _name, _attributes) -> None:
        self.emit()
        if self.depth >= self.EMIT_DEPTH:
            self.emit_start = self.parser.CurrentByteIndex
        self.depth += 1

    def EndElementHandler(self, _name) -> None:
        self.emit()
        if self.depth >= self.EMIT_DEPTH:
            self.emit_start = self.parser.CurrentByteIndex
        self.depth -= 1

    def CharacterDataHandler(self, _data) -> None:
        if not self.character_data and not self.in_string:
            self.emit()
            self.emit_start = self.parser.CurrentByteIndex
            self.raw_string = False
            self.in_string = True

    def StartCdataSectionHandler(self) -> None:
        self.emit()
        self.character_data = True
        self.emit_start = self.parser.CurrentByteIndex

    def EndCdataSectionHandler(self) -> None:
        self.character_data = False

    def DefaultHandler(self, _data) -> None:
        if self.depth >= self.EMIT_DEPTH:
            self.emit()
            self.emit_start = self.parser.CurrentByteIndex

    def parse(self) -> str:
        self.parser.Parse(self.text, True)
        if self.depth >= self.EMIT_DEPTH:
            self.emit()
        # Remove trailing whitespace from text
        if self.do_cleanup and self.output:
            self.output[-1] = self.output[-1].rstrip()
        return "".join(self.output)


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"
