#!/usr/bin/env python3
# Copyright: 2016, Maximiliano Curia <maxy@debian.org>
#
# License: ISC
#  Permission to use, copy, modify, and/or distribute this software for any
#  purpose with or without fee is hereby granted, provided that the above
#  copyright notice and this permission notice appear in all copies.
#  .
#  THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH
#  REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
#  AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT,
#  INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
#  LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE
#  OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
#  PERFORMANCE OF THIS SOFTWARE.

"""File Parsers"""

from __future__ import annotations

import codecs
import collections
import collections.abc as abc
import contextlib
import dataclasses
import functools
import html
import io
import re as reo
import shutil
import string
import subprocess
import tempfile
import tokenize
import warnings
from pathlib import Path
from typing import TYPE_CHECKING

import xdg.Mime

from decopy.common._regex import rex
from decopy.common.log import logger
from decopy.common.re_types import ParseResult

# Local modules
from decopy.licensing.holders import parse_holders
from decopy.licensing.licenses import clean_comments, find_licenses

if TYPE_CHECKING:
    from decopy.common.core_types import Options

# initialize xdg cache before using it
xdg.Mime.update_cache()

PRINTABLE_BYTES: bytes = bytes(string.printable, encoding="ascii")

type ParserFunc = abc.Callable[[str], ParseResult]
type ContentParserFunc = abc.Callable[[str, bytes], ParseResult]


@dataclasses.dataclass(frozen=True)
class Parser:
    """A MIME format's two entry points, paired in a single registry row.

    `from_path` parses a file on disk; `from_bytes` parses in-memory bytes (an
    archive member). Pairing them means adding a format touches one entry in
    KNOWN_PARSERS rather than two parallel tables kept in sync by hand.
    """

    from_path: ParserFunc
    from_bytes: ContentParserFunc


# A picklable description of what to parse, so a worker process can parse a node
# without holding the node itself: ("file", path, b"") for a file on disk, or
# ("bytes", name, content) for an in-memory archive member.
type ParseSpec = tuple[str, str, bytes]


def has_control_chars(bytes_seq: bytes) -> bool:
    for i in bytes_seq:
        # ascii control chars (C0), and delete (DEL)
        if i < 32 or i == 127:  # noqa: PLR2004
            # except tab (HT), line feed (LF) and carriage return (CR)
            if i in (9, 10, 13):
                continue
            return True
    return False


def _bytes_reader(data: bytes) -> io.BufferedReader:
    return io.BufferedReader(io.BytesIO(data))


def detect_bom(buffer: bytes) -> str:
    # This is what chardet.detect does, but chardet fallsback to ascii. :/
    # So you either need to feed all the file to chardet and read it
    # twice (or keep it in memory). Returns "utf-8" when no BOM is present.
    for encoding, boms in (
        ("UTF-8-SIG", (codecs.BOM_UTF8,)),
        ("UTF-32", (codecs.BOM_UTF32_LE, codecs.BOM_UTF32_BE)),
        ("X-ISO-10646-UCS-4-3412", (b"\xfe\xff\x00\x00",)),
        ("X-ISO-10646-UCS-4-2143", (b"\x00\x00\xff\xfe",)),
        ("UTF-16", (codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)),
    ):
        if buffer.startswith(boms):
            return encoding
    return "utf-8"


def generic_input(reader: io.BufferedReader) -> abc.Iterator[str]:
    buffer = reader.peek(4)
    default_encoding = detect_bom(buffer)
    fallback_encoding = "latin1"

    for raw_line in reader:
        try:
            line = raw_line.decode(default_encoding)
        except UnicodeDecodeError:
            if has_control_chars(raw_line):
                # binary?
                chars = (
                    chr(char) if char in PRINTABLE_BYTES else "\n" for char in raw_line
                )
                line = "".join(chars)
                # Drop extra new lines
                line = reo.sub(r"\n{2,}", "\n", line)
            else:
                line = raw_line.decode(fallback_encoding)
        # convert mac to unix
        line = reo.sub(r"\r([^\n])", r"\n\1", line)
        yield line


def _process_content(content: str) -> ParseResult:
    "Process file contents"

    holders = parse_holders(content)
    content = clean_comments(content)

    licenses = find_licenses(content)

    return holders, licenses


def _generic_parse(reader: io.BufferedReader) -> ParseResult:
    return _process_content("".join(generic_input(reader)))


def generic_parser(filename: str) -> ParseResult:
    """Generic read, treat all files as text

    Binary content is filtered ala 'strings(1)'.
    Bom is detected, files with no bom are treated as utf-8 files, but lines
    that fail to be decoded are decoded with latin1.
    """

    with Path(filename).open("rb") as f:
        return _generic_parse(f)


def generic_content_parser(_name: str, data: bytes) -> ParseResult:
    return _generic_parse(_bytes_reader(data))


def _subprocess_output(result: subprocess.CompletedProcess[bytes]) -> str:
    content = "".join(generic_input(_bytes_reader(result.stdout)))
    content += "".join(generic_input(_bytes_reader(result.stderr)))
    return content


@functools.lru_cache
def cmd_parser_factory(*cmd: str) -> ParserFunc:
    def _parser(filename: str) -> ParseResult:
        fullcmd = [*cmd, filename]

        try:
            result = subprocess.run(  # noqa: S603
                fullcmd,
                check=True,
                capture_output=True,
            )
            content = _subprocess_output(result)
        except (subprocess.CalledProcessError, UnicodeDecodeError) as e:
            logger.info("failed to parse %s with %s, (%s)", filename, cmd, e)
            logger.info("falling back")
            return generic_parser(filename)

        return _process_content(content)

    if shutil.which(cmd[0]) is None:
        logger.warning("command %s not found, using generic parser as fallback", cmd[0])
        return generic_parser

    return _parser


@functools.lru_cache
def cmd_content_parser_factory(*cmd: str) -> ContentParserFunc:
    if shutil.which(cmd[0]) is None:
        logger.warning("command %s not found, using generic parser as fallback", cmd[0])
        return generic_content_parser

    def _content_parser(name: str, data: bytes) -> ParseResult:
        suffix = Path(name).suffix or ""
        tmp_path: str | None = None
        try:
            with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
                tmp_path = tmp.name
                tmp.write(data)
            result = subprocess.run(  # noqa: S603
                [*cmd, tmp_path],
                capture_output=True,
                check=True,
            )
            content = _subprocess_output(result)
        except (OSError, subprocess.CalledProcessError) as e:
            logger.info("failed to parse %s with %s: %s", name, cmd, e)
            return generic_content_parser(name, data)
        finally:
            if tmp_path is not None:
                with contextlib.suppress(OSError):
                    Path(tmp_path).unlink()
        return _process_content(content)

    return _content_parser


def _html_parse(reader: io.BufferedReader, name: str = "") -> ParseResult:
    # Keep in mind that most template systems will also match this type. We
    # could use beautifulsoup to obtain the text and the html comments, but we
    # would loose the templating system comments, and the comments of the js
    # parts.
    content = "".join(generic_input(reader))
    # Not sure if this should go after the unescape
    content = reo.sub(r"<\s*br\s*/?>", "\n", content)
    try:
        content = html.unescape(content)
    except Exception as e:
        logger.info("failed to unescape html entities from %s, (%s)", name, e)
        logger.info("using content as is")
    return _process_content(content)


def html_parser(filename: str) -> ParseResult:
    "Drop/convert html entities, and brs"
    with Path(filename).open("rb") as f:
        return _html_parse(f, filename)


def html_content_parser(name: str, data: bytes) -> ParseResult:
    return _html_parse(_bytes_reader(data), name)


POUND_LINE_RE: rex.Pattern[str] = rex.compile(r"^\s*#")


def pound_lines_parser(filename: str) -> ParseResult:
    """Keep only the lines starting with #"""

    lines = []
    with Path(filename).open("rb") as f:
        for line in generic_input(f):
            if POUND_LINE_RE.match(line):
                lines.append(line)
            else:
                lines.append("\n")

    content = "".join(lines)

    return _process_content(content)


PO_COMMENTED_TRANSLATION_RE: rex.Pattern[str] = rex.compile(
    r"^\s*#.{,2} msg(?:str|id|ctxt)\b",
)


def _po_parse(reader: io.BufferedReader) -> ParseResult:
    lines = []
    for line in generic_input(reader):
        if not POUND_LINE_RE.match(line) or PO_COMMENTED_TRANSLATION_RE.match(line):
            lines.append("\n")
        else:
            lines.append(line)
    return _process_content("".join(lines))


def po_file_parser(filename: str) -> ParseResult:
    """Keep only the comments (ignore also #~ msgid lines)"""
    with Path(filename).open("rb") as f:
        return _po_parse(f)


def po_content_parser(_name: str, data: bytes) -> ParseResult:
    return _po_parse(_bytes_reader(data))


def _python_parse(readline: abc.Callable[[], bytes]) -> ParseResult:
    lines = []
    newline = True
    # We tokenize third-party source only to harvest comments/docstrings; its
    # own lexical warnings (e.g. invalid escape sequences like f"\{x}") are not
    # ours to report, so keep them off the user's stderr.
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        for token in tokenize.tokenize(readline):
            if (token.type == tokenize.COMMENT) or (
                newline and token.type == tokenize.STRING
            ):
                lines.append(token.string)
            elif token.type == tokenize.NEWLINE:
                newline = True
            elif newline and (
                token.type in {tokenize.INDENT, tokenize.NL, tokenize.ENCODING}
            ):
                continue
            else:
                newline = False
    return _process_content("\n".join(lines))


def python_parser(filename: str) -> ParseResult:
    """Extract comments and doc strings from a python file"""
    with Path(filename).open("rb") as f:
        return _python_parse(f.readline)


def python_content_parser(name: str, data: bytes) -> ParseResult:
    try:
        return _python_parse(io.BytesIO(data).readline)
    except (tokenize.TokenError, SyntaxError):
        return generic_content_parser(name, data)


KNOWN_CMD_PARSERS: dict[str, tuple[str, ...]] = {
    "application/gzip": ("zcat",),
    "application/x-bzip2": ("bzcat",),  # .bz2 (application/x-bzip was a dead alias)
    "application/x-bzip3": ("bzip3", "-d"),  # .bz3
    "application/x-lzma": ("xzcat",),  # .lzma
    "application/x-xz": ("xzcat",),  # .xz
    "application/zstd": ("zstdcat",),  # .zst
    "application/x-lzip": ("lzip", "-d", "-c"),  # .lz
    "image/svg+xml-compressed": ("zcat",),
    # "application/bzip2":         ("exiftool",),
    # "application/json":          ("exiftool",),
    # "application/octet-stream":  ("exiftool",),
    # "application/postscript":    ("exiftool",),
    # "application/rdf+xml":       ("exiftool",),
    # "application/x-bittorrent":  ("exiftool",),
    # "application/x-gzip":        ("exiftool",),
    # "application/x-httpd-php":   ("exiftool",),
    # "application/x-rar-compressed": ("exiftool",),
    # "application/x-tar":         ("exiftool",),
    # "application/xml":           ("exiftool",),
    # "application/zip":           ("exiftool",),
    # "image/svg+xml":             ("exiftool",),
    # "text/calendar":             ("exiftool",),
    # "text/html":                 ("exiftool",),
    # "text/rtf":                  ("exiftool",),
    # "text/vcard":                ("exiftool",),
    "application/ResEdit": ("exiftool",),
    "application/dicom": ("exiftool",),
    "application/itunes": ("exiftool",),
    "application/msword": ("exiftool",),
    "application/mxf": ("exiftool",),
    "application/pdf": ("exiftool",),
    "application/sketch": ("exiftool",),
    "application/vnd.adobe.fla": ("exiftool",),
    "application/vnd.adobe.illustrator": ("exiftool",),
    "application/vnd.adobe.indesign-idml-package": ("exiftool",),
    "application/vnd.adobe.photoshop": ("exiftool",),
    "application/vnd.iccprofile": ("exiftool",),
    "application/vnd.ms-excel": ("exiftool",),
    "application/vnd.ms-excel.addin.macroEnabled": ("exiftool",),
    "application/vnd.ms-excel.sheet.binary.macroEnabled": ("exiftool",),
    "application/vnd.ms-excel.sheet.macroEnabled": ("exiftool",),
    "application/vnd.ms-excel.template.macroEnabled": ("exiftool",),
    "application/vnd.ms-officetheme": ("exiftool",),
    "application/vnd.ms-powerpoint": ("exiftool",),
    "application/vnd.ms-powerpoint.presentation.macroEnabled": ("exiftool",),
    "application/vnd.ms-powerpoint.slideshow.macroEnabled": ("exiftool",),
    "application/vnd.ms-powerpoint.template.macroEnabled": ("exiftool",),
    "application/vnd.ms-word.document.macroEnabled": ("exiftool",),
    "application/vnd.ms-word.template.macroEnabledTemplate": ("exiftool",),
    "application/vnd.oasis.opendocument.chart": ("exiftool",),
    "application/vnd.oasis.opendocument.database": ("exiftool",),
    "application/vnd.oasis.opendocument.formula": ("exiftool",),
    "application/vnd.oasis.opendocument.graphics": ("exiftool",),
    "application/vnd.oasis.opendocument.image": ("exiftool",),
    "application/vnd.oasis.opendocument.presentation": ("exiftool",),
    "application/vnd.oasis.opendocument.spreadsheet": ("exiftool",),
    "application/vnd.oasis.opendocument.text": ("exiftool",),
    "application/vnd.openxmlformats-officedocument.presentationml.presentation": (
        "exiftool",
    ),
    "application/vnd.openxmlformats-officedocument.presentationml.slideshow": (
        "exiftool",
    ),
    "application/vnd.openxmlformats-officedocument.presentationml.template": (
        "exiftool",
    ),
    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ("exiftool",),
    "application/vnd.openxmlformats-officedocument.spreadsheetml.template": (
        "exiftool",
    ),
    "application/vnd.openxmlformats-officedocument.wordprocessingml.document": (
        "exiftool",
    ),
    "application/vnd.openxmlformats-officedocument.wordprocessingml.template": (
        "exiftool",
    ),
    "application/vnd.palm": ("exiftool",),
    "application/vnd.rn-realmedia": ("exiftool",),
    "application/vnd.rn-realmedia-vbr": ("exiftool",),
    "application/x-3ds": ("exiftool",),
    "application/x-captureone": ("exiftool",),
    "application/x-chm": ("exiftool",),
    "application/x-dfont": ("exiftool",),
    "application/x-font-otf": ("exiftool",),
    "application/x-font-ttf": ("exiftool",),
    "application/x-font-type1": ("exiftool",),
    "application/x-indesign": ("exiftool",),
    "application/x-indesign-interchange": ("exiftool",),
    "application/x-iso9660-image": ("exiftool",),
    "application/x-iwork-keynote-sffkey": ("exiftool",),
    "application/x-iwork-numbers-sffnumbers": ("exiftool",),
    "application/x-iwork-pages-sffpages": ("exiftool",),
    "application/x-magick-image": ("exiftool",),
    "application/x-matroska": ("exiftool",),
    "application/x-mie": ("exiftool",),
    "application/x-mobipocket-ebook": ("exiftool",),
    "application/x-shockwave-flash": ("exiftool",),
    "application/x-visio": ("exiftool",),
    "application/x-wmf": ("exiftool",),
    "audio/audible": ("exiftool",),
    "audio/flac": ("exiftool",),
    "audio/mpeg": ("exiftool",),
    "audio/ogg": ("exiftool",),
    "audio/x-aiff": ("exiftool",),
    "audio/x-ds2": ("exiftool",),
    "audio/x-dss": ("exiftool",),
    "audio/x-matroska": ("exiftool",),
    "audio/x-monkeys-audio": ("exiftool",),
    "audio/x-ms-wma": ("exiftool",),
    "audio/x-musepack": ("exiftool",),
    "audio/x-pn-realaudio": ("exiftool",),
    "audio/x-pn-realaudio-plugin": ("exiftool",),
    "font/ttf": ("exiftool",),
    "image/apng": ("exiftool",),
    "image/bmp": ("exiftool",),
    "image/bpg": ("exiftool",),
    "image/flif": ("exiftool",),
    "image/gif": ("exiftool",),
    "image/jng": ("exiftool",),
    "image/jp2": ("exiftool",),
    "image/jpeg": ("exiftool",),
    "image/jpm": ("exiftool",),
    "image/jpx": ("exiftool",),
    "image/pgf": ("exiftool",),
    "image/pict": ("exiftool",),
    "image/png": ("exiftool",),
    "image/tiff": ("exiftool",),
    "image/vnd.djvu": ("exiftool",),
    "image/vnd.fpx": ("exiftool",),
    "image/vnd.ms-photo": ("exiftool",),
    "image/vnd.radiance": ("exiftool",),
    "image/x-adobe-dng": ("exiftool",),
    "image/x-canon-cr2": ("exiftool",),
    "image/x-canon-cr3": ("exiftool",),
    "image/x-canon-crw": ("exiftool",),
    "image/x-dpx": ("exiftool",),
    "image/x-epson-erf": ("exiftool",),
    "image/x-exr": ("exiftool",),
    "image/x-exv": ("exiftool",),
    "image/x-fujifilm-raf": ("exiftool",),
    "image/x-gopro-gpr": ("exiftool",),
    "image/x-hasselblad-3fr": ("exiftool",),
    "image/x-hasselblad-fff": ("exiftool",),
    "image/x-j2c": ("exiftool",),
    "image/x-kodak-dcr": ("exiftool",),
    "image/x-kodak-k25": ("exiftool",),
    "image/x-kodak-kdc": ("exiftool",),
    "image/x-leica-rwl": ("exiftool",),
    "image/x-light-lri": ("exiftool",),
    "image/x-lytro-lfp": ("exiftool",),
    "image/x-mamiya-mef": ("exiftool",),
    "image/x-minolta-mrw": ("exiftool",),
    "image/x-nikon-nef": ("exiftool",),
    "image/x-nikon-nrw": ("exiftool",),
    "image/x-olympus-orf": ("exiftool",),
    "image/x-paintshoppro": ("exiftool",),
    "image/x-panasonic-rw2": ("exiftool",),
    "image/x-pentax-pef": ("exiftool",),
    "image/x-photo-cd": ("exiftool",),
    "image/x-portable-bitmap": ("exiftool",),
    "image/x-portable-graymap": ("exiftool",),
    "image/x-portable-pixmap": ("exiftool",),
    "image/x-quicktime": ("exiftool",),
    "image/x-raw": ("exiftool",),
    "image/x-rawzor": ("exiftool",),
    "image/x-samsung-srw": ("exiftool",),
    "image/x-sigma-x3f": ("exiftool",),
    "image/x-sony-arw": ("exiftool",),
    "image/x-sony-sr2": ("exiftool",),
    "image/x-sony-srf": ("exiftool",),
    "image/x-tiff-big": ("exiftool",),
    "image/x-xcf": ("exiftool",),
    "video/3gp": ("exiftool",),
    "video/3gpp": ("exiftool",),
    "video/3gpp2": ("exiftool",),
    "video/divx": ("exiftool",),
    "video/m2ts": ("exiftool",),
    "video/mng": ("exiftool",),
    "video/mp4": ("exiftool",),
    "video/mpeg": ("exiftool",),
    "video/ogg": ("exiftool",),
    "video/quicktime": ("exiftool",),
    "video/vnd.rn-realvideo": ("exiftool",),
    "video/webm": ("exiftool",),
    "video/x-canon-crm": ("exiftool",),
    "video/x-dv": ("exiftool",),
    "video/x-flv": ("exiftool",),
    "video/x-matroska": ("exiftool",),
    "video/x-ms-asf": ("exiftool",),
    "video/x-ms-dvr": ("exiftool",),
    "video/x-ms-wmv": ("exiftool",),
    "video/x-ms-wtv": ("exiftool",),
    "video/x-red-r3d": ("exiftool",),
}

# The generic strings-style parser, used as the fallback and for archives (which
# both entry points read as text). A single instance so identity checks work.
GENERIC = Parser(from_path=generic_parser, from_bytes=generic_content_parser)

# Formats with a dedicated parser pair (path parser + bytes parser).
_FORMAT_PARSERS: dict[str, Parser] = {
    "text/x-gettext-translation": Parser(po_file_parser, po_content_parser),
    "text/x-gettext-translation-template": Parser(po_file_parser, po_content_parser),
    "text/x-po": Parser(po_file_parser, po_content_parser),
    "text/x-pot": Parser(po_file_parser, po_content_parser),
    "text/x-python": Parser(python_parser, python_content_parser),
    "text/html": Parser(html_parser, html_content_parser),
    "application/x-php": Parser(html_parser, html_content_parser),
}

# Archive containers: both entry points fall back to the generic parser.
ARCHIVE_MIMES: frozenset[str] = frozenset(
    {
        "application/x-compressed-tar",  # .tar.gz / .tgz
        "application/x-xz-compressed-tar",  # .tar.xz
        "application/x-bzip2-compressed-tar",  # .tar.bz2
        "application/x-bzip1-compressed-tar",  # .tar.bz (bzip1)
        "application/x-bzip3-compressed-tar",  # .tar.bz3
        "application/x-zstd-compressed-tar",  # .tar.zst
        "application/x-lzip-compressed-tar",  # .tar.lz
        "application/x-lzma-compressed-tar",  # .tar.lzma
        "application/x-tar",  # .tar
        "application/zip",  # .zip / .jar / .war
    }
)

# The single MIME -> Parser registry. Each row carries both entry points, so a
# new format is added in exactly one place.
KNOWN_PARSERS: dict[str, Parser] = {
    mime: Parser(cmd_parser_factory(*cmd), cmd_content_parser_factory(*cmd))
    for mime, cmd in KNOWN_CMD_PARSERS.items()
}
KNOWN_PARSERS.update(_FORMAT_PARSERS)
KNOWN_PARSERS.update(dict.fromkeys(ARCHIVE_MIMES, GENERIC))


def _mime_bfs(xdg_type: xdg.Mime.MIMEtype) -> abc.Iterator[str]:
    queue: collections.deque[xdg.Mime.MIMEtype] = collections.deque([xdg_type])
    seen: set[str] = set()
    while queue:
        xdg_mime = queue.popleft()
        mime = str(xdg_mime)
        if mime not in seen:
            seen.add(mime)
            yield mime
        queue.extend(xdg_mime.inherits_from())
        logger.debug("Original type: %s, queue: %s", xdg_type, queue)


def lookup_parser(xdg_type: xdg.Mime.MIMEtype) -> Parser:
    """The Parser for a MIME type (walking its inheritance), or GENERIC.

    Single source of dispatch: the path- and bytes-side lookups below are views
    of this, so both entry points always agree on which format a MIME resolves
    to.
    """
    for mime in _mime_bfs(xdg_type):
        parser = KNOWN_PARSERS.get(mime)
        if parser is not None:
            return parser
    return GENERIC


def lookup_mimetypes(xdg_type: xdg.Mime.MIMEtype) -> ParserFunc:
    """The path-side parser for a MIME type (a view of lookup_parser)."""
    return lookup_parser(xdg_type).from_path


# When no specialised parser/tool matches, the generic parser does strings-style
# extraction. Running that over a binary file yields garbage holders, so skip a
# binary file at the fallback. "Binary" = a NUL byte in the first chunk, except a
# BOM-declared multi-byte text encoding (UTF-16/32/UCS-4 legitimately contain
# NULs; detect_bom recognises them and generic_input decodes accordingly).
_BINARY_SNIFF_BYTES = 8192
# Empty licenses list, for the "nothing parsed" results below.
_NO_LICENSES: list[str] = []


def _is_binary_bytes(data: bytes) -> bool:
    if detect_bom(data[:4]) != "utf-8":
        return False
    return b"\x00" in data[:_BINARY_SNIFF_BYTES]


def _path_is_binary(fullname: str) -> bool:
    try:
        with Path(fullname).open("rb") as f:
            return _is_binary_bytes(f.read(_BINARY_SNIFF_BYTES))
    except OSError:
        return False


def parse_file(fullname: str, options: Options) -> ParseResult:
    """Parses the received file with the matching parser.

    Returns:
        The list of copyrights and licenses obtained by the parser.
    """
    if options.text:
        parser: ParserFunc = generic_parser
    else:
        xdg_type = xdg.Mime.get_type(fullname)
        logger.debug("Type for %s is: %s", fullname, xdg_type)

        parser = lookup_mimetypes(xdg_type)
        if parser is generic_parser and _path_is_binary(fullname):
            logger.debug("Skipping binary file %s (no specialised parser)", fullname)
            return [], _NO_LICENSES

    copyrights, licenses = parser(fullname)
    logger.debug("Parsed %s: %s, %s", fullname, copyrights, licenses)
    return copyrights, licenses


def detect_mime_by_magic(header: bytes) -> str | None:
    if header.startswith(b"#!"):
        shebang = header[:80].split(b"\n")[0]
        if b"python" in shebang:
            return "text/x-python"
        if b"perl" in shebang:
            return "text/x-perl"
    if header[:5] == b"<?xml":
        return "text/xml"
    stripped = header.lstrip()
    if stripped[:9].lower() == b"<!doctype" or stripped[:5].lower() == b"<html":
        return "text/html"
    return None


def lookup_content_parser(xdg_type: xdg.Mime.MIMEtype) -> ContentParserFunc:
    """The bytes-side parser for a MIME type (a view of lookup_parser)."""
    return lookup_parser(xdg_type).from_bytes


def parse_content_bytes(name: str, data: bytes, options: Options) -> ParseResult:
    """Parse copyright/license info from in-memory bytes.

    Returns:
        The list of copyrights and licenses obtained by the parser.
    """
    if options.text:
        return generic_content_parser(name, data)
    xdg_type = xdg.Mime.get_type(name)
    if str(xdg_type) == "application/octet-stream":
        magic_mime = detect_mime_by_magic(data[:512])
        if magic_mime:
            xdg_type = xdg.Mime.MIMEtype(*magic_mime.split("/", 1))
    parser = lookup_content_parser(xdg_type)
    if parser is generic_content_parser and _is_binary_bytes(data):
        logger.debug("Skipping binary content %s (no specialised parser)", name)
        return [], _NO_LICENSES
    logger.debug("Content parser for %s is: %s", name, parser)
    copyrights, licenses = parser(name, data)
    logger.debug("Parsed content %s: %s, %s", name, copyrights, licenses)
    return copyrights, licenses


def parse_from_spec(spec: ParseSpec, options: Options) -> ParseResult:
    """Parse a node from its picklable ParseSpec (file path or in-memory bytes).

    Lets a worker process parse without the node object; the kind tag is data,
    not a type check on the (unavailable) node.
    """
    kind, name, content = spec
    if kind == "file":
        return parse_file(name, options)
    return parse_content_bytes(name, content, options)
