from copy import deepcopy
import datetime
import itertools
import mimetypes
from posixpath import splitext, relpath, dirname  # EPUB paths are always posix
import re
from urllib.parse import unquote, urljoin, urlsplit, urlunsplit
from uuid import uuid4
import zipfile
from lxml.builder import ElementMaker
import lxml.etree as etree
import lxml.html


class EpubParseError(Exception):
    """Raised when parsing an incorrect file."""

    pass


EPUB_NS = 'http://www.idpf.org/2007/ops'
XHTML_NS = 'http://www.w3.org/1999/xhtml'
OPF_NS = 'http://www.idpf.org/2007/opf'
DC_NS = 'http://purl.org/dc/elements/1.1/'
NCX_NS = 'http://www.daisy.org/z3986/2005/ncx/'

AUDIO_TYPES = (
    'audio/mpeg',
    'audio/mp4',
    'audio/ogg; codecs=opus',
    'audio/ogg',
)
CONTENT_TYPES = (
    'application/xhtml+xml',
    'image/svg+xml',
    'text/html',
)
FONT_TYPES = (
    'font/ttf',
    'application/font-sfnt',
    'font/otf',
    'application/vnd.ms-opentype',
    'font/woff',
    'application/font-woff',
    'font/woff2',
    # the ones after this aren't core media types but whatever
    # actually in my EPUBs application/x-font-ttf is much more common than
    # font/ttf!
    'application/x-font-truetype',
    'application/x-font-ttf',
    'application/x-font-otf',
    'font/opentype',
)
IMAGE_TYPES = (
    'image/gif',
    'image/jpeg',
    'image/png',
    'image/svg+xml',
    'image/webp',
)
SCRIPT_TYPES = (
    'application/javascript',
    'application/ecmascript',
    'text/javascript',
)
VIDEO_TYPES = (
    # none of these are core media types or are in any of my EPUBs,
    # this is just for completeness
    'video/mp4',
    'video/mpeg',
    'video/ogg',
    'video/x-msvideo',
    'video/webm',
)

# https://www.dublincore.org/specifications/dublin-core/dcmi-terms/
DCMES_VOCABULARY = {
    'contributor',
    'coverage',
    'creator',
    'date',
    'description',
    'format',
    'identifier',
    'language',
    'publisher',
    'relation',
    'rights',
    'source',
    'subject',
    'title',
    'type',
}

DEFAULT_VOCABULARIES = {
    'meta': 'http://idpf.org/epub/vocab/package/meta/#',
    'link': 'http://idpf.org/epub/vocab/package/link/#',
    'item': 'http://idpf.org/epub/vocab/package/item/#',
    'itemref': 'http://idpf.org/epub/vocab/package/itemref/#',
}

# including dc for if you try to use resolve_property with the dc metadata names
# and the epub:type prefixes because why not
RESERVED_PREFIXES = {
    'a11y': 'http://www.idpf.org/epub/vocab/package/a11y/#',
    'dcterms': 'http://purl.org/dc/terms/',
    'marc': 'http://id.loc.gov/vocabulary/',
    'media': 'http://www.idpf.org/epub/vocab/overlays/#',
    'onix': 'http://www.editeur.org/ONIX/book/codelists/current.html#',
    'rendition': 'http://www.idpf.org/vocab/rendition/#',
    'schema': 'http://schema.org/',
    'xsd': 'http://www.w3.org/2001/XMLSchema#',
    'msv': 'http://www.idpf.org/epub/vocab/structure/magazine/#',
    'prism':
    'http://www.prismstandard.org/specifications/3.0/PRISM_CV_Spec_3.0.htm#',
    'dc': 'http://purl.org/dc/elements/1.1/',
}

E = ElementMaker(nsmap={None: XHTML_NS, 'epub': EPUB_NS})
NDOC_TEMPLATE = E.html(
    E.head(
        E.meta(charset='UTF-8'),
        E.title('Table of Contents'),
        E.style('.nav-ol {list-style: none;}\n.nav-span {font-weight:bold;}'),
    ),
    E.body(),
)

E = ElementMaker(nsmap={None: XHTML_NS})
COVER_TEMPLATE = E.html(
    E.head(
        E.meta(charset='UTF-8'),
        E.title('Cover'),
        E.style(
            '.cover-container {text-align: center;}\n'
            + '.cover {\n  max-width: 100%;\n  max-height: 100%;\n'
            + '  width: 100%;\n  height: 100%;\n  margin: 0;\n  padding: 0;\n'
            + '  border: 0;\n}'
        ),
    ),
    E.body(E.div(E.img(**{'class': 'cover'}), **{'class': 'cover-container'})),
)

del E  # this should be just a temporary variable


def html_to_xhtml(content, title=None):
    """Turns a HTML document into XHTML.

    Takes a content string, which is a HTML document or just a fragment (which
    will be wrapped in a <body> in a <html>). If it is wrapped and title is not
    None, a <title> tag is added with that content. Returns bytes encoded in
    utf-8.
    """

    if not isinstance(content, str):
        raise TypeError('content must be a string')
    if title is not None and not isinstance(title, str):
        raise TypeError('title must be a string or None')
    html = lxml.html.document_fromstring(content)
    html.set('xmlns', XHTML_NS)
    if html.find('head') is None:
        head = etree.Element('head')
        etree.SubElement(head, 'meta', charset='UTF-8')
        if title is not None:
            title_tag = etree.SubElement(head, 'title')
            title_tag.text = title
        html.insert(0, head)
    return lxml.html.tostring(
        html, pretty_print=True, method='xml', doctype='<!DOCTYPE html>'
    )


class Meta:
    """Holds one metadata property; represents a metadata <meta> or <dc:*> tag.

    Class attributes are name, content, and attrs. name, a string, is the name
    or property attribute, or the name of the tag for a DC tag (for example, for
    a dc title, name will be 'dc:title'). content is also a string and is the
    metadata value. attrs is a dict (from strings to strings) with the optional
    tag attributes.
    """

    def __init__(self, name, content, **attrs):
        # strict: check vocabularies
        if not isinstance(name, str):
            raise TypeError('Meta name must be a string')
        if not isinstance(content, str):
            raise TypeError('Meta content must be a string')
        for (k, v) in attrs.items():
            if not isinstance(k, str):
                raise TypeError('attrs key must be a string.')
            if not isinstance(v, str):
                raise TypeError('attrs value must be a string.')
        self.name = name
        self.content = content
        self.attrs = attrs

    def __repr__(self):
        return etree.tostring(self.as_element(), encoding='unicode')

    def __deepcopy__(self, memo):
        return Meta(self.name, self.content, **deepcopy(self.attrs, memo))

    def as_element(self, version=3):
        if version not in (2, 3):
            raise ValueError('EPUB version must be 2 or 3')
        if self.name.startswith('dc:'):
            meta = etree.Element(f'{{{DC_NS}}}{self.name[3:]}', **self.attrs)
            meta.text = self.content
        elif version == 3:
            meta = etree.Element('meta', property=self.name, **self.attrs)
            meta.text = self.content
        else:
            meta = etree.Element(
                'meta', name=self.name, content=self.content, **self.attrs
            )
        return meta


class Link:
    """Holds one metadata link; Represents a metadata <link> tag.

    Class attributes are rel, href and attrs. rel and href are strings; they're
    the link's relation to the publication and the link itself respectively.
    attrs is a dict (from string to string) with the optional tag attributes.
    rel is a space-separated list of properties which should be handled with the
    has_rel, add_rel and remove_rel methods.
    """

    def __init__(self, rel, href, **attrs):
        if not isinstance(rel, str):
            raise TypeError('Link rel must be a string')
        if not isinstance(href, str):
            raise TypeError('Link href must be a string')
        for (k, v) in attrs.items():
            if not isinstance(k, str):
                raise TypeError('attrs key must be a string.')
            if not isinstance(v, str):
                raise TypeError('attrs value must be a string.')
        self.rel = rel
        self.href = href
        self.attrs = attrs

    def __repr__(self):
        return etree.tostring(self.as_element(), encoding='unicode')

    def __deepcopy__(self, memo):
        return Link(self.rel, self.href, **deepcopy(self.attrs, memo))

    def as_element(self):
        return etree.Element('link', rel=self.rel, href=self.href, **self.attrs)

    def has_rel(self, prop):
        if not isinstance(prop, str):
            raise TypeError('Link rel must be a string')

        return prop in self.rel.split(' ')

    def add_rel(self, prop):
        if not isinstance(prop, str):
            raise TypeError('Link rel must be a string')

        if self.has_rel(prop):
            return
        if self.rel:
            self.rel += ' ' + prop
        else:
            self.rel = prop

    def remove_rel(self, prop):
        if not isinstance(prop, str):
            raise TypeError('Link rel must be a string')
        if not self.has_rel(prop):
            raise ValueError(f'Link rel property {prop} not found')
        spl = self.rel.split(' ')
        spl.remove(prop)
        self.rel = ' '.join(spl)


def guess_link_type(href):
    """Guesses the type of a link (not necessarily just a path)

    Guesses non-strictly and returns just the media type (first member of
    mimetypes.guess_type return tuple).
    """

    path = urlsplit(href).path
    type = mimetypes.guess_type(path, strict=False)[0]
    # mimetypes doesn't know about NCX
    if type is None and splitext(path)[1] == '.ncx':
        return 'application/x-dtbncx+xml'
    return type


class Item:
    """Holds information about a file; Represents a manifest <item> tag.

    Class attributes are id, href, media_type and attrs. id, href, and
    media_type are strings; they're the file's unique id, link from the package
    document, and MIME type respectively. attrs is a dict (from string to
    string) with the optional tag attributes. If the media_type is not passed on
    initialisation, it is guessed from the href. attrs['properties'], if it
    exists, is a space-separated list of properties which should be handled with
    the has_property, add_property and remove_property methods.
    """

    def __init__(self, id, href, media_type=None, **attrs):
        # strict: check fallback (+media type)
        if not isinstance(id, str):
            raise TypeError('Item id must be a string')
        if not isinstance(href, str):
            raise TypeError('Item href must be a string')
        if media_type is not None and not isinstance(href, str):
            raise TypeError('Item media type must be a string')
        for (k, v) in attrs.items():
            if not isinstance(k, str):
                raise TypeError('attrs key must be a string.')
            if not isinstance(v, str):
                raise TypeError('attrs value must be a string.')
        self.id = id
        self.href = href
        if media_type is None:
            media_type = guess_link_type(href)
        if media_type is None:
            raise ValueError(f'unknown media type of file {path}')
        self.media_type = media_type
        self.attrs = attrs

    def __repr__(self):
        return etree.tostring(self.as_element(), encoding='unicode')

    def __deepcopy__(self, memo):
        return Item(
            self.id, self.href, self.media_type, **deepcopy(self.attrs, memo)
        )

    def as_element(self):
        return etree.Element(
            'item', id=self.id, href=self.href,
            **({'media-type': self.media_type} | self.attrs)
        )

    def has_property(self, prop):
        if not isinstance(prop, str):
            raise TypeError('Item property must be a string')

        return ('properties' in self.attrs
                and prop in self.attrs['properties'].split(' '))

    def add_property(self, prop):
        if not isinstance(prop, str):
            raise TypeError('Item property must be a string')

        if 'properties' not in self.attrs or not self.attrs['properties']:
            self.attrs['properties'] = prop
        elif self.has_property(prop):
            return
        else:
            self.attrs['properties'] += ' ' + prop

    def remove_property(self, prop):
        if not isinstance(prop, str):
            raise TypeError('Item property must be a string')
        if not self.has_property(prop):
            raise ValueError(f'property {prop} not found')
        spl = self.attrs['properties'].split(' ')
        spl.remove(prop)
        self.attrs['properties'] = ' '.join(spl)
        if not self.attrs['properties']:
            del self.attrs['properties']


def unpack_tag_content(content):
    if not isinstance(content, str):
        raise TypeError('content must be a string')
    span = etree.fromstring(f'<span>{content}</span>')
    return span.text, list(span)


class NavEntry:
    """A link or section header in the navigation document or NCX.

    Attributes are href, text, children, id, is_header and attrs. href is the
    link, text and children make up the entry's contents, id is the id
    attribute, attrs is the optional other tag attrs. If is_header is True, the
    entry will be displayed as a <span> in the navigation document, otherwise it
    will be an <a>.

    link is mandatory even for headers because it is in NCX, but the Nav class
    can add it automatically. text is text before the first tag under the entry,
    and children are each of the tags under it (lxml.etree.Element instances).
    To turn some inner text with tags into text and children attributes, use
    unpack_tag_content. id is mandatory because it is so in NCX; if it's not
    passed on initialization it will be a random UUID.

    attrs uses ElementTree syntax for namespaces, so the epub:type will be
    {http://www.idpf.org/2007/ops}type; for convenience, you can pass a type
    argument on initialization and it will be assigned to the epub:type, and
    after that you can use the get_type and set_type methods.
    """

    def __init__(
        self,
        href,
        text,
        children=None,
        id=None,
        is_header=False,
        type=None,
        **attrs,
    ):
        if not isinstance(href, str):
            raise TypeError('NavEntry href must be a string')
        if not isinstance(text, str):
            raise TypeError('NavEntry text must be a string')
        if (children is not None
            and (not isinstance(children, list)
                 or any(not isinstance(x, etree.Element) for x in children))):
            raise TypeError(
                'NavEntry children must be a list of lxml.etree.Element'
            )
        for (k, v) in attrs.items():
            if not isinstance(k, str):
                raise TypeError('attrs key must be a string.')
            if not isinstance(v, str):
                raise TypeError('attrs value must be a string.')
        self.href = href
        self.text = text
        self.id = id
        if id is None:
            self.id = 'uuid_' + str(uuid4())
        self.is_header = is_header
        if children is None:
            self.children = []
        else:
            self.children = children
        self.attrs = attrs
        if type is not None:
            if self.get_type() is not None:
                raise ValueError('two type arguments were given')
            self.set_type(type)

    def __repr__(self):
        string = '<NavEntry'
        if self.is_header:
            string += ' [header]'
        string += f' "{self.inner_text()}">'
        return string

    def __deepcopy__(self, memo):
        return NavEntry(
            self.href,
            self.text,
            self.children,
            self.id,
            self.is_header,
            **deepcopy(self.attrs, memo),
        )

    def content(self):
        string = self.text
        for ch in self.children:
            string += etree.tostring(ch, encoding='unicode')
        return string

    def inner_text(self):
        string = self.text
        for ch in self.children:
            string += (ch.text or '') + (ch.tail or '')
        return string

    def get_type(self):
        return self.attrs.get(f'{{{EPUB_NS}}}type')

    def set_type(self, type):
        self.attrs[f'{{{EPUB_NS}}}type'] = type

    def as_element(self):
        if self.is_header:
            elm = etree.Element('span', id=self.id, **self.attrs)
            if 'class' not in self.attrs:
                elm.set('class', 'nav-span')
        else:
            elm = etree.Element('a', href=self.href, id=self.id, **self.attrs)
            if 'class' not in self.attrs:
                elm.set('class', 'nav-a')
        elm.text = self.text
        elm.extend(self.children)
        return elm

    def as_ncx_element(self, tag='navPoint'):
        elm = etree.Element(tag, id=self.id, **self.attrs)
        lab = etree.SubElement(elm, 'navLabel')
        text = etree.SubElement(lab, 'text')
        text.text = self.text
        etree.SubElement(elm, 'content', src=self.href)
        return elm

    def as_guide_reference(self):
        type = self.get_type()
        if type is None:
            raise ValueError('missing type for guide entry')
        if type == 'bodymatter':
            type = 'text'
        return etree.Element(
            'reference', type=type, title=self.text, href=self.href,
            **{k: self.attrs[k] for k in self.attrs if k != f'{{{EPUB_NS}}}type'}
        )


NAV_SECTION_START, NAV_SECTION_END = 'start section', 'end section'


def flatten_nav(tree):
    """Turns a nav tree (return value of Nav.tree) back into a Nav state."""

    state = []
    for x in tree:
        if isinstance(x, NavEntry):
            state.append(x)
        elif (isinstance(x, tuple) and len(x) == 2
              and isinstance(x[0], NavEntry) and isinstance(x[1], list)):
            state.append(x[0])
            state.append(NAV_SECTION_START)
            state.extend(flatten_nav(x[1]))
            state.append(NAV_SECTION_END)
        else:
            raise TypeError(
                'members of tree must be NavEntry or (NavEntry, list) tuple'
            )
    return state


class Nav:
    """Represents a <nav> tag in the navigation document or a tag in the NCX.

    The header attribute stores the string contents of the nav's main header.
    The type attribute is the nav type. The state attribute is the nav contents;
    it is a flat list, with the constants NAV_SECTION_START and NAV_SECTION_END
    surrounding subsections (the entry before NAV_SECTION_START is the section
    header). For just the flat entries in the state, use the entries method; for
    a representation with nessted lists, use the tree method (reversible by
    the flatten_nav function).

    When using add_section, you don't need to specify a link. In the navigation
    document there really will be no link (by default, if add_header is True),
    the entry will be a <span>, but in the NCX we need a link. This link will be
    the same as the next NavEntry if there is one, and the previous NavEntry if
    the header is at the end of the state. So we initially set headers' hrefs to
    the previous entry's href (or the empty string if there isn't one), and
    change them to the next entry when it is added. We keep track of how many
    headers are waiting for an entry to inherit its link in the hrefless_headers
    attribute, automatically incrementing it, then changing the hrefs of that
    many entries at the end of the state and resetting it to 0.
    """

    def __init__(self, header='Table of Contents', type='toc'):
        if header is not None and not isinstance(header, str):
            raise TypeError('Nav header must be a string or None')
        if not isinstance(type, str):
            raise TypeError('Nav type must be a string')
        self.header = header
        self.type = type
        self.state = []
        self.hrefless_headers = 0

    def __repr__(self):
        return f'<{self.type} nav object>'

    def __deepcopy__(self, memo):
        new = Nav(self.header, self.type)
        new.hrefless_headers = self.hrefless_headers
        new.state = deepcopy(self.state, memo)
        return new

    def tree(self):
        tree = []
        stack = [tree]
        for x in self.state:
            if x == NAV_SECTION_START:
                header = stack[-1][-1]
                sec = []
                stack[-1][-1] = (header, sec)
                stack.append(sec)
            elif x == NAV_SECTION_END:
                stack.pop()
            else:
                stack[-1].append(x)
        return tree

    def entries(self):
        return [
            x for x in self.state
            if x not in (NAV_SECTION_START, NAV_SECTION_END)
        ]

    def is_empty(self):
        return not self.state

    def fix_hrefless_headers(self, href):
        i = len(self.state) - 1
        while self.hrefless_headers > 0:
            if i < 0:
                raise ValueError(
                    'Nav.hrefless_headers is non-zero, but Nav state does not'
                    + ' end with that many headers of empty sections'
                )
            if isinstance(self.state[i], NavEntry):
                self.state[i].href = href
                self.hrefless_headers -= 1
            i -= 1

    def add_entry(
        self, href, text=None, children=None, id=None, type=None, **attrs
    ):
        if 'is_header' in attrs:
            raise TypeError(
                'passed is_header to add_entry, where it is False by default.'
                + ' To add a section header, use start_section'
            )
        if isinstance(href, NavEntry):
            if (text is not None or children is not None
                or id is not None or type is not None or attrs):
                raise TypeError(
                    'Nav.add_entry takes either a single NavEntry or arguments'
                    + ' to make one'
                )
            entry = href
            if entry.is_header:
                raise ValueError(
                    'NavEntry argument of Nav.add_entry must not be a header'
                )
        else:
            if text is None:
                raise TypeError(
                    'Nav.add_entry takes either a single NavEntry or multiple'
                    + ' arguments to make one'
                )
            entry = NavEntry(href, text, children, id, type=type, **attrs)
        self.fix_hrefless_headers(entry.href)
        self.state.append(entry)

    def start_section(
        self,
        text=None,
        children=None,
        id=None,
        is_header=True,
        type=None,
        **attrs,
    ):
        if text is None:
            if (children is not None or id is not None
                or type is not None or attrs):
                raise TypeError(
                    'Nav.start_section takes either no arguments, a single'
                    + ' header NavEntry or arguments to make one'
                )
            self.state.append(NAV_SECTION_START)
            return
        if isinstance(text, NavEntry):
            if children is not None or type is not None or attrs:
                raise TypeError(
                    'Nav.start_section takes either no arguments, a single'
                    + ' header NavEntry or arguments to make one'
                )
            entry = text
            self.fix_hrefless_headers(entry.href)
        else:
            href = ''
            for x in self.state[::-1]:
                if isinstance(x, NavEntry):
                    href = x.href
                    break
            self.hrefless_headers += 1
            entry = NavEntry(href, text, children, id, is_header, type, **attrs)
        self.state.append(entry)
        self.state.append(NAV_SECTION_START)

    def end_section(self):
        depth = 0
        for x in self.state:
            if x == NAV_SECTION_START:
                depth += 1
            elif x == NAV_SECTION_END:
                depth -= 1
        if depth < 1:
            raise ValueError('ending section but no section is open.')
        self.state.append(NAV_SECTION_END
)

    def as_element(self):
        nav = etree.Element('nav')
        nav.set(f'{{{EPUB_NS}}}type', self.type)
        if self.header is not None:
            h2 = etree.SubElement(nav, 'h2')
            h2.set('class', 'nav-h')
            h2.text = self.header
        ol = etree.SubElement(nav, 'ol')
        ol.set('class', 'nav-ol')
        stack = []

        li = None
        for x in self.state:
            if x == NAV_SECTION_START:
                if li is None:
                    raise ValueError('Nav subsection without a header')
                stack.append(ol)
                ol = etree.SubElement(li, 'ol')
                ol.set('class', 'nav-ol')
            elif x == NAV_SECTION_END:
                ol = stack.pop()
            else:
                li = etree.SubElement(ol, 'li')
                li.set('class', 'nav-li')
                li.append(x.as_element())
        return nav

    def as_ncx_list(self):
        if self.type == 'toc':
            elm = etree.Element('navMap')
            child = 'navPoint'
        elif self.type == 'page-list':
            elm = etree.Element('pageList')
            child = 'pageTarget'
        else:
            elm = etree.Element('navList')
            child = 'navTarget'

        if self.header is not None:
            label = etree.SubElement(elm, 'navLabel')
            text = etree.SubElement(label, 'text')
            text.text = self.header

        last_point = None
        stack = [elm]
        for x in self.state:
            if x == NAV_SECTION_START:
                if last_point is None:
                    raise ValueError('Nav subsection without a header')
                stack.append(last_point)
            elif x == NAV_SECTION_END:
                stack.pop()
            else:
                last_point = x.as_ncx_element(child)
                stack[-1].append(last_point)

        return elm

    def as_guide(self):
        guide = etree.Element('guide')
        for entry in self.entries():
            guide.append(entry.as_guide_reference())
        return guide


def make_ndoc(navs, template=NDOC_TEMPLATE):
    html = deepcopy(template)
    body = None
    for child in html:
        if child.tag == 'body':
            body = child
            break
    if body is None:
        raise ValueError('no <body> in navigation document template')
    for nav in navs:
        body.append(nav.as_element())
    return etree.tostring(html, pretty_print=True, encoding='utf-8')


def make_ncx(title, creator, identifier, navs):
    E = ElementMaker(nsmap={None: NCX_NS})
    ncx = E.ncx(
        E.head(E.meta(name='dtb:uid', content=identifier)),
        E.docTitle(E.text(title)),
        E.docAuthor(E.text(creator)),
        version='2005-1'
    )
    for nav in navs:
        # landmarks nav goes to package guide, not ncx
        if nav.type == 'landmarks':
            continue
        ncx.append(nav.as_ncx_list())
    return etree.tostring(ncx, pretty_print=True, encoding='utf-8')


class Publication:
    """An EPUB file.

    Metadata may be added with the add_meta method. To read metadata, use the
    get_meta method. To add items to the EPUB, use the add_item method,
    indicating whether the item belongs to the main sequence of the publication.
    The publication must have a navigation document, with the string 'nav'
    within its properties.

    Attributes
    ----------
    version : Literal[2] | Literal[3]
        The version of the read EPUB, and the default version the EPUB will be
        written in. Is either 2 or 3, defaults to 3.
    items : dict
        Each Item in the EPUB file, keyed by identifier.
    contents : dict
        The contents of each item, keyed by identifier.
    spine : list
        A list containing the reading sequence of an EPUB file.
    spine_is_linear : dict
        A dict mapping spine entry ids to the boolean value of their linear
        attribute, for the spine entries that have the attribute.
    paths : dict
        A mapping from the full path of each Item in the publication to its id.
    ids : set
        The id of each item or metadata in the package document, if they have
        those. Used to make new ids that are unambiguous.
    package_prefixes : dict
        Prefixes used in the package document metadata. Keys are namespaces,
        values are urls.
    ndoc : str | None
        The identifier of the navigation document item, if there is one.
    ncx : str | None
        The identifier of the NCX item, if there is one.
    navs : list
        List of <nav> tags (Nav objects), which will be turned into the
        navigation document. Begins with the required "toc nav", the table of
        contents. If ndoc is not None, this is ignored.
    toc : Nav
        The first nav in navs, of type=toc, created at initialization. Has its
        own name for convenience.
    landmarks : Nav | None
        Another convenience name for a member of navs, the landmarks nav or
        guide, if it exists.
    ndoc_position : int | None
        Position at which a navigation document is added into the spine, if one
        is created. If it is None the navigation document isn't added to the
        spine. If it is 'end' it is appended to the end of the spine.
    unique_identifier : str | None
        The id of the publication's main unique identifier pointed to from the
        <package> tag.
    package_document_path: str
        Full path to package document.
    nav_directory : str
        Path to the directory of the navigation document or the NCX that the
        navs were read from, if created with read_epub; also the path where the
        navigation document and NCX will be written if they are created from
        navs.

    """

    def __init__(self):
        self.package_document_path = 'OPS/content.opf'
        self.nav_directory = 'OPS/'

        self.version = 3
        self.unique_identifier = None
        self.package_prefixes = {}
        self.metadata = {}
        self.links = []
        self.items = {}
        self.spine = []
        self.spine_is_linear = {}

        self.paths = {}
        self.ids = set()

        self.contents = {}

        self.ndoc = None
        self.ncx = None
        self.cover = None

        self.toc = Nav()
        self.landmarks = None
        self.navs = [self.toc]
        self.ndoc_position = 'end'

    def __repr__(self):
        if 'dc:title' in self.metadata:
            return f'<Publication object "{self.get_title()}">'
        return '<Publication object>'

    def __deepcopy__(self, memo):
        new = Publication()
        new.package_document_path = self.package_document_path
        new.nav_directory = self.nav_directory

        new.version = self.version
        new.unique_identifier = self.unique_identifier
        new.package_prefixes = deepcopy(self.package_prefixes, memo)
        new.metadata = deepcopy(self.metadata, memo)
        new.links = deepcopy(self.links, memo)
        new.items = deepcopy(self.items, memo)
        new.spine = deepcopy(self.spine, memo)
        new.spine_is_linear = deepcopy(self.spine_is_linear, memo)

        new.paths = deepcopy(self.paths, memo)
        new.ids = deepcopy(self.ids, memo)

        new.contents = deepcopy(self.contents, memo)

        new.ndoc = self.ndoc
        new.ncx = self.ncx
        new.cover = self.cover

        new.toc = deepcopy(self.toc, memo)
        new.landmarks = deepcopy(self.landmarks, memo)
        new.navs = deepcopy(self.navs, memo)
        new.ndoc_position = self.ndoc_position

        return new

    def full_path(self, href):
        if not isinstance(href, str):
            raise TypeError('href must be string')
        path = urlsplit(href).path
        return urljoin(self.package_document_path, path)

    def full_path_from_nav(self, href):
        if not isinstance(href, str):
            raise TypeError('href must be string')
        path = urlsplit(href).path
        return urljoin(self.nav_directory, path)

    def resolve_property(self, prop, elem=None):
        if not isinstance(prop, str):
            raise TypeError('property must be a string')
        if elem is not None and not isinstance(elem, str):
            raise TypeError('element name must be a string')

        if prop.startswith(':'):
            raise ValueError(f'invalid property {prop} (starts with colon)')
        if ':' not in prop:
            return DEFAULT_VOCABULARIES.get(elem), prop
        prefix, ref = prop.split(':', 1)
        if prefix in self.package_prefixes:
            return self.package_prefixes[prefix], ref
        return RESERVED_PREFIXES.get(prefix), ref

    def get_metas(self, name=None):
        if name is None:
            return itertools.chain(*self.metadata.values())
        if not isinstance(name, str):
            raise TypeError('get_metas argument must be a string')
        if name not in self.metadata:
            return ()
        return iter(self.metadata[name])

    def get_meta(self, name=None, id=None):
        if name is None and id is None:
            raise TypeError('get_meta must get at least one argument.')
        if name is not None and not isinstance(name, str):
            raise TypeError('get_meta name argument must be a string')
        if id is not None and not isinstance(id, str):
            raise TypeError('get_meta id argument must be a string')
        for meta in self.get_metas(name):
            if id is None or ('id' in meta.attrs and meta.attrs['id'] == id):
                return meta.content
        raise ValueError(f'metadata {name} not found')

    def get_dc_meta(self, name=None, id=None):
        if name is None:
            return self.get_meta(id=id)
        if not isinstance(name, str):
            raise TypeError('get_dc_meta name argument must be a string')
        return self.get_meta('dc:' + name, id)

    def get_title(self):
        return self.get_meta('dc:title')

    def get_creator(self):
        return self.get_meta('dc:creator')

    def get_identifier(self):
        if self.unique_identifier is None:
            return self.get_meta('dc:identifier')
        return self.get_meta('dc:identifier', self.unique_identifier)

    def get_language(self):
        return self.get_meta('dc:language')

    def get_links(self, rel=None):
        if rel is None:
            for link in self.links:
                yield link
        else:
            for link in self.links:
                if link.has_rel(rel):
                    yield link

    def get_item(self, item=None, href=None, path=None):
        """Finds an item corresponding to an argument.

        Exactly one of the three arguments must be passed. If the (first)
        argument is a string, returns the item that has the string as an
        identifier. If it is an Item, returns the item in the publication with
        the same identifier as it. If it is a NavEntry, returns the item that
        NavEntry's href points to (relative to nav_directory). If the href
        argument is passed instead, returns the item that this href points to
        relative to package_document_path. Finally, if the path argument is
        passed, returns the item with that full path. In any case, if there is
        no corresponding item, a ValueError is raised.
        """

        if (item is not None) + (href is not None) + (path is not None) != 1:
            raise TypeError('get_item takes exactly one argument')

        if href is not None:
            if not isinstance(href, str):
                raise TypeError('href must be string')
            path = self.full_path(href)

        if path is not None:
            if not isinstance(path, str):
                raise TypeError('path must be string')
            if path not in self.paths:
                raise ValueError(f'Item of path {path} not found')
            id = self.paths[path]
        elif isinstance(item, Item):
            id = item.id
        elif isinstance(item, NavEntry):
            path = self.full_path_from_nav(item.href)
            if path not in self.paths:
                raise ValueError(f'Item of path {path} (from nav) not found')
            id = self.paths[path]
        elif isinstance(item, str):
            id = item
        else:
            raise TypeError(
                f'wrong type of argument {type(item)},'
                + ' must be Item, NavEntry or string'
            )

        if id not in self.items:
            raise ValueError(f'Item of id {id} not found')
        return self.items[id]

    def get_items(self, *media_types):
        """Yields items with one of the specified media types.

        If no media types are given, just yields all items. The return value is
        an iterable (not a list).
        """

        for x in self.items.values():
            if media_types and x.media_type not in media_types:
                continue
            yield x

    def get_content_items(self):
        return map(self.get_item, self.spine)

    def is_in_spine(self, item=None, href=None, path=None):
        return self.get_item(item, href, path).id in self.spine

    def get_content(self, item=None, href=None, path=None):
        item = self.get_item(item, href, path)
        return self.contents.get(item.id)

    def get_landmarks(self):
        if self.landmarks is None:
            return ()
        for entry in self.landmarks.entries():
            yield (entry.get_type(), self.get_item(entry))

    def get_landmark(self, type):
        for (other, item) in self.get_landmarks():
            if type == other:
                return item
        return None

    def make_path(self, template):
        if template not in self.paths:
            return template
        base, ext = splitext(template)
        i = 2
        while base + f'-{i}' + ext in self.paths:
            i += 1
        return base + f'-{i}' + ext

    def make_id(self, template=''):
        i = 0
        while template + str(i).zfill(4) in self.ids:
            i += 1
        return template + str(i).zfill(4)

    def add_meta(self, name, content=None, **attrs):
        if isinstance(name, Meta):
            if content is not None or attrs:
                raise TypeError(
                    'arguments to Publication.add_meta must be either a Meta or'
                    + ' name, content and optional attrs to make one'
                )
            meta = name
        else:
            if content is None:
                raise TypeError(
                    'arguments to Publication.add_meta must be either a Meta or'
                    + ' name, content and optional attrs to make one'
                )
            meta = Meta(name, content, **attrs)
        if meta.name == 'cover':
            if meta.content not in self.items:
                # some EPUBs have the cover point to the path instead of the id
                # in others, for some reason, the value is just "cover-image",
                # even though that's not the id of anything.
                # weird, but this happens in three different EPUBs of mine
                # and the epub 2 spec doesn't seem to place any constraints
                # whatsoever on <meta> tags, so whatever
                path = self.full_path(meta.content)
                if path in self.paths:
                    meta.content = self.paths[path]
                elif meta.content == 'cover-image':
                    return
                else:
                    raise ValueError(f'unknown cover image item {meta.content}')
            if self.cover is not None and self.cover != meta.content:
                raise ValueError("can't have two cover images")
            self.cover = meta.content
            self.items[meta.content].add_property('cover-image')
            return
        # Handles unique identifier; if unique_identifier hasn't been set, we 
        # make the next identifier the main one.
        if meta.name == 'dc:identifier' and self.unique_identifier is None:
            if 'id' not in meta.attrs:
                meta.attrs['id'] = self.make_id('pub-id')
            self.unique_identifier = meta.attrs['id']

        if 'id' in meta.attrs:
            self.ids.add(meta.attrs['id'])
        if meta.name not in self.metadata:
            self.metadata[meta.name] = []
        self.metadata[meta.name].append(meta)

    def add_dc_meta(self, name, content, **attrs):
        if name not in DCMES_VOCABULARY:
            raise ValueError('invalid DC metadata: ' + name)
        self.add_meta('dc:' + name, content, **attrs)

    def add_dc_metas(self, **kwargs):
        for name, content in kwargs.items():
            self.add_dc_meta(name, content)

    def add_uuid(self):
        self.add_dc_meta('identifier', 'urn:uuid:' + str(uuid4()))

    def add_link(self, rel, href=None, **attrs):
        if isinstance(rel, Link):
            if href is not None or attrs:
                raise TypeError(
                    'arguments to Publication.add_link must be either a Link or'
                    + ' rel, href and optional other attrs to make one'
                )
            link = rel
        else:
            if href is None:
                raise TypeError(
                    'arguments to Publication.add_link must be either a Link or'
                    + ' rel, href and optional other attrs to make one'
                )
            link = Link(rel, href, **attrs)
        self.links.append(link)

    def add_item(self, href, content=None, id=None, media_type=None, **attrs):
        if isinstance(href, Item):
            if id is not None or media_type is not None or attrs:
                raise TypeError(
                    'arguments to Publication.add_item must be either an Item'
                    + 'or arguments to make an Item (and optionally content)'
                )
            item = href
            path = self.full_path(item.href)
        else:
            path = self.full_path(href)
            if id is None:
                id = self.make_id(splitext(path)[1][1:])
            item = Item(id, href, media_type, **attrs)
        if item.id in self.items:
            raise ValueError('duplicate identifier: ' + item.id)
        if path in self.paths:
            raise ValueError('duplicate item path: ' + path)
        if content is not None and not isinstance(content, bytes):
            raise TypeError('item content must be bytes')

        if item.has_property('nav'):
            if self.ndoc is not None and self.ndoc != item.id:
                raise ValueError("can't have two navigation documents.")
            if item.media_type != 'application/xhtml+xml':
                raise ValueError('Navigation document must be XHTML')
            self.ndoc = item.id

        if item.media_type == 'application/x-dtbncx+xml':
            if self.ncx is not None and self.ncx != item.id:
                raise ValueError("can't have two NCXs")
            self.ncx = item.id

        if item.has_property('cover-image'):
            if self.cover is not None and self.cover !=item. id:
                raise ValueError("can't have two cover images")
            self.cover = item.id

        self.ids.add(item.id)
        self.items[item.id] = item
        self.paths[path] = item.id
        if content is not None:
            self.contents[item.id] = content
        return item.id

    def add_spine_entry(self, item=None, href=None, path=None, linear=None):
        id = self.get_item(item, href, path).id
        if id in self.spine:
            raise ValueError(f'duplicate spine entry {id}')
        self.spine.append(id)
        if linear is not None:
            self.spine_is_linear[id] = linear

    def add_chapter(
        self,
        title,
        href,
        content=None,
        id=None,
        media_type=None,
        to_xhtml=True,
        **attrs,
    ):
        if media_type is None:
            media_type = guess_link_type(href)
        if media_type not in ('application/xhtml+xml', 'image/svg+xml'):
            raise ValueError('content item must be XHTML or SVG')
        if to_xhtml:
            if content is None:
                raise ValueError('to_xhtml is True but no content was given')
            if media_type != 'application/xhtml+xml':
                raise ValueError('to_xhtml is True but item type is not XHTML')
            content = html_to_xhtml(content, title)
        
        id = self.add_item(href, content, id, media_type, **attrs)
        self.add_spine_entry(id)

        frag = urlsplit(href).fragment
        path = relpath(self.full_path(href), self.nav_directory)
        toc_href = urlunsplit(('', '', path, '', frag))
        self.toc.add_entry(toc_href, title)
        return id

    def add_cover(self, href, content=None, id=None, media_type=None, **attrs):
        """Adds a cover image and a XHTML document displaying it."""

        id = self.add_item(href, content, id, media_type, **attrs)
        # code reuse! this sets the item we just added as the cover:
        self.add_meta('cover', id)

        xhtml_path = self.make_path(self.full_path('cover.xhtml'))
        xhtml_href = relpath(xhtml_path, dirname(self.package_document_path))
        src = relpath(self.full_path(href), dirname(xhtml_path))

        xhtml = deepcopy(COVER_TEMPLATE)
        xhtml[1][0][0].set('src', src)
        xhtml_id = self.add_item(
            xhtml_href,
            etree.tostring(xhtml, encoding='utf-8', pretty_print=True)
        )
        self.spine.insert(0, xhtml_id)
        toc_href = relpath(self.full_path(xhtml_href), self.nav_directory)
        self.toc.state.insert(0, NavEntry(toc_href, 'Cover'))
        return id, xhtml_id

    def set_content(self, content, item=None, href=None, path=None):
        if not isinstance(content, bytes):
            raise TypeError('item content must be bytes')
        item = self.get_item(item, href, path)
        self.contents[item.id] = content

    def remove_item(self, item=None, href=None, path=None):
        item = self.get_item(item, href, path)
        id = item.id
        path = self.full_path(item.href)
        if not isinstance(id, str):
            raise TypeError('Publication item id must be a string')
        elif id not in self.items:
            raise ValueError('Publication item not found: ' + id)
        del self.items[id]
        del self.paths[path]
        if self.ndoc == id:
            self.ndoc = None
        elif self.ncx == id:
            self.ncx = None
        if id in self.contents:
            del self.contents[id]
        while id in self.spine:
            self.spine.remove(id)
        if id in self.spine_is_linear:
            del self.spine_is_linear[id]

        def tree_without_item(tree):
            i = 0
            while i < len(tree):
                if isinstance(tree[i], NavEntry):
                    if self.full_path_from_nav(tree[i].href) == path:
                        del tree[i]
                    else:
                        i += 1
                else:
                    new = tree_without_item(tree[i][1])
                    header_is_item = (
                        self.full_path_from_nav(tree[i].href) == path
                    )
                    if header_is_item and not new:
                        del tree[i]
                    elif not header_is_item and not new:
                        tree[i] = tree[i][0]
                        i += 1
                    elif header_is_item and new:
                        new_entry = deepcopy(tree[i][0])
                        if isinstance(new[0], NavEntry):
                            new_entry.href = new[0].href
                        else:
                            new_entry.href = new[0][0].href
                        tree[i] = (new_entry, new)
                        i += 1
                    else:  # not header_is_item and new
                        tree[i] = (tree[i][0], new)
                        i += 1
            return tree

        for nav in self.navs:
            nav.state = flatten_nav(tree_without_item(nav.tree()))

    def set_last_modified_date(self):
        """Sets a last modified date to datetime.now().

        If there is already a last modified date, it is replaced, unless it has
        a refines attribute (see the epub spec).
        """

        now = datetime.datetime.now(datetime.timezone.utc)
        meta = Meta('dcterms:modified', now.strftime('%Y-%m-%dT%H:%M:%SZ'))
        if 'dcterms:modified' in self.metadata:
            self.metadata['dcterms:modified'] = (
                [meta]
                + [x for x in self.metadata['dcterms:modified']
                   if 'refines' in x.attrs]
            )
        else:
            self.metadata['dcterms:modified'] = [meta]

    def check_fallback_chain(self):
        """Checks the files' fallback chains.

        Raises a ValueError if a fallback isn't an item or there is recursion.
        """

        # Records checked items for optimization
        checked_items = set()

        for uid in self.items:
            if uid in checked_items:
                continue

            item = self.items[uid]
            # Records the fallback chain to avoid infinite loops.
            fallback_chain = set()
            while 'fallback' in item.attrs:
                if item.attrs['fallback'] not in self.items:
                    raise ValueError('invalid fallback')
                item = self.items[item.attrs['fallback']]

                if item.id in fallback_chain:
                    raise ValueError('infinite loop in fallback chain')
                fallback_chain.add(item.id)
            checked_items.add(uid)
            checked_items.update(fallback_chain)

    def package_document_content(
        self, ndoc_path=None, ncx_path=None, version=None, add_guide=True
    ):
        """Returns the publication's package document as bytes."""

        if version is None:
            version = self.version
        if version not in (2, 3):
            raise ValueError('EPUB version must be 2 or 3')

        package = etree.Element(
            'package', version=f'{version}.0', nsmap={None: OPF_NS}
        )
        package.set('unique-identifier', self.unique_identifier)

        # prefix attribute
        if self.package_prefixes and version == 3:
            pref = ' '.join(
                f'{i}: {self.package_prefixes[i]}'
                for i in self.package_prefixes
            )
            package.set('prefix', pref)

        metadata = etree.SubElement(package, 'metadata', nsmap={'dc': DC_NS})
        for ls in self.metadata.values():
            for meta in ls:
                metadata.append(meta.as_element(version))
        if version == 2 and self.cover is not None:
            etree.SubElement(metadata, 'meta', name='cover', content=self.cover)
        for link in self.links:
            metadata.append(link.as_element())

        manifest = etree.SubElement(package, 'manifest')
        for item in self.items.values():
            manifest.append(item.as_element())

        spine = etree.SubElement(package, 'spine')
        for x in self.spine:
            if x in self.spine_is_linear:
                etree.SubElement(
                    spine, 'itemref', idref=x,
                    linear=('yes' if self.spine_is_linear[x] else 'no')
                )
            else:
                etree.SubElement(spine, 'itemref', idref=x)

        if ndoc_path is not None:
            ndoc_id = self.make_id('nav')
            ndoc_href = relpath(ndoc_path, dirname(self.package_document_path))
            ndoc_item = Item(
                ndoc_id, ndoc_href, 'application/xhtml+xml', properties='nav'
            )
            manifest.append(ndoc_item.as_element())
            if self.ndoc_position is not None:
                itemref = etree.Element('itemref', idref=ndoc_id)
                if self.ndoc_position == 'end':
                    spine.append(itemref)
                else:
                    spine.insert(self.ndoc_position, itemref)

        if ncx_path is not None:
            ncx_id = self.make_id('ncx')
            ncx_href = relpath(ncx_path, dirname(self.package_document_path))
            ncx_item = Item(ncx_id, ncx_href, 'application/x-dtbncx+xml')
            manifest.append(ncx_item.as_element())
            spine.set('toc', ncx_id)
        elif self.ncx is not None:
            spine.set('toc', self.ncx)

        if self.landmarks is not None and add_guide:
            package.append(self.landmarks.as_guide())

        return etree.tostring(package, pretty_print=True, encoding='utf-8')

    def write_epub(
        self,
        path,
        version=None,
        set_modified_date=True,
        add_ndoc=True,
        add_ncx=True,
        add_guide=True
    ):
        """Writes the publication to a path as an EPUB file.

        path can be anything zipfile.ZipFile accepts, so bytes, str, Path and
        io.BytesIO objects are allowed. The EPUB version will be the version
        argument if it is passed, otherwise self.version. If set_modified_date
        is True we will call self.set_last_modified_date(). A navigation
        document is made from navs if add_ndoc is True and self.ndoc is None
        (there isn't already a navigation document); a NCX is made from navs if
        add_ncx is True and self.ncx is None. A <guide> is made from
        self.landmarks if that isn't None and add_guide is True.
        """

        if version is None:
            version = self.version
        if version not in (2, 3):
            raise ValueError('EPUB version must be 2 or 3')

        if set_modified_date:
            self.set_last_modified_date()

        with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as file:
            file.writestr(
                'mimetype',
                b'application/epub+zip',
                compress_type=zipfile.ZIP_STORED,
            )

            E = ElementMaker(
                nsmap={None: 'urn:oasis:names:tc:opendocument:xmlns:container'}
            )
            container = E.container(
                E.rootfiles(
                    E.rootfile(
                        **{'full-path': self.package_document_path,
                           'media-type': 'application/oebps-package+xml'}
                    )
                ),
                version='1.0',
            )
            with file.open('META-INF/container.xml', 'w') as container_file:
                container_file.write(
                    etree.tostring(
                        container, encoding='utf-8', pretty_print=True
                    )
                )

            for id in self.contents:
                with file.open(
                        self.full_path(self.items[id].href), 'w'
                ) as item_file:
                    item_file.write(self.contents[id])

            ndoc_path = None
            if self.ndoc is None and add_ndoc:
                ndoc_path = self.make_path(
                    urljoin(self.nav_directory, 'nav.xhtml')
                )
                with file.open(ndoc_path, 'w') as nav_file:
                    nav_file.write(make_ndoc(self.navs))

            ncx_path = None
            if self.ncx is None and add_ncx:
                if 'dc:title' not in self.metadata:
                    raise ValueError('missing title (for NCX)')
                if 'dc:creator' not in self.metadata:
                    raise ValueError('missing author (for NCX)')
                if 'dc:identifier' not in self.metadata:
                    raise ValueError('missing identifier (for NCX)')
                title = self.get_title()
                creator = self.get_creator()
                identifier = self.get_identifier()

                ncx_path = self.make_path(
                    urljoin(self.nav_directory, 'toc.ncx')
                )
                with file.open(ncx_path, 'w') as ncx_file:
                    ncx_file.write(
                        make_ncx(title, creator, identifier, self.navs)
                    )

            with file.open(self.package_document_path, 'w') as package_document:
                package_document.write(self.package_document_content(
                    ndoc_path, ncx_path, version, add_guide
                ))


def parse_nav_tag(elm):

    def add_li(nav, li):
        if not len(li):
            raise EpubParseError('empty <li> in navigation document')

        if li[0].tag == f'{{{XHTML_NS}}}span':
            if len(li) != 2:
                raise EpubParseError(
                    '<span> without subsection in navigation document'
                )
            nav.start_section(li[0].text or '', list(li[0]), **li[0].attrib)
            for subli in li[1]:
                add_li(nav, subli)
            nav.end_section()
        elif li[0].tag == f'{{{XHTML_NS}}}a':
            href = li[0].get('href')
            if href is None:
                raise EpubParseError('<a> with no href in navigation document')
            nav.add_entry(
                href, li[0].text or '', list(li[0]),
                **{k: li[0].get(k) for k in li[0].keys() if k != 'href'}
            )
            if len(li) >= 2:
                nav.start_section()
                for subli in li[1]:
                    add_li(nav, subli)
                nav.end_section()

    elm = deepcopy(elm)  # because we will take its children
    if len(elm) >= 2:
        header = elm[0].text
        i = 1
    else:
        header = None
        i = 0
    type = elm.get(f'{{{EPUB_NS}}}type')
    if type is None:
        raise EpubParseError('<nav> tag without epub:type')
    nav = Nav(header, type)
    for li in elm[i]:
        add_li(nav, li)
    return nav


def parse_ncx_list(elm):

    # parse navPoint pageTarget or navTarget (they're the same thing)
    def add_point(nav, point, child):
        # there might be many navLabels for different languages,
        # but in a nav doc we can't do that so we just take the first
        label = point.find(f'{{{NCX_NS}}}navLabel')
        if label is None:
            raise EpubParseError('missing navLabel in NCX')
        if not len(label):
            raise EpubParseError('empty navLabel in NCX')
        if label[0].tag == f'{{{NCX_NS}}}text':
            text = label[0].text or ''
            children = label[1:]
        else:
            text = None
            children = list(label)
        href = point.find(f'{{{NCX_NS}}}content').get('src')
        nav.add_entry(href, text, children)
        # only navPoints can nest but whatever
        if point.find(child) is not None:
            nav.start_section()
            for subpoint in point.findall(child):
                add_point(nav, subpoint, child)
            nav.end_section()

    if elm.tag == f'{{{NCX_NS}}}navMap':
        type = 'toc'
        # default header, will be overwritten by navLabel if one exists
        header = 'Table of Contents'
        child = f'{{{NCX_NS}}}navPoint'
    elif elm.tag == f'{{{NCX_NS}}}pageList':
        type = 'page-list'
        header = 'Page List'
        child = f'{{{NCX_NS}}}pageTarget'
    elif elm.tag == f'{{{NCX_NS}}}navList':
        type = '?'  # unknown, should be set manually
        header = None
        child = f'{{{NCX_NS}}}navTarget'
    else:
        raise EpubParseError('unknown NCX list tag: ' + elm.tag)

    hlabel = elm.find(f'{{{NCX_NS}}}navLabel')
    if (hlabel is not None and len(hlabel)
        and hlabel[0].tag == f'{{{NCX_NS}}}text'):
        header = hlabel[0].text

    nav = Nav(header, type)
    for point in elm.findall(child):
        add_point(nav, point, child)
    return nav


def parse_ndoc(doc):
    if isinstance(doc, str) or isinstance(doc, bytes):
        try:
            doc = etree.fromstring(doc)
        except etree.ParseError as e:
            raise EpubParseError('in navigation document: ' + e.args[0])
    elif not isinstance(doc, etree.Element):
        raise TypeError(
            'navigation document must be string, bytes or etree.Element'
        )
    navs = []
    for tag in doc.iter(f'{{{XHTML_NS}}}nav'):
        navs.append(parse_nav_tag(tag))
    return navs


def parse_ncx(doc):
    if isinstance(doc, str) or isinstance(doc, bytes):
        try:
            doc = etree.fromstring(doc)
        except etree.ParseError as e:
            raise EpubParseError('in NCX: ' + e.args[0])
    elif not isinstance(doc, etree.Element):
        raise TypeError('NCX must be string, bytes or etree.Element')
    navs = []
    for child in doc.iter(
        f'{{{NCX_NS}}}navMap', f'{{{NCX_NS}}}pageList', f'{{{NCX_NS}}}navList'
    ):
        navs.append(parse_ncx_list(child))
    return navs


def read_epub(path, read_contents=True):
    """Reads from an EPUB file and returns a Publication."""

    with zipfile.ZipFile(path, 'r', zipfile.ZIP_DEFLATED) as file:
        # Parsing container.xml
        if not zipfile.Path(file, 'META-INF/container.xml').exists():
            raise EpubParseError('container.xml not found')
        try:
            container_xml = etree.parse(file.open('META-INF/container.xml'))
        except etree.ParseError as e:
            raise EpubParseError('in container.xml: ' + e.args[0])
        container = container_xml.getroot()
        if not len(container) or not len(container[0]):
            raise EpubParseError('container.xml <rootfile> not found')
        package_path = container[0][0].get('full-path')

        # Parsing the package document
        if not zipfile.Path(file, package_path).exists():
            raise EpubParseError(f'package document at {package_path} not found')
        try:
            package_xml = etree.parse(file.open(package_path))
        except etree.ParseError as e:
            raise EpubParseError('in package document: ' + e.args[0])
        package = package_xml.getroot()
        unique_identifier = package.get('unique-identifier')
        prefix = package.get('prefix')
        package_prefixes = {}
        if prefix is not None:
            # Space is added because the regex matches for whitespaces at the end
            prefix += ' '
            mos = re.findall(r'(.+?):(.+?)\s+', prefix)
            for mo in mos:
                package_prefixes[mo[0]] = mo[1].strip()
        if package.get('version') == '3.0':
            version = 3
        else:
            version = 2

        pub = Publication()
        pub.package_prefixes = package_prefixes
        pub.unique_identifier = unique_identifier
        pub.package_document_path = package_path
        pub.version = version

        # Metadata
        metadata = package.find(f'{{{OPF_NS}}}metadata')
        if metadata is None:
            raise EpubParseError('package metadata not found')
        cover = None
        for meta in metadata.iter(tag=etree.Element):
            if meta.tag == f'{{{OPF_NS}}}meta' and meta.get('property') is None:
                # epub 2 meta tag
                if meta.get('name') is None:
                    raise EpubParseError(
                        'name not found in <meta> tag: '
                        + etree.tostring(meta, encoding='unicode')
                    )
                if meta.get('content') is None:
                    raise EpubParseError(
                        'content not found in <meta> tag: '
                        + etree.tostring(meta, encoding='unicode')
                    )
                if meta.get('name') == 'cover':
                    # cover meta will be added after manifest
                    # because then we'll know if it's the id of some item
                    cover = meta
                    continue
                pub.add_meta(
                    meta.get('name'),
                    meta.get('content'),
                    **{k: meta.get(k) for k in meta.keys()
                       if k not in ('name', 'content')}
                )
            elif meta.tag == f'{{{OPF_NS}}}meta':
                # epub 3 meta tag
                pub.add_meta(
                    meta.get('property'),
                    meta.text or '',
                    **{k: meta.get(k) for k in meta.keys() if k != 'property'}
                )
            elif meta.tag == f'{{{OPF_NS}}}link':
                if meta.get('rel') is None:
                    raise EpubParseError(
                        'rel not found in <link> tag: '
                        + etree.tostring(meta, encoding='unicode')
                    )
                if meta.get('href') is None:
                    raise EpubParseError(
                        'href not found in <link> tag: '
                        + etree.tostring(meta, encoding='unicode')
                    )
                pub.add_link(**meta.attrib)
            elif meta.tag.startswith(f'{{{DC_NS}}}'):
                name = meta.tag.removeprefix(f'{{{DC_NS}}}')
                pub.add_dc_meta(name, meta.text or '', **meta.attrib)

        # Manifest
        manifest = package.find(f'{{{OPF_NS}}}manifest')
        if manifest is None:
            raise EpubParseError('package manifest not found')
        for item in manifest.iter(tag=f'{{{OPF_NS}}}item'):
            if item.get('href') is None:
                raise EpubParseError(
                    'href not found in <item> tag: '
                    + etree.tostring(item, encoding='unicode')
                )
            if read_contents and not urlsplit(item.get('href')).scheme:
                path = pub.full_path(unquote(item.get('href')))
                if zipfile.Path(file, path).exists():
                    with file.open(path) as itemfile:
                        content = itemfile.read()
                else:
                    content = None
            else:
                content = None
            # unfortunately we need Item.media_type instead of Item.media-type
            pub.add_item(
                content=content,
                media_type=item.get('media-type'),
                **{k: item.get(k) for k in item.keys() if k != 'media-type'},
            )

        if cover is not None:
            pub.add_meta('cover', cover.get('content'))

        # Spine
        spine = package.find(f'{{{OPF_NS}}}spine')
        if spine is None:
            raise EpubParseError('package spine not found')
        pub.ncx = spine.get('toc')
        for itemref in spine.iter(tag=f'{{{OPF_NS}}}itemref'):
            if itemref.get('idref') is None:
                raise EpubParseError(
                    'error parsing <itemref> tag: '
                    + etree.tostring(itemref, encoding='unicode')
                )
            if itemref.get('linear') is not None:
                pub.add_spine_entry(
                    itemref.get('idref'), linear=(itemref.get('linear') != 'no')
                )
            else:
                pub.add_spine_entry(itemref.get('idref'))

        # Navigation Document / NCX
        if pub.ndoc is not None and read_contents and pub.ndoc in pub.contents:
            pub.navs = parse_ndoc(pub.contents[pub.ndoc])
            pub.nav_directory = dirname(pub.full_path(pub.items[pub.ndoc].href))
            if pub.nav_directory:
                pub.nav_directory += '/'
        elif pub.ncx is not None and read_contents and pub.ncx in pub.contents:
            pub.navs = parse_ncx(pub.contents[pub.ncx])
            pub.nav_directory = dirname(pub.full_path(pub.items[pub.ncx].href))
            if pub.nav_directory:
                pub.nav_directory += '/'
        for nav in pub.navs:
            if nav.type == 'toc':
                pub.toc = nav
            elif nav.type == 'landmarks':
                pub.landmarks = nav

        # Guide
        guide = package.find(f'{{{OPF_NS}}}guide')
        if pub.landmarks is None and guide is not None:
            pub.landmarks = Nav()
            for ref in guide.iter(tag=f'{{{OPF_NS}}}reference'):
                if ref.get('href') is None:
                    raise EpubParseError(
                        'href not found in <reference> tag: '
                        + etree.tostring(ref, encoding='unicode')
                    )
                if ref.get('type') is None:
                    raise EpubParseError(
                        'type not found in <reference> tag: '
                        + etree.tostring(ref, encoding='unicode')
                    )
                href = ref.get('href')
                _, _, _, _, frag = urlsplit(href)
                href = relpath(pub.full_path(href), pub.nav_directory)
                if frag:
                    href += '#' + frag
                type = ref.get('type')
                if type == 'text':
                    type = 'bodymatter'
                title = ref.get('title')
                if title is None:
                    title = ''
                pub.landmarks.add_entry(href, title, type=type)

        return pub
