#!/usr/bin/env python3
# Copyright: 2026, 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.

"""Tests for parsers.py - verifies return shapes and helper behaviour."""

import bz2
import gzip
import lzma
import re
import shutil
import subprocess
import tempfile
import unittest
import warnings
from pathlib import Path

import xdg.Mime

from decopy.common.core_types import CopyrightHolder, Options
from decopy.fstree import parsers


def _make_options(**kwargs) -> Options:
    defaults: dict = {
        "mode": "full",
        "copyright_file": "debian/copyright",
        "debug": False,
        "text": True,
        "group_by": "license",
        "split_on_license": False,
        "split_debian": False,
        "glob": False,
        "progress": False,
        "jobs": 1,
        "output": "",
        "root": ".",
        "files": [],
        "exclude_fullname_re": re.compile(""),
        "exclude_special_re": re.compile(""),
        "exclude_file_re": re.compile(""),
        "exclude_directory_re": re.compile(""),
    }
    defaults.update(kwargs)
    return Options(**defaults)


class TestParseFile(unittest.TestCase):
    """parse_file returns (list[CopyrightHolder], ValuesView[str])."""

    def test_returns_tuple_of_list_and_iterable_of_strings(self):
        options = _make_options(text=True)
        holders, licenses = parsers.parse_file("tests/testdata/one/bsd.txt", options)
        self.assertIsInstance(holders, list)
        for lic in licenses:
            self.assertIsInstance(lic, str)

    def test_holders_are_copyright_holder_instances(self):
        options = _make_options(text=True)
        # decopy source files carry copyright headers and are always available;
        # use the imported module's own file so this works both in-tree and
        # when running against the installed package (e.g. autopkgtest).
        assert parsers.__file__ is not None
        holders, _ = parsers.parse_file(parsers.__file__, options)
        self.assertGreater(len(holders), 0)
        for holder in holders:
            self.assertIsInstance(holder, CopyrightHolder)

    def test_empty_file_returns_empty_holders(self):
        options = _make_options(text=True)
        holders, _ = parsers.parse_file("tests/testdata/one/empty.txt", options)
        self.assertIsInstance(holders, list)
        self.assertEqual(holders, [])

    def test_binary_file_is_skipped(self):
        options = _make_options(text=False)
        with tempfile.NamedTemporaryFile(suffix=".o", delete=False) as f:
            f.write(b"\x7fELF\x00\x00Copyright 2024 Ghost <ghost@example.com>\x00")
            path = f.name
        try:
            holders, _ = parsers.parse_file(path, options)
            self.assertEqual(holders, [])
        finally:
            Path(path).unlink()


class TestHasControlChars(unittest.TestCase):
    """has_control_chars identifies binary-like byte sequences."""

    def test_printable_bytes_returns_false(self):
        self.assertFalse(parsers.has_control_chars(b"hello world\n"))

    def test_tab_lf_cr_are_allowed(self):
        self.assertFalse(parsers.has_control_chars(b"\t\n\r"))

    def test_null_byte_returns_true(self):
        self.assertTrue(parsers.has_control_chars(b"hello\x00world"))

    def test_del_returns_true(self):
        self.assertTrue(parsers.has_control_chars(b"hello\x7fworld"))

    def test_other_control_char_returns_true(self):
        self.assertTrue(parsers.has_control_chars(b"hello\x01world"))


_COPYRIGHT_BYTES = (
    b"# Copyright (C) 2024 Test Author <test@example.com>\n"
    b"# SPDX-License-Identifier: MIT\n"
)


class TestParserDispatch(unittest.TestCase):
    """lookup_mimetypes routes each compression MIME type to the correct parser."""

    def _lookup(self, ext: str) -> parsers.ParserFunc:
        return parsers.lookup_mimetypes(xdg.Mime.get_type(f"dummy{ext}"))

    def test_gz_uses_zcat(self):
        self.assertIs(self._lookup(".gz"), parsers.cmd_parser_factory("zcat"))

    def test_bz2_uses_bzcat(self):
        self.assertIs(self._lookup(".bz2"), parsers.cmd_parser_factory("bzcat"))

    def test_xz_uses_xzcat(self):
        self.assertIs(self._lookup(".xz"), parsers.cmd_parser_factory("xzcat"))

    def test_lzma_uses_xzcat(self):
        self.assertIs(self._lookup(".lzma"), parsers.cmd_parser_factory("xzcat"))

    def test_zst_uses_zstdcat(self):
        self.assertIs(self._lookup(".zst"), parsers.cmd_parser_factory("zstdcat"))

    def test_lz_uses_lzip(self):
        self.assertIs(
            self._lookup(".lz"), parsers.cmd_parser_factory("lzip", "-d", "-c")
        )

    def test_bz3_uses_bzip3(self):
        self.assertIs(self._lookup(".bz3"), parsers.cmd_parser_factory("bzip3", "-d"))


class TestCompressedFileParsing(unittest.TestCase):
    """parse_file decompresses and extracts copyright info from compressed files."""

    def _parse(self, suffix: str, data: bytes) -> tuple:
        options = _make_options(text=False)
        with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
            f.write(data)
            fname = f.name
        try:
            return parsers.parse_file(fname, options)
        finally:
            Path(fname).unlink()

    def _has_author(self, holders: list) -> bool:
        return any("Test Author" in str(h) for h in holders)

    def test_gz(self):
        holders, _ = self._parse(".gz", gzip.compress(_COPYRIGHT_BYTES))
        self.assertTrue(self._has_author(holders))

    def test_bz2(self):
        holders, _ = self._parse(".bz2", bz2.compress(_COPYRIGHT_BYTES))
        self.assertTrue(self._has_author(holders))

    def test_xz(self):
        data = lzma.compress(_COPYRIGHT_BYTES, format=lzma.FORMAT_XZ)
        holders, _ = self._parse(".xz", data)
        self.assertTrue(self._has_author(holders))

    def test_lzma(self):
        data = lzma.compress(_COPYRIGHT_BYTES, format=lzma.FORMAT_ALONE)
        holders, _ = self._parse(".lzma", data)
        self.assertTrue(self._has_author(holders))

    @unittest.skipUnless(
        shutil.which("zstd") and shutil.which("zstdcat"), "zstd not installed"
    )
    def test_zst(self):
        zstd = shutil.which("zstd")
        assert zstd is not None
        result = subprocess.run(  # noqa: S603
            [zstd, "--quiet", "--stdout"],
            input=_COPYRIGHT_BYTES,
            capture_output=True,
            check=True,
        )
        holders, _ = self._parse(".zst", result.stdout)
        self.assertTrue(self._has_author(holders))

    @unittest.skipUnless(shutil.which("lzip"), "lzip not installed")
    def test_lz(self):
        lzip = shutil.which("lzip")
        assert lzip is not None
        result = subprocess.run(  # noqa: S603
            [lzip, "-c"],
            input=_COPYRIGHT_BYTES,
            capture_output=True,
            check=True,
        )
        holders, _ = self._parse(".lz", result.stdout)
        self.assertTrue(self._has_author(holders))


_PY_SNIPPET = b"# Copyright (C) 2024 Alice <alice@example.com>\n"
_HTML_SNIPPET = b"<!-- Copyright 2024 Bob <bob@example.com> -->\n<html></html>"
_PO_SNIPPET = (
    b"# Copyright 2024 Carol <carol@example.com>\n"
    b"# Translator: Dave\n"
    b'msgid "hello"\n'
    b'msgstr ""\n'
)


class TestDetectMimeByMagic(unittest.TestCase):
    """detect_mime_by_magic identifies format from header bytes."""

    def test_python_shebang(self):
        self.assertEqual(
            parsers.detect_mime_by_magic(b"#!/usr/bin/python3\n"), "text/x-python"
        )

    def test_env_python_shebang(self):
        self.assertEqual(
            parsers.detect_mime_by_magic(b"#!/usr/bin/env python3\n"), "text/x-python"
        )

    def test_perl_shebang(self):
        self.assertEqual(
            parsers.detect_mime_by_magic(b"#!/usr/bin/perl\n"), "text/x-perl"
        )

    def test_xml_declaration(self):
        self.assertEqual(
            parsers.detect_mime_by_magic(b"<?xml version='1.0'?>"), "text/xml"
        )

    def test_html_doctype(self):
        self.assertEqual(parsers.detect_mime_by_magic(b"<!DOCTYPE html>"), "text/html")

    def test_html_tag(self):
        self.assertEqual(parsers.detect_mime_by_magic(b"<html>"), "text/html")

    def test_html_tag_case_insensitive(self):
        self.assertEqual(parsers.detect_mime_by_magic(b"<HTML>"), "text/html")

    def test_leading_whitespace_before_html(self):
        self.assertEqual(parsers.detect_mime_by_magic(b"  <html>"), "text/html")

    def test_non_python_shebang_returns_none(self):
        self.assertIsNone(parsers.detect_mime_by_magic(b"#!/bin/bash\necho hello"))

    def test_unknown_returns_none(self):
        self.assertIsNone(parsers.detect_mime_by_magic(b"Hello, world!"))

    def test_empty_returns_none(self):
        self.assertIsNone(parsers.detect_mime_by_magic(b""))


class TestContentParsers(unittest.TestCase):
    """Unit tests for the _*_content_parser helpers."""

    def test_generic_extracts_copyright(self):
        data = b"# Copyright 2024 Eve <eve@example.com>\n"
        holders, _ = parsers.generic_content_parser("test.txt", data)
        self.assertTrue(any("Eve" in str(h) for h in holders))

    def test_html_extracts_copyright_from_comment(self):
        holders, _ = parsers.html_content_parser("test.html", _HTML_SNIPPET)
        self.assertTrue(any("Bob" in str(h) for h in holders))

    def test_html_does_not_crash_on_bad_entities(self):
        holders, _ = parsers.html_content_parser(
            "test.html", b"<!-- Copyright &amp;&amp; -->\n"
        )
        self.assertIsInstance(holders, list)

    def test_po_extracts_copyright_from_comment(self):
        holders, _ = parsers.po_content_parser("test.po", _PO_SNIPPET)
        self.assertTrue(any("Carol" in str(h) for h in holders))

    def test_po_ignores_msgid_lines(self):
        holders, _ = parsers.po_content_parser("test.po", _PO_SNIPPET)
        all_text = " ".join(str(h) for h in holders)
        self.assertNotIn("hello", all_text)

    def test_python_extracts_copyright_from_comment(self):
        holders, _ = parsers.python_content_parser("test.py", _PY_SNIPPET)
        self.assertTrue(any("Alice" in str(h) for h in holders))

    def test_python_falls_back_on_invalid_syntax(self):
        holders, _ = parsers.python_content_parser("bad.py", b"\xff\xfe garbage ~~~")
        self.assertIsInstance(holders, list)

    def test_python_invalid_escape_emits_no_syntaxwarning(self):
        # Foreign source (e.g. ruff fixtures) may contain invalid escape
        # sequences such as f"\{x}". Tokenizing it to extract comments must not
        # leak a SyntaxWarning to the user's stderr.
        data = b'# Copyright 2024 Alice <alice@example.com>\nx = f"\\{foo}\\{bar:\\}"\n'
        with warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter("always")
            parsers.python_content_parser("frag.py", data)
        leaked = [w for w in caught if issubclass(w.category, SyntaxWarning)]
        self.assertFalse(leaked, "tokenizing foreign source leaked a SyntaxWarning")


class TestLookupContentParser(unittest.TestCase):
    """lookup_content_parser routes MIME types to content parsers."""

    def _lookup(self, ext: str) -> parsers.ContentParserFunc:
        return parsers.lookup_content_parser(xdg.Mime.get_type(f"dummy{ext}"))

    def test_python_uses_python_content_parser(self):
        self.assertIs(self._lookup(".py"), parsers.python_content_parser)

    def test_html_uses_html_content_parser(self):
        self.assertIs(self._lookup(".html"), parsers.html_content_parser)

    def test_po_uses_po_content_parser(self):
        self.assertIs(self._lookup(".po"), parsers.po_content_parser)

    def test_gz_uses_cmd_content_parser_factory_result(self):
        parser = self._lookup(".gz")
        self.assertIs(parser, parsers.cmd_content_parser_factory("zcat"))

    def test_tar_uses_generic_content_parser(self):
        self.assertIs(self._lookup(".tar"), parsers.generic_content_parser)

    def test_unknown_extension_falls_back_to_generic(self):
        self.assertIs(self._lookup(".xyzzy_unknown"), parsers.generic_content_parser)


class TestUnifiedParserRegistry(unittest.TestCase):
    """One registry of Parser objects; the path/bytes lookups are views over it."""

    def test_registry_holds_parser_objects(self):
        self.assertTrue(parsers.KNOWN_PARSERS)
        self.assertTrue(
            all(isinstance(p, parsers.Parser) for p in parsers.KNOWN_PARSERS.values())
        )

    def test_lookup_parser_pairs_both_entry_points(self):
        p = parsers.lookup_parser(xdg.Mime.get_type("dummy.gz"))
        self.assertIs(p.from_path, parsers.cmd_parser_factory("zcat"))
        self.assertIs(p.from_bytes, parsers.cmd_content_parser_factory("zcat"))

    def test_lookups_are_views_over_lookup_parser(self):
        xt = xdg.Mime.get_type("dummy.py")
        p = parsers.lookup_parser(xt)
        self.assertIs(parsers.lookup_mimetypes(xt), p.from_path)
        self.assertIs(parsers.lookup_content_parser(xt), p.from_bytes)

    def test_unknown_falls_back_to_generic_parser_object(self):
        p = parsers.lookup_parser(xdg.Mime.get_type("dummy.xyzzy_unknown"))
        self.assertIs(p.from_path, parsers.generic_parser)
        self.assertIs(p.from_bytes, parsers.generic_content_parser)


class TestParseContentBytes(unittest.TestCase):
    """parse_content_bytes integration: dispatch and result shape."""

    def test_text_mode_uses_generic_regardless_of_extension(self):
        opts = _make_options(text=True)
        data = b"# Copyright 2024 Grace <grace@example.com>\n"
        holders, _ = parsers.parse_content_bytes("script.py", data, opts)
        self.assertTrue(any("Grace" in str(h) for h in holders))

    def test_python_extension_finds_copyright(self):
        opts = _make_options(text=False)
        data = b"# Copyright 2024 Henry <henry@example.com>\n"
        holders, _ = parsers.parse_content_bytes("script.py", data, opts)
        self.assertTrue(any("Henry" in str(h) for h in holders))

    def test_magic_detection_shebang_for_extensionless_file(self):
        opts = _make_options(text=False)
        data = b"#!/usr/bin/python3\n# Copyright 2024 Iris <iris@example.com>\n"
        holders, _ = parsers.parse_content_bytes("Makefile", data, opts)
        self.assertTrue(any("Iris" in str(h) for h in holders))

    def test_returns_list_and_iterable_for_empty_bytes(self):
        opts = _make_options(text=False)
        holders, licenses = parsers.parse_content_bytes("empty.txt", b"", opts)
        self.assertIsInstance(holders, list)
        for lic in licenses:
            self.assertIsInstance(lic, str)

    def test_binary_content_is_skipped(self):
        opts = _make_options(text=False)
        data = b"\x7fELF\x00\x00Copyright 2024 Ghost <ghost@example.com>\x00"
        holders, _ = parsers.parse_content_bytes("blob.o", data, opts)
        self.assertEqual(holders, [])

    def test_binary_skip_not_applied_in_text_mode(self):
        # --text forces text handling; honour it even for binary bytes.
        opts = _make_options(text=True)
        data = b"\x00 Copyright 2024 Ghost <ghost@example.com>\x00"
        holders, _ = parsers.parse_content_bytes("blob.o", data, opts)
        self.assertTrue(any("Ghost" in str(h) for h in holders))

    def test_text_with_unknown_mime_still_scanned(self):
        opts = _make_options(text=False)
        data = b"Copyright 2024 Henry <henry@example.com>\n"
        holders, _ = parsers.parse_content_bytes("notes.xyz", data, opts)
        self.assertTrue(any("Henry" in str(h) for h in holders))

    def test_utf16_bom_text_not_skipped(self):
        # UTF-16 text legitimately contains NUL bytes; the BOM exempts it.
        opts = _make_options(text=False)
        data = "Copyright 2024 Ivy <ivy@example.com>".encode("utf-16")
        holders, _ = parsers.parse_content_bytes("blob.o", data, opts)
        self.assertTrue(any("Ivy" in str(h) for h in holders))


if __name__ == "__main__":
    unittest.main()
