"""
Provide an API to load `Gramps <https://gramps-project.org>`_ family trees into Betty ancestries.
"""

from __future__ import annotations

import gzip
import re
import tarfile
from collections import defaultdict
from contextlib import suppress, ExitStack
from dataclasses import dataclass
from enum import Enum
from logging import getLogger
from pathlib import Path
from typing import Iterable, cast, TYPE_CHECKING, TypeVar, Generic, final
from xml.etree import ElementTree

import aiofiles
from aiofiles.tempfile import TemporaryDirectory
from betty.ancestry.citation import Citation
from betty.ancestry.enclosure import Enclosure
from betty.ancestry.event import Event
from betty.ancestry.event_type.event_types import Unknown as UnknownEventType
from betty.ancestry.file import File
from betty.ancestry.file_reference import FileReference
from betty.ancestry.gender.genders import Unknown as UnknownGender
from betty.ancestry.link import HasLinks, Link
from betty.ancestry.name import Name
from betty.ancestry.note import Note
from betty.ancestry.person import Person
from betty.ancestry.person_name import PersonName
from betty.ancestry.place import Place
from betty.ancestry.place_type.place_types import Unknown as UnknownPlaceType
from betty.ancestry.presence import Presence
from betty.ancestry.presence_role.presence_roles import Unknown as UnknownPresenceRole
from betty.ancestry.source import Source
from betty.asyncio import ensure_await
from betty.date import DateRange, Datey, Date
from betty.error import FileNotFound
from betty.gramps.error import GrampsError, UserFacingGrampsError
from betty.locale import UNDETERMINED_LOCALE
from betty.locale.localizable import _, plain
from betty.media_type import MediaType, InvalidMediaType
from betty.model import Entity
from betty.model.association import ToManyResolver, ToOneResolver, resolve
from betty.plugin import PluginNotFound
from betty.privacy import HasPrivacy
from betty.typing import internal
from geopy import Point
from lxml import etree
from typing_extensions import override

if TYPE_CHECKING:
    from betty.copyright_notice import CopyrightNotice
    from betty.license import License
    from betty.plugin import PluginRepository
    from betty.ancestry import Ancestry
    from betty.ancestry.has_notes import HasNotes
    from betty.ancestry.has_citations import HasCitations
    from betty.ancestry.has_file_references import HasFileReferences
    from betty.ancestry.event_type import EventType
    from betty.ancestry.place_type import PlaceType
    from betty.ancestry.presence_role import PresenceRole
    from betty.ancestry.gender import Gender
    from betty.factory import Factory
    from betty.locale.localizer import Localizer
    from collections.abc import MutableMapping, Mapping, Sequence, Awaitable, Callable

_EntityT = TypeVar("_EntityT", bound=Entity)


class LoaderUsedAlready(GrampsError):
    """
    Raised when a :py:class:`betty.gramps.loader.GrampsLoader` is used more than once.
    """

    pass  # pragma: no cover


class GrampsFileNotFound(UserFacingGrampsError, FileNotFound):
    """
    Raised when a Gramps family tree file cannot be found.
    """

    pass  # pragma: no cover


class XPathError(GrampsError):
    """
    An error occurred when evaluating an XPath selector on Gramps XML.
    """

    pass  # pragma: no cover


class GrampsEntityType(Enum):
    """
    The supported Gramps entity types.
    """

    CITATION = "citation"
    EVENT = "event"
    OBJECT = "object"
    PERSON = "person"
    SOURCE = "source"


@final
@dataclass(frozen=True)
class GrampsEntityReference:
    """
    A reference to an entity in a Gramps family tree.
    """

    entity_type: GrampsEntityType
    entity_id: str

    @override
    def __str__(self) -> str:
        return f"{self.entity_type.value} ({self.entity_id})"


class _ToOneResolver(Generic[_EntityT], ToOneResolver[_EntityT]):
    def __init__(self, handles_to_entities: Mapping[str, Entity], handle: str):
        self._handles_to_entities = handles_to_entities
        self._handle = handle

    @override
    def resolve(self) -> _EntityT:
        return cast(_EntityT, self._handles_to_entities[self._handle])


class _ToManyResolver(Generic[_EntityT], ToManyResolver[_EntityT]):
    def __init__(self, handles_to_entities: Mapping[str, Entity], *handles: str):
        self._handles_to_entities = handles_to_entities
        self._handles = handles

    @override
    def resolve(self) -> Iterable[_EntityT]:
        for handle in self._handles:
            yield cast(_EntityT, self._handles_to_entities[handle])


@internal
class GrampsLoader:
    """
    Load Gramps family history data into a project.
    """

    def __init__(
        self,
        ancestry: Ancestry,
        *,
        factory: Factory,
        localizer: Localizer,
        copyright_notices: PluginRepository[CopyrightNotice],
        licenses: PluginRepository[License],
        attribute_prefix_key: str | None = None,
        event_type_mapping: Mapping[str, Callable[[], EventType | Awaitable[EventType]]]
        | None = None,
        gender_mapping: Mapping[str, Callable[[], Gender | Awaitable[Gender]]]
        | None = None,
        place_type_mapping: Mapping[str, Callable[[], PlaceType | Awaitable[PlaceType]]]
        | None = None,
        presence_role_mapping: Mapping[
            str, Callable[[], PresenceRole | Awaitable[PresenceRole]]
        ]
        | None = None,
    ):
        super().__init__()
        self._ancestry = ancestry
        self._handles_to_entities: MutableMapping[str, Entity] = {}
        self._factory = factory
        self._attribute_prefix_key = attribute_prefix_key
        self._added_entity_counts: MutableMapping[type[Entity], int] = defaultdict(
            lambda: 0
        )
        self._tree: ElementTree.ElementTree | None = None
        self._loaded = False
        self._localizer = localizer
        self._copyright_notices = copyright_notices
        self._licenses = licenses
        self._event_type_mapping = event_type_mapping or {}
        self._gender_mapping = gender_mapping or {}
        self._place_type_mapping = place_type_mapping or {}
        self._presence_role_mapping = presence_role_mapping or {}

    async def load_file(self, file_path: Path) -> None:
        """
        Load family history data from any of the supported Gramps file types.

        :raises betty.gramps.error.GrampsError:
        """
        file_path = file_path.resolve()
        logger = getLogger(__name__)
        logger.info(
            self._localizer._('Loading "{file_path}"...').format(
                file_path=str(file_path),
            )
        )

        if file_path.suffix == ".gpkg":
            return await self.load_gpkg(file_path)
        if file_path.suffix == ".gramps":
            return await self.load_gramps(file_path)
        if file_path.suffix == ".xml":
            try:
                async with aiofiles.open(file_path, mode="r") as f:
                    xml = await f.read()
            except FileNotFoundError:
                raise GrampsFileNotFound.new(file_path) from None
            return await self.load_xml(xml)

        raise UserFacingGrampsError(
            _(
                'Could not load "{file_path}" as a *.gpkg, a *.gramps, or an *.xml family tree.'
            ).format(file_path=str(file_path))
        )

    async def load_gramps(self, gramps_path: Path) -> None:
        """
        Load family history data from a Gramps *.gramps file.

        :raises betty.gramps.error.GrampsError:
        """
        gramps_path = gramps_path.resolve()
        try:
            with gzip.open(gramps_path) as f:
                xml = f.read()
            await self._load_xml(xml)
        except FileNotFoundError:
            raise GrampsFileNotFound.new(gramps_path) from None
        except OSError as error:
            raise UserFacingGrampsError(
                _("Could not extract {file_path} as a gzip file  (*.gz).").format(
                    file_path=str(gramps_path)
                )
            ) from error

    async def load_gpkg(self, gpkg_path: Path) -> None:
        """
        Load family history data from a Gramps *.gpkg file.

        :raises betty.gramps.error.GrampsError:
        """
        gpkg_path = gpkg_path.resolve()
        with ExitStack() as stack:
            try:
                tar_file = stack.enter_context(
                    tarfile.open(  # noqa SIM115
                        name=gpkg_path, mode="r:gz"
                    )
                )
            except FileNotFoundError:
                raise GrampsFileNotFound.new(gpkg_path) from None
            except (OSError, tarfile.ReadError) as error:
                raise UserFacingGrampsError(
                    _(
                        "Could not extract {file_path} as a gzipped tar file  (*.tar.gz)."
                    ).format(file_path=str(gpkg_path))
                ) from error
            async with TemporaryDirectory() as cache_directory_path_str:
                tar_file.extractall(cache_directory_path_str, filter="data")
                await self.load_gramps(Path(cache_directory_path_str) / "data.gramps")

    async def load_xml(self, xml: str) -> None:
        """
        Load family history data from XML.

        :raises betty.gramps.error.GrampsError:
        """
        await self._load_xml(xml.encode("utf-8"))

    async def _load_xml(self, xml: bytes) -> None:
        try:
            tree = cast(  # type: ignore[bad-cast]
                ElementTree.ElementTree, etree.ElementTree(etree.fromstring(xml))
            )
        except etree.ParseError as error:
            raise UserFacingGrampsError(plain(str(error))) from error
        await self._load_tree(tree)

    async def _load_tree(self, tree: ElementTree.ElementTree) -> None:
        """
        Load family history data from a Gramps XML tree.
        """
        if self._loaded:
            raise LoaderUsedAlready("This loader has been used up.")

        self._loaded = True
        self._tree = tree

        logger = getLogger(__name__)

        database = self._tree.getroot()

        media_path: Path | None = None
        try:
            mediapath = self._xpath1(database, "./ns:header/ns:mediapath")
        except XPathError:
            pass
        else:
            if mediapath.text is not None:
                media_path = Path(mediapath.text).resolve()

        with self._ancestry.unchecked():
            await self._load_notes(database)
            logger.info(
                self._localizer._("Loaded {note_count} notes.").format(
                    note_count=self._added_entity_counts[Note]
                )
            )
            await self._load_objects(database, media_path)
            logger.info(
                self._localizer._("Loaded {file_count} files.").format(
                    file_count=self._added_entity_counts[File]
                )
            )

            await self._load_repositories(database)
            repository_count = self._added_entity_counts[Source]
            logger.info(
                self._localizer._(
                    "Loaded {repository_count} repositories as sources."
                ).format(repository_count=repository_count)
            )

            await self._load_sources(database)
            logger.info(
                self._localizer._("Loaded {source_count} sources.").format(
                    source_count=self._added_entity_counts[Source] - repository_count
                )
            )

            await self._load_citations(database)
            logger.info(
                self._localizer._("Loaded {citation_count} citations.").format(
                    citation_count=self._added_entity_counts[Citation]
                )
            )

            await self._load_places(database)
            logger.info(
                self._localizer._("Loaded {place_count} places.").format(
                    place_count=self._added_entity_counts[Place]
                )
            )

            await self._load_events(database)
            logger.info(
                self._localizer._("Loaded {event_count} events.").format(
                    event_count=self._added_entity_counts[Event]
                )
            )

            await self._load_people(database)
            logger.info(
                self._localizer._("Loaded {person_count} people.").format(
                    person_count=self._added_entity_counts[Person]
                )
            )

            await self._load_families(database)

        resolve(*self._ancestry)

    def _resolve1(
        self, entity_type: type[_EntityT], handle: str
    ) -> _ToOneResolver[_EntityT]:
        return _ToOneResolver(self._handles_to_entities, handle)

    def _resolve(
        self, entity_type: type[_EntityT], *handles: str
    ) -> _ToManyResolver[_EntityT]:
        return _ToManyResolver(self._handles_to_entities, *handles)

    def _add_entity(self, entity: Entity, handle: str | None = None) -> None:
        self._ancestry.add(entity)
        if handle is not None:
            self._handles_to_entities[handle] = entity
        self._added_entity_counts[entity.type] += 1

    _NS = {
        "ns": "http://gramps-project.org/xml/1.7.1/",
    }

    def _xpath(
        self, element: ElementTree.Element, selector: str
    ) -> Sequence[ElementTree.Element]:
        return element.findall(selector, namespaces=self._NS)

    def _xpath1(
        self, element: ElementTree.Element, selector: str
    ) -> ElementTree.Element:
        found_element = element.find(selector, namespaces=self._NS)
        if found_element is None:
            raise XPathError(
                f'Cannot find an element "{selector}" within {str(element)}.'
            )
        return found_element

    _DATE_PATTERN = re.compile(r"^.{4}((-.{2})?-.{2})?$")
    _DATE_PART_PATTERN = re.compile(r"^\d+$")

    def _load_date(self, element: ElementTree.Element) -> Datey | None:
        with suppress(XPathError):
            dateval_element = self._xpath1(element, "./ns:dateval")
            if dateval_element.get("cformat") is None:
                dateval_type = dateval_element.get("type")
                if dateval_type is None:
                    return self._load_dateval(dateval_element, "val")
                dateval_type = str(dateval_type)
                if dateval_type == "about":
                    date = self._load_dateval(dateval_element, "val")
                    if date is None:
                        return None
                    date.fuzzy = True
                    return date
                if dateval_type == "before":
                    return DateRange(
                        None,
                        self._load_dateval(dateval_element, "val"),
                        end_is_boundary=True,
                    )
                if dateval_type == "after":
                    return DateRange(
                        self._load_dateval(dateval_element, "val"),
                        start_is_boundary=True,
                    )
        with suppress(XPathError):
            datespan_element = self._xpath1(element, "./ns:datespan")
            if datespan_element.get("cformat") is None:
                return DateRange(
                    self._load_dateval(datespan_element, "start"),
                    self._load_dateval(datespan_element, "stop"),
                )
        with suppress(XPathError):
            daterange_element = self._xpath1(element, "./ns:daterange")
            if daterange_element.get("cformat") is None:
                return DateRange(
                    self._load_dateval(daterange_element, "start"),
                    self._load_dateval(daterange_element, "stop"),
                    start_is_boundary=True,
                    end_is_boundary=True,
                )
        return None

    def _load_dateval(
        self, element: ElementTree.Element, value_attribute_name: str
    ) -> Date | None:
        dateval = str(element.get(value_attribute_name))
        if self._DATE_PATTERN.fullmatch(dateval):
            date_parts: tuple[int | None, int | None, int | None] = tuple(  # type: ignore[assignment]
                (
                    int(part)
                    if self._DATE_PART_PATTERN.fullmatch(part) and int(part) > 0
                    else None
                )
                for part in dateval.split("-")
            )
            date = Date(*date_parts)
            dateval_quality = element.get("quality")
            if dateval_quality == "estimated":
                date.fuzzy = True
            return date
        return None

    async def _load_notes(self, database: ElementTree.Element) -> None:
        for element in self._xpath(database, "./ns:notes/ns:note"):
            await self._load_note(element)

    async def _load_note(self, element: ElementTree.Element) -> None:
        note_handle = element.get("handle")
        note_id = element.get("id")
        assert note_id is not None
        text_element = self._xpath1(element, "./ns:text")
        assert text_element is not None
        text = str(text_element.text)
        note = Note(
            id=note_id,
            text=text,
        )
        if element.get("priv") == "1":
            note.private = True
        self._add_entity(note, note_handle)

    def _load_noteref(
        self, owner: HasNotes & Entity, element: ElementTree.Element
    ) -> None:
        owner.notes = self._resolve(Note, *self._load_handles("noteref", element))

    async def _load_objects(
        self, database: ElementTree.Element, media_path: Path | None
    ) -> None:
        for element in self._xpath(database, "./ns:objects/ns:object"):
            await self._load_object(element, media_path)

    async def _load_object(
        self, element: ElementTree.Element, media_path: Path | None
    ) -> None:
        file_handle = element.get("handle")
        file_id = element.get("id")
        assert file_id is not None
        file_element = self._xpath1(element, "./ns:file")
        src = file_element.get("src")
        assert src is not None
        file_path = Path(src)
        if media_path is not None:
            file_path = media_path / file_path
        if not file_path.is_absolute():
            raise UserFacingGrampsError(
                _(
                    'Cannot load Gramps file {file_id} with relative path {file_path}, because your family tree does not include a base path. In Gramps, add a "base path for relative media paths" to your family tree, and export it again.'
                ).format(file_id=file_id, file_path=str(file_path))
            )
        if not file_path.is_file():
            raise UserFacingGrampsError(
                _(
                    "Cannot load Gramps file {file_id}, because {file_path} is not a file."
                ).format(file_id=file_id, file_path=str(file_path))
            )
        file = File(
            id=file_id,
            path=file_path,
        )
        mime = file_element.get("mime")
        assert mime is not None
        file.media_type = MediaType(mime)
        description = file_element.get("description")
        if description:
            file.description = description
        if element.get("priv") == "1":
            file.private = True

        self._load_attributes_for(
            file,
            GrampsEntityReference(GrampsEntityType.OBJECT, file.id),
            element,
            "attribute",
        )
        copyright_notice_id = self._load_attribute(
            "copyright-notice", element, "attribute"
        )
        if copyright_notice_id:
            try:
                file.copyright_notice = await self._copyright_notices.new_target(
                    copyright_notice_id
                )
            except PluginNotFound:
                getLogger(__name__).warning(
                    self._localizer._(
                        'Betty is unfamiliar with Gramps file "{file_id}"\'s copyright notice ID of "{copyright_notice_id}" and ignored it.',
                    ).format(file_id=file_id, copyright_notice_id=copyright_notice_id)
                )
        license_id = self._load_attribute("license", element, "attribute")
        if license_id:
            try:
                file.license = await self._licenses.new_target(license_id)
            except PluginNotFound:
                getLogger(__name__).warning(
                    self._localizer._(
                        'Betty is unfamiliar with Gramps file "{file_id}"\'s license ID of "{license_id}" and ignored it.',
                    ).format(file_id=file_id, license_id=license_id)
                )

        self._add_entity(file, file_handle)
        file.citations = self._resolve(
            Citation, *self._load_handles("citationref", element)
        )
        self._load_noteref(file, element)

    async def _load_people(self, database: ElementTree.Element) -> None:
        for element in self._xpath(database, "./ns:people/ns:person"):
            await self._load_person(element)

    async def _load_person(self, element: ElementTree.Element) -> None:
        person_handle = element.get("handle")
        assert person_handle is not None
        person_id = element.get("id")
        assert person_id is not None
        gramps_gender = self._load_attribute("gender", element, "attribute")
        if gramps_gender is None:
            gramps_gender = self._xpath1(element, "./ns:gender").text
            assert gramps_gender is not None

        gender: Gender
        try:
            gender_factory = self._gender_mapping[gramps_gender]
        except KeyError:
            gender = UnknownGender()
            getLogger(__name__).warning(
                self._localizer._(
                    'Betty is unfamiliar with Gramps person "{person_id}"\'s gender of "{gramps_gender}". The person was imported, but their gender was set to "{betty_gender}".',
                ).format(
                    person_id=person_id,
                    gramps_gender=gramps_gender,
                    betty_gender=gender.plugin_label().localize(self._localizer),
                )
            )
        else:
            gender = await ensure_await(gender_factory())
        person = Person(id=element.get("id"), gender=gender)

        name_elements = sorted(
            self._xpath(element, "./ns:name"), key=lambda x: x.get("alt") == "1"
        )
        person_names = []
        for name_element in name_elements:
            is_alternative = name_element.get("alt") == "1"
            try:
                individual_name = self._xpath1(name_element, "./ns:first").text
            except XPathError:
                individual_name = None
            surname_elements = [
                surname_element
                for surname_element in self._xpath(name_element, "./ns:surname")
                if surname_element.text is not None
            ]
            if surname_elements:
                for surname_element in surname_elements:
                    if not is_alternative:
                        is_alternative = surname_element.get("prim") == "0"
                    affiliation_name = surname_element.text
                    surname_prefix = surname_element.get("prefix")
                    if surname_prefix is not None:
                        affiliation_name = f"{surname_prefix} {affiliation_name}"
                    person_name = PersonName(
                        person=person,
                        individual=individual_name,
                        affiliation=affiliation_name,
                    )
                    self._load_citationref(person_name, name_element)
                    person_names.append((person_name, is_alternative))
            elif individual_name is not None:
                person_name = PersonName(person=person, individual=individual_name)
                self._load_citationref(person_name, name_element)
                person_names.append((person_name, is_alternative))
        for person_name, __ in sorted(person_names, key=lambda x: x[1]):
            self._add_entity(person_name)
            person.names.add(person_name)

        await self._load_eventrefs(person, element)
        if element.get("priv") == "1":
            person.private = True

        self._load_attributes_for(
            person,
            GrampsEntityReference(GrampsEntityType.PERSON, person.id),
            element,
            "attribute",
        )

        self._load_citationref(person, element)
        self._load_objref(person, element)
        self._load_noteref(person, element)
        self._load_urls(person, element)
        self._add_entity(person, person_handle)

    async def _load_families(self, database: ElementTree.Element) -> None:
        for element in self._xpath(database, "./ns:families/ns:family"):
            await self._load_family(element)

    async def _load_family(self, element: ElementTree.Element) -> None:
        children = [
            cast(Person, self._handles_to_entities[child_handle])
            for child_handle in self._load_handles("childref", element)
        ]
        for parent_handle_type in ("father", "mother"):
            parent_handle = self._load_handle(parent_handle_type, element)
            if parent_handle is None:
                continue
            parent = self._handles_to_entities[parent_handle]
            assert isinstance(parent, Person)
            await self._load_eventrefs(parent, element)
            parent.children = children

    async def _load_eventrefs(
        self, person: Person, element: ElementTree.Element
    ) -> None:
        eventrefs = self._xpath(element, "./ns:eventref")
        for eventref in eventrefs:
            await self._load_eventref(person, eventref)

    async def _load_eventref(
        self, person: Person, eventref: ElementTree.Element
    ) -> None:
        event_handle = eventref.get("hlink")
        assert event_handle is not None
        gramps_presence_role = cast(str, eventref.get("role"))

        presence_role: PresenceRole
        try:
            presence_role_factory = self._presence_role_mapping[gramps_presence_role]
        except KeyError:
            presence_role = UnknownPresenceRole()
            getLogger(__name__).warning(
                self._localizer._(
                    'Betty is unfamiliar with person "{person_id}"\'s Gramps presence role of "{gramps_presence_role}" for the event with Gramps handle "{event_handle}". The role was imported, but set to "{betty_presence_role}".',
                ).format(
                    person_id=person.id,
                    event_handle=event_handle,
                    gramps_presence_role=gramps_presence_role,
                    betty_presence_role=presence_role.plugin_label().localize(
                        self._localizer
                    ),
                )
            )
        else:
            presence_role = await ensure_await(presence_role_factory())
        presence = Presence(
            person,
            presence_role,
            self._resolve1(Event, event_handle),
        )
        if eventref.get("priv") == "1":
            presence.private = True

        self._load_attributes_for(
            presence,
            GrampsEntityReference(GrampsEntityType.PERSON, person.id),
            eventref,
            "attribute",
        )

        self._add_entity(presence)

    async def _load_places(self, database: ElementTree.Element) -> None:
        for element in self._xpath(database, "./ns:places/ns:placeobj"):
            await self._load_place(element)

    async def _load_place(self, element: ElementTree.Element) -> None:
        place_handle = element.get("handle")
        assert place_handle is not None
        place_id = element.get("id")
        assert place_id is not None
        gramps_type = element.get("type")
        assert gramps_type is not None
        names = []
        for name_element in self._xpath(element, "./ns:pname"):
            # The Gramps language is a single ISO language code, which is a valid BCP 47 locale.
            language = name_element.get("lang")
            date = self._load_date(name_element)
            name = name_element.get("value")
            assert name is not None
            names.append(
                Name(
                    {language or UNDETERMINED_LOCALE: name},
                    date=date,
                )
            )

        place_type: PlaceType
        try:
            place_type_factory = self._place_type_mapping[gramps_type]
        except KeyError:
            place_type = UnknownPlaceType()
            getLogger(__name__).warning(
                self._localizer._(
                    'Betty is unfamiliar with Gramps place "{place_id}"\'s type of "{gramps_place_type}". The place was imported, but its type was set to "{betty_place_type}".',
                ).format(
                    place_id=place_id,
                    gramps_place_type=gramps_type,
                    betty_place_type=place_type.plugin_label().localize(
                        self._localizer
                    ),
                )
            )
        else:
            place_type = await ensure_await(place_type_factory())

        place = Place(
            id=place_id,
            names=names,
            place_type=place_type,
        )

        coordinates = self._load_coordinates(element)
        if coordinates:
            place.coordinates = coordinates

        self._load_urls(place, element)

        self._load_noteref(place, element)

        self._add_entity(place, place_handle)

        for encloser_handle in self._load_handles("placeref", element):
            enclosure = Enclosure(
                enclosee=self._resolve1(Place, place_handle),
                encloser=self._resolve1(Place, encloser_handle),
            )
            self._add_entity(enclosure)

    def _load_coordinates(self, element: ElementTree.Element) -> Point | None:
        with suppress(XPathError):
            coord_element = self._xpath1(element, "./ns:coord")

            coordinates = f"{coord_element.get('lat')}; {coord_element.get('long')}"
            try:
                return Point.from_string(coordinates)
            except ValueError:
                getLogger(__name__).warning(
                    self._localizer._(
                        'Cannot load coordinates "{coordinates}", because they are in an unknown format.',
                    ).format(
                        coordinates=coordinates,
                    )
                )
        return None

    async def _load_events(self, database: ElementTree.Element) -> None:
        for element in self._xpath(database, "./ns:events/ns:event"):
            await self._load_event(element)

    async def _load_event(self, element: ElementTree.Element) -> None:
        event_handle = element.get("handle")
        event_id = element.get("id")
        assert event_id is not None
        gramps_type = self._xpath1(element, "./ns:type").text
        assert gramps_type is not None

        event_type: EventType
        try:
            event_type_factory = self._event_type_mapping[gramps_type]
        except KeyError:
            event_type = UnknownEventType()
            getLogger(__name__).warning(
                self._localizer._(
                    'Betty is unfamiliar with Gramps event "{event_id}"\'s type of "{gramps_event_type}". The event was imported, but its type was set to "{betty_event_type}".',
                ).format(
                    event_id=event_id,
                    gramps_event_type=gramps_type,
                    betty_event_type=event_type.plugin_label().localize(
                        self._localizer
                    ),
                )
            )
        else:
            event_type = await ensure_await(event_type_factory())

        event = Event(
            id=event_id,
            event_type=event_type,
        )

        event.date = self._load_date(element)

        # Load the event place.
        place_handle = self._load_handle("place", element)
        if place_handle is not None:
            event.place = self._resolve1(Place, place_handle)

        # Load the description.
        with suppress(XPathError):
            description = self._xpath1(element, "./ns:description").text
            if description:
                event.description = description

        if element.get("priv") == "1":
            event.private = True

        self._load_objref(event, element)
        self._load_citationref(event, element)
        self._load_noteref(event, element)

        self._load_attributes_for(
            event,
            GrampsEntityReference(GrampsEntityType.EVENT, event.id),
            element,
            "attribute",
        )

        self._add_entity(event, event_handle)

    async def _load_repositories(self, database: ElementTree.Element) -> None:
        for element in self._xpath(database, "./ns:repositories/ns:repository"):
            await self._load_repository(element)

    async def _load_repository(self, element: ElementTree.Element) -> None:
        repository_source_handle = element.get("handle")

        source = Source(
            id=element.get("id"),
            name=self._xpath1(element, "./ns:rname").text,
        )

        self._load_urls(source, element)
        self._load_noteref(source, element)
        self._add_entity(source, repository_source_handle)

    async def _load_sources(self, database: ElementTree.Element) -> None:
        for element in self._xpath(database, "./ns:sources/ns:source"):
            await self._load_source(element)

    async def _load_source(self, element: ElementTree.Element) -> None:
        source_handle = element.get("handle")
        try:
            source_name = self._xpath1(element, "./ns:stitle").text
        except XPathError:
            source_name = None

        source = Source(
            id=element.get("id"),
            name=source_name,
        )

        repository_source_handle = self._load_handle("reporef", element)
        if repository_source_handle is not None:
            source.contained_by = self._resolve1(Source, repository_source_handle)

        # Load the author.
        with suppress(XPathError):
            author = self._xpath1(element, "./ns:sauthor").text
            if author:
                source.author = author

        # Load the publication info.
        with suppress(XPathError):
            publisher = self._xpath1(element, "./ns:spubinfo").text
            if publisher:
                source.publisher = publisher

        if element.get("priv") == "1":
            source.private = True

        self._load_attributes_for(
            source,
            GrampsEntityReference(GrampsEntityType.SOURCE, source.id),
            element,
            "srcattribute",
        )

        self._load_objref(source, element)
        self._load_noteref(source, element)
        self._add_entity(source, source_handle)

    async def _load_citations(self, database: ElementTree.Element) -> None:
        for element in self._xpath(database, "./ns:citations/ns:citation"):
            await self._load_citation(element)

    async def _load_citation(self, element: ElementTree.Element) -> None:
        citation_handle = element.get("handle")
        source_handle = self._xpath1(element, "./ns:sourceref").get("hlink")
        assert source_handle is not None

        citation = Citation(
            id=element.get("id"), source=self._resolve1(Source, source_handle)
        )

        citation.date = self._load_date(element)
        if element.get("priv") == "1":
            citation.private = True

        with suppress(XPathError):
            page = self._xpath1(element, "./ns:page").text
            if page:
                citation.location = page

        self._load_objref(citation, element)

        self._load_attributes_for(
            citation,
            GrampsEntityReference(GrampsEntityType.CITATION, citation.id),
            element,
            "srcattribute",
        )

        self._add_entity(citation, citation_handle)

    def _load_citationref(
        self,
        owner: HasCitations & Entity,
        element: ElementTree.Element,
    ) -> None:
        owner.citations = self._resolve(
            Citation, *self._load_handles("citationref", element)
        )

    def _load_handles(
        self, handle_type: str, element: ElementTree.Element
    ) -> Iterable[str]:
        for handle_element in self._xpath(element, f"./ns:{handle_type}"):
            hlink = handle_element.get("hlink")
            if hlink:
                yield hlink

    def _load_handle(
        self, handle_type: str, element: ElementTree.Element
    ) -> str | None:
        for handle_element in self._xpath(element, f"./ns:{handle_type}"):
            return handle_element.get("hlink")
        return None

    def _load_objref(
        self, owner: HasFileReferences & Entity, element: ElementTree.Element
    ) -> None:
        for handle_element in self._xpath(element, "./ns:objref"):
            file_handle = handle_element.get("hlink")
            assert file_handle is not None
            file_reference = FileReference(owner, self._resolve1(File, file_handle))
            try:
                region_element = self._xpath1(handle_element, "./ns:region")
            except XPathError:
                pass
            else:
                region_left = region_element.get("corner1_x")
                region_top = region_element.get("corner1_y")
                region_right = region_element.get("corner2_x")
                region_bottom = region_element.get("corner2_y")
                file_reference.focus = (
                    0 if region_left is None else int(region_left),
                    0 if region_top is None else int(region_top),
                    0 if region_right is None else int(region_right),
                    0 if region_bottom is None else int(region_bottom),
                )
            self._add_entity(file_reference)

    def _load_urls(self, owner: HasLinks, element: ElementTree.Element) -> None:
        url_elements = self._xpath(element, "./ns:url")
        for url_element in url_elements:
            link = Link(str(url_element.get("href")))
            link.relationship = "external"
            description = url_element.get("description")
            if description:
                link.label = description
            owner.links.append(link)

    def _load_attribute_privacy(
        self, entity: HasPrivacy & Entity, element: ElementTree.Element, tag: str
    ) -> None:
        privacy_value = self._load_attribute("privacy", element, tag)
        if privacy_value is None:
            return
        if privacy_value == "private":
            entity.private = True
            return
        if privacy_value == "public":
            entity.public = True
            return
        getLogger(__name__).warning(
            self._localizer._(
                'The betty:privacy Gramps attribute must have a value of "public" or "private", but "{privacy_value}" was given for {entity_type} {entity_id} ({entity_label}), which was ignored.',
            ).format(
                privacy_value=privacy_value,
                entity_type=entity.plugin_label().localize(self._localizer),
                entity_id=entity.id,
                entity_label=entity.label.localize(self._localizer),
            )
        )

    _LINK_ATTRIBUTE_PATTERN = re.compile(r"^link-([^:]+?):(.+?)$")

    def _load_attribute_links(
        self,
        entity: HasLinks & Entity,
        gramps_entity_reference: GrampsEntityReference,
        element: ElementTree.Element,
        tag: str,
    ) -> None:
        logger = getLogger(__name__)

        attributes = self._load_attributes(element, tag)
        links_attributes: MutableMapping[str, MutableMapping[str, str]] = defaultdict(
            dict
        )
        for attribute_type, attribute_value in attributes.items():
            match = self._LINK_ATTRIBUTE_PATTERN.fullmatch(attribute_type)
            if match is None:
                continue
            link_name = match.group(1)
            link_attribute_name = match.group(2)
            links_attributes[link_name][link_attribute_name] = attribute_value
        for link_name, link_attributes in links_attributes.items():
            if "url" not in link_attributes:
                logger.warning(
                    self._localizer._(
                        'The Gramps {gramps_entity_reference} entity requires a "betty:link-{link_name}:url" attribute. This link was ignored.',
                    ).format(
                        gramps_entity_reference=gramps_entity_reference,
                        link_name=link_name,
                    )
                )
                continue
            link = Link(link_attributes["url"])
            entity.links.append(link)
            if "description" in link_attributes:
                link.description = link_attributes["description"]
            if "label" in link_attributes:
                link.label = link_attributes["label"]
            if "locale" in link_attributes:
                link.locale = link_attributes["locale"]
            if "media_type" in link_attributes:
                try:
                    media_type = MediaType(link_attributes["media_type"])
                except InvalidMediaType:
                    logger.warning(
                        self._localizer._(
                            'The Gramps {gramps_entity_reference} entity has a "betty:link-{link_name}:media_type" attribute with value "{media_type}", which is not a valid IANA media type. This media type was ignored.',
                        ).format(
                            gramps_entity_reference=gramps_entity_reference,
                            link_name=link_name,
                            media_type=link_attributes["media_type"],
                        )
                    )
                else:
                    link.media_type = media_type
            if "relationship" in link_attributes:
                link.relationship = link_attributes["relationship"]

    def _load_attribute(
        self, name: str, element: ElementTree.Element, tag: str
    ) -> str | None:
        try:
            return self._load_attributes(element, tag)[name]
        except KeyError:
            return None

    def _load_attributes(
        self, element: ElementTree.Element, tag: str
    ) -> Mapping[str, str]:
        prefixes = ["betty"]
        if self._attribute_prefix_key:
            prefixes.append(f"betty-{self._attribute_prefix_key}")
        attributes: MutableMapping[str, str] = {}
        for prefix in prefixes:
            with suppress(XPathError):
                attribute_elements = self._xpath(element, f"./ns:{tag}")
                for attribute_element in attribute_elements:
                    attribute_type = attribute_element.attrib["type"]
                    attribute_value = attribute_element.get("value")
                    if (
                        attribute_type.startswith(f"{prefix}:")
                        and attribute_value is not None
                    ):
                        attributes[attribute_type[len(prefix) + 1 :]] = attribute_value
        return attributes

    def _load_attributes_for(
        self,
        entity: Entity,
        gramps_entity_reference: GrampsEntityReference,
        element: ElementTree.Element,
        tag: str,
    ) -> None:
        if isinstance(entity, HasPrivacy):
            self._load_attribute_privacy(entity, element, tag)
        if isinstance(entity, HasLinks):
            self._load_attribute_links(entity, gramps_entity_reference, element, tag)
