#!/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 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 ._regex import rex

# Local modules
from .holders import parse_holders
from .licenses import clean_comments, find_licenses
from .log import logger
from .re_types import ParseResult

if TYPE_CHECKING:
    from .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]

# 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",),
}

_TEXT_PARSERS: dict[str, ParserFunc] = {
    "text/x-gettext-translation": po_file_parser,
    "text/x-gettext-translation-template": po_file_parser,
    "text/x-po": po_file_parser,
    "text/x-pot": po_file_parser,
    "text/x-python": python_parser,
    "text/html": html_parser,
    "application/x-php": html_parser,
}

_ARCHIVE_PARSERS: dict[str, ParserFunc] = {
    "application/x-compressed-tar": generic_parser,  # .tar.gz / .tgz
    "application/x-xz-compressed-tar": generic_parser,  # .tar.xz
    "application/x-bzip2-compressed-tar": generic_parser,  # .tar.bz2
    "application/x-bzip1-compressed-tar": generic_parser,  # .tar.bz (bzip1)
    "application/x-bzip3-compressed-tar": generic_parser,  # .tar.bz3
    "application/x-zstd-compressed-tar": generic_parser,  # .tar.zst
    "application/x-lzip-compressed-tar": generic_parser,  # .tar.lz
    "application/x-lzma-compressed-tar": generic_parser,  # .tar.lzma
    "application/x-tar": generic_parser,  # .tar
    "application/zip": generic_parser,  # .zip / .jar / .war
}

KNOWN_PARSERS: dict[str, ParserFunc] = {
    mime: cmd_parser_factory(*cmd) for mime, cmd in KNOWN_CMD_PARSERS.items()
}
KNOWN_PARSERS.update(_TEXT_PARSERS)
KNOWN_PARSERS.update(_ARCHIVE_PARSERS)

ARCHIVE_MIMES: frozenset[str] = frozenset(_ARCHIVE_PARSERS)

KNOWN_CONTENT_PARSERS: dict[str, ContentParserFunc] = {
    "text/x-gettext-translation": po_content_parser,
    "text/x-gettext-translation-template": po_content_parser,
    "text/x-po": po_content_parser,
    "text/x-pot": po_content_parser,
    "text/x-python": python_content_parser,
    "text/html": html_content_parser,
    "application/x-php": html_content_parser,
}


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_mimetypes(xdg_type: xdg.Mime.MIMEtype) -> ParserFunc:
    for mime in _mime_bfs(xdg_type):
        parser = KNOWN_PARSERS.get(mime)
        if parser is not None:
            return parser
    return generic_parser


# 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 view, for the "nothing parsed" results below (ParseResult's
# second slot is a ValuesView, not a list).
_NO_LICENSES: abc.ValuesView[str] = {}.values()


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:
    for mime in _mime_bfs(xdg_type):
        content_parser = KNOWN_CONTENT_PARSERS.get(mime)
        if content_parser is not None:
            return content_parser
        cmd = KNOWN_CMD_PARSERS.get(mime)
        if cmd is not None:
            return cmd_content_parser_factory(*cmd)
        if mime in _ARCHIVE_PARSERS:
            return generic_content_parser
    return generic_content_parser


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)
