"""Unit tests for the problem_report module."""

# pylint: disable=too-many-lines

import base64
import contextlib
import datetime
import email
import io
import json
import locale
import sys
import textwrap
import time
import unittest
import unittest.mock
from unittest.mock import MagicMock, patch

try:
    import zstandard
except ImportError:
    zstandard = None  # type: ignore

import problem_report

BIN_DATA = b"ABABABABAB\0\0\0Z\x01\x02"
GZIP_BIN_DATA = (
    b"\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03str\x84B\x06"
    b"\x06\x86(F&\x003\x95\xd4\x0b\x10\x00\x00\x00"
)
ZSTD_BIN_DATA = (
    b"(\xb5/\xfd$\x10\x81\x00\x00ABABABABAB\x00\x00\x00Z\x01\x02\xbc\xdf\xdd\xfd"
)


class T(unittest.TestCase):  # pylint: disable=too-many-public-methods
    """Unit tests for the problem_report module."""

    def test_add_tags(self) -> None:
        """Test ProblemReport.add_tags()."""
        report = problem_report.ProblemReport()
        report.add_tags({"tag1"})
        self.assertEqual(report["Tags"], "tag1")
        report.add_tags(["tag2", "next_tag"])
        self.assertEqual(report["Tags"], "next_tag tag1 tag2")

    def test_add_tag_drop_duplicates(self) -> None:
        """Test ProblemReport.add_tags() dropping duplicates."""
        report = problem_report.ProblemReport()
        report.add_tags({"same"})
        self.assertEqual(report["Tags"], "same")
        report.add_tags(["same"])
        self.assertEqual(report["Tags"], "same")

    def test_basic_operations(self) -> None:
        """Basic creation and operation."""
        pr = problem_report.ProblemReport()
        pr["foo"] = "bar"
        pr["bar"] = " foo   bar\nbaz\n   blip  "
        pr["dash-key"] = "1"
        pr["dot.key"] = "1"
        pr["underscore_key"] = "1"
        self.assertEqual(pr["foo"], "bar")
        self.assertEqual(pr["bar"], " foo   bar\nbaz\n   blip  ")
        self.assertEqual(pr["ProblemType"], "Crash")
        locale_time = locale.getlocale(locale.LC_TIME)
        locale.setlocale(locale.LC_TIME, "C")
        try:
            self.assertTrue(time.strptime(pr["Date"]))
        finally:
            locale.setlocale(locale.LC_TIME, locale_time)
        self.assertEqual(pr["dash-key"], "1")
        self.assertEqual(pr["dot.key"], "1")
        self.assertEqual(pr["underscore_key"], "1")

    def test_ctor_arguments(self) -> None:
        """non-default constructor arguments."""
        pr = problem_report.ProblemReport("KernelCrash")
        self.assertEqual(pr["ProblemType"], "KernelCrash")
        pr = problem_report.ProblemReport(date="19801224 12:34")
        self.assertEqual(pr["Date"], "19801224 12:34")

    def test_get_timestamp(self) -> None:
        """get_timestamp() returns timestamp."""
        r = problem_report.ProblemReport()
        timestamp = r.get_timestamp()
        assert timestamp
        self.assertAlmostEqual(timestamp, time.time(), delta=2)

        r["Date"] = "Thu Jan 9 12:00:00 2014"
        # delta is ±12 hours, as this depends on the timezone that the test is
        # run in
        timestamp = r.get_timestamp()
        assert timestamp
        self.assertAlmostEqual(timestamp, 1389265200, delta=43200)

    def test_get_timestamp_locale_german(self) -> None:
        """get_timestamp() returns date when LC_TIME is set."""
        now = datetime.datetime.now()

        pr = problem_report.ProblemReport(date=now.strftime("%a %b %d %H:%M:%S %Y"))
        orig_ctime = locale.getlocale(locale.LC_TIME)
        try:
            locale.setlocale(locale.LC_TIME, "de_DE.UTF-8")
        except locale.Error:
            self.skipTest("Missing German locale support")
        self.assertEqual(pr.get_timestamp(), int(now.timestamp()))
        locale.setlocale(locale.LC_TIME, orig_ctime)

    def test_get_timestamp_returns_none(self) -> None:
        """get_timestamp() returns None."""
        pr = problem_report.ProblemReport()
        del pr["Date"]
        self.assertIsNone(pr.get_timestamp())

    def test_consistency_checks(self) -> None:
        """Various error conditions."""
        pr = problem_report.ProblemReport()
        self.assertRaises(
            problem_report.InvalidProblemReportKeyError, pr.__setitem__, "a b", "1"
        )
        self.assertRaises(TypeError, pr.__setitem__, "a", 1)
        self.assertRaises(TypeError, pr.__setitem__, "a", (1,))
        self.assertRaises(TypeError, pr.__setitem__, "a", ("/tmp/nonexistent", ""))
        self.assertRaises(
            TypeError,
            pr.__setitem__,
            "a",
            ("/tmp/nonexistent", False, 0, True, "bogus"),
        )
        self.assertRaises(TypeError, pr.__setitem__, "a", ["/tmp/nonexistent"])
        self.assertRaises(KeyError, pr.__getitem__, "Nonexistent")

    def test_write(self) -> None:
        """write() and proper formatting."""
        pr = problem_report.ProblemReport(date="now!")
        pr["Simple"] = "bar"
        pr["SimpleUTF8"] = "1äö2Φ3".encode()
        pr["SimpleUnicode"] = "1äö2Φ3"
        pr["TwoLineUnicode"] = "pi-π\nnu-η"
        pr["TwoLineUTF8"] = "pi-π\nnu-η".encode()
        pr["WhiteSpace"] = " foo   bar\nbaz\n  blip  \n\nafteremptyline"
        # Unicode with a non-space low ASCII character \x05 in it
        pr["UnprintableUnicode"] = b"a\xc3\xa4\x05z1\xc3\xa9".decode("UTF-8")
        out = io.BytesIO()
        pr.write(out)
        expected = (textwrap.dedent("""\
            ProblemType: Crash
            Date: now!
            Simple: bar
            SimpleUTF8: 1äö2Φ3
            SimpleUnicode: 1äö2Φ3
            TwoLineUTF8:
             pi-π
             nu-η
            TwoLineUnicode:
             pi-π
             nu-η
            UnprintableUnicode: aä\x05z1é
            WhiteSpace:
            """) + "  foo   bar\n baz\n   blip  \n \n afteremptyline\n").encode("UTF-8")
        self.assertEqual(out.getvalue(), expected)

    def test_load(self) -> None:
        """load() with various formatting."""
        report = textwrap.dedent(f"""\
            ProblemType: Crash
            Date: now!
            Simple: bar
            WhiteSpace:
              foo   bar
             baz
               blip{'  '}
            """)
        pr = problem_report.ProblemReport()
        pr.load(io.BytesIO(report.encode()))
        self.assertEqual(pr["ProblemType"], "Crash")
        self.assertEqual(pr["Date"], "now!")
        self.assertEqual(pr["Simple"], "bar")
        self.assertEqual(pr["WhiteSpace"], " foo   bar\nbaz\n  blip  ")

        # test last field a bit more
        report += " \n"
        pr.load(io.BytesIO(report.encode()))
        self.assertEqual(pr["ProblemType"], "Crash")
        self.assertEqual(pr["Date"], "now!")
        self.assertEqual(pr["Simple"], "bar")
        self.assertEqual(pr["WhiteSpace"], " foo   bar\nbaz\n  blip  \n")

        # last field might not be \n terminated
        pr.load(io.BytesIO(textwrap.dedent("""\
            ProblemType: Crash
            Date: now!
            Simple: bar
            WhiteSpace:
             foo
             bar""").encode()))
        self.assertEqual(pr["ProblemType"], "Crash")
        self.assertEqual(pr["Date"], "now!")
        self.assertEqual(pr["Simple"], "bar")
        self.assertEqual(pr["WhiteSpace"], "foo\nbar")

        report = (
            "ProblemType: Crash\n"
            "WhiteSpace:\n"
            "  foo   bar\n"
            " baz\n"
            " \n"
            "   blip  \n"
            "Last: foo\n"
        )
        pr = problem_report.ProblemReport()
        pr.load(io.BytesIO(report.encode()))
        self.assertEqual(pr["WhiteSpace"], " foo   bar\nbaz\n\n  blip  ")
        self.assertEqual(pr["Last"], "foo")

        report += " \n"
        pr = problem_report.ProblemReport()
        pr.load(io.BytesIO(report.encode()))
        self.assertEqual(pr["WhiteSpace"], " foo   bar\nbaz\n\n  blip  ")
        self.assertEqual(pr["Last"], "foo\n")

        # empty lines in values must have a leading space in coding
        invalid_spacing = io.BytesIO(textwrap.dedent("""\
            WhiteSpace:
             first

             second
            """).encode())
        pr = problem_report.ProblemReport()
        self.assertRaises(ValueError, pr.load, invalid_spacing)

        # test that load() cleans up properly
        pr.load(io.BytesIO(b"ProblemType: Crash"))
        self.assertEqual(list(pr.keys()), ["ProblemType"])

    def test_load_string_with_null(self) -> None:
        """Test load() with a NULL character in a string key.

        Test case for https://launchpad.net/bugs/2146806
        """
        proc_maps = "7f8e5d61f000-7f8e5d620000 r--p 00005000 fc:00 190815U\0\0"
        content = f"ProblemType: Crash\n" f"Date: now!\n" f"ProcMaps: {proc_maps}\n"
        report = problem_report.ProblemReport()
        report.load(io.BytesIO(content.encode()))

        self.assertEqual(report["ProblemType"], "Crash")
        self.assertEqual(report["Date"], "now!")
        self.assertEqual(report["ProcMaps"], proc_maps)

    def test_load_binary_blob(self) -> None:
        """Throw exception when binary file (e.g. core) is loaded."""
        report = problem_report.ProblemReport()
        with (
            io.BytesIO(b"AB\xfc:CD") as report_file,
            self.assertRaisesRegex(
                problem_report.MalformedProblemReport,
                "codec can't decode byte 0xfc in position 2",
            ),
        ):
            report.load(report_file)

    def test_load_missing_colon(self) -> None:
        """Throw exception when key-value line misses a colon as separator."""
        report = problem_report.ProblemReport()
        with (
            io.BytesIO(b"\n") as report_file,
            self.assertRaisesRegex(
                problem_report.MalformedProblemReport,
                r"Line '\\n' does not contain a colon",
            ),
        ):
            report.load(report_file)

    def test_load_invalid_key(self) -> None:
        """Throw exception when the problem report key uses invalid characters."""
        report = problem_report.ProblemReport()
        with (
            io.BytesIO(b"Invalid/key: value") as report_file,
            self.assertRaisesRegex(
                problem_report.InvalidProblemReportKeyError, "key 'Invalid/key'"
            ),
        ):
            report.load(report_file)

    def test_load_invalid_utf8(self) -> None:
        """Throw exception when binary file is invalid UTF-8."""
        report = problem_report.ProblemReport()
        with (
            io.BytesIO(b"\x7fELF\x02\x01\xb0j") as report_file,
            self.assertRaisesRegex(
                problem_report.MalformedProblemReport,
                r"Line '\\x7fELF\\x02\\x01\\\\xb0j' does not contain a colon",
            ),
        ):
            report.load(report_file)

    def test_load_incorrect_padding(self) -> None:
        """Throw exception when base64 encoded data has incorrect padding."""
        report = problem_report.ProblemReport()
        content = (
            b"CoreDump: base64\n"
            b" H4sICAAAAAAC/0NvcmVEdW1wAA==\n"
            b" 7Z0LYFPV/cdP0rQ\n"
        )
        with (
            io.BytesIO(content) as report_file,
            self.assertRaisesRegex(
                problem_report.MalformedProblemReport,
                "^Malformed problem report: Incorrect padding."
                " Is this a proper .crash text file\\?$",
            ),
        ):
            report.load(report_file)

    @unittest.skipUnless(zstandard, "zstandard Python module not available")
    def test_load_zstd_compressed_data(self) -> None:
        """Test reading zstd-compressed data."""
        report = problem_report.ProblemReport()
        content = (
            b"CoreDump: base64\n"
            b" KLUv/SROHQIAgoQOEqC7ASCSGCybZRrN7//Hsn7dVyActu7bbMcLaav0RC06\n"
            b" m6fCZ/N7aeOeyqxspRVn88bSx8a8opWQAwEA/8OEAhOOwLA=\n"
        )
        with io.BytesIO(content) as report_file:
            report.load(report_file)
        self.assertEqual(
            report["CoreDump"],
            "sample data that is compressed with zstd"
            " and is long enough for zstd to work.\n",
        )

    @unittest.skipUnless(zstandard, "zstandard Python module not available")
    def test_reading_zstd_compressed_value(self) -> None:
        """Test reading zstd-compressed CompressedValue."""
        report = problem_report.ProblemReport()
        content = (
            b"CoreDump: base64\n"
            b" KLUv/SRPHQIA8sQPFKClOoBIEkjIbmfbhiz9/1/96mYFATGiz9JHLfTZY9aV\n"
            b" a9QBRdQz0AkUjbdbVqNQorP3UpGC1NIi9X3UgygBAEyQcYs=\n"
        )
        with io.BytesIO(content) as report_file:
            report.load(report_file, binary="compressed")
        self.assertEqual(
            report["CoreDump"].get_value(),
            b"fake core dump data for testing purposes"
            b" which is long enough to be compressed\n",
        )

    @unittest.skipUnless(zstandard, "zstandard Python module not available")
    def test_writing_zstd_compressed_value(self) -> None:
        """Test writing zstd-compressed CompressedValue."""
        compressed_value = problem_report.CompressedValue(
            compressed_value=base64.b64decode(
                b"KLUv/SRCvQEAIsQMEMC3AbLcS0WaZ8rvf69jyw3ghpXr6pBr5i7HiCKroc60"
                b"3+tB4rC/4TV1osHyJUXme6IIAD6PTiM="
            )
        )
        output = io.BytesIO()
        compressed_value.write(output)
        self.assertEqual(
            output.getvalue(),
            b"Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam\n",
        )

    @unittest.skipUnless(zstandard, "zstandard Python module not available")
    def test_zstd_compressed_value_length(self) -> None:
        """Test getting length of zstd-compressed CompressedValue."""
        compressed_value = problem_report.CompressedValue(
            compressed_value=base64.b64decode(
                b"KLUv/SRB3QEAwoMMEbDrOJRilklIQlLIJC3OvFUMIIwdaHxrV6aSHEv6rFlP"
                b"/ll/IaXfo19actUkdvm8hggBAAlhlgHbiBMF"
            )
        )
        self.assertEqual(len(compressed_value), 65)

    @unittest.skipUnless(zstandard, "zstandard Python module not available")
    def test_len_zstd_compressed_value_nosize(self) -> None:
        """Test len() on zstd-compressed CompressedValue without a size header."""
        compressed_value = problem_report.CompressedValue(
            compressed_value=base64.b64decode(b"KLUv/QBYEQAAe30=")
        )
        self.assertEqual(len(compressed_value), 2)

    @unittest.mock.patch("builtins.__import__")
    def test_zstandard_missing(self, import_mock: MagicMock) -> None:
        """Test reading zstd-compressed data when zstandard is missing."""
        with contextlib.suppress(KeyError):
            sys.modules.pop("zstandard")
        import_mock.side_effect = ImportError("mocked import error")

        report = problem_report.ProblemReport()
        content = (
            b"CoreDump: base64\n"
            b" KLUv/SROHQIAgoQOEqC7ASCSGCybZRrN7//Hsn7dVyActu7bbMcLaav0RC06\n"
            b" m6fCZ/N7aeOeyqxspRVn88bSx8a8opWQAwEA/8OEAhOOwLA=\n"
        )
        expected_message = (
            "Failed to import zstandard library: mocked import error."
            " Please install python3-zstandard."
        )
        with (
            self.assertRaisesRegex(RuntimeError, expected_message),
            io.BytesIO(content) as report_file,
        ):
            report.load(report_file)

    def test_write_fileobj(self) -> None:
        """Write a report with a pointer to a file-like object."""
        tempbin = io.BytesIO(BIN_DATA)
        tempasc = io.BytesIO(b"Hello World")

        pr = problem_report.ProblemReport(date="now!")
        pr["BinFile"] = (tempbin,)
        pr["AscFile"] = (tempasc, False)
        out = io.BytesIO()
        pr.write(out)
        out.seek(0)

        pr = problem_report.ProblemReport()
        pr.load(out)
        self.assertEqual(pr["BinFile"], tempbin.getvalue())
        self.assertEqual(pr["AscFile"], tempasc.getvalue().decode())

    def test_write_empty_fileobj(self) -> None:
        """Write a report with a pointer to a file-like object with
        enforcing non-emptyness."""
        tempbin = io.BytesIO(b"")
        tempasc = io.BytesIO(b"")

        pr = problem_report.ProblemReport(date="now!")
        pr["BinFile"] = (tempbin, True, None, True)
        out = io.BytesIO()
        self.assertRaises(OSError, pr.write, out)

        pr = problem_report.ProblemReport(date="now!")
        pr["AscFile"] = (tempasc, False, None, True)
        out = io.BytesIO()
        self.assertRaises(OSError, pr.write, out)

    @patch("problem_report.CompressedValue.iter_compressed.__defaults__", (48,))
    def test_write_compressed_value(self) -> None:
        """Write a report with a multi-line compressed value."""
        report = problem_report.ProblemReport(date="now!")
        report["BinValue"] = problem_report.CompressedValue(
            compressed_value=b"\x1f\x8b\x08\x00\x00\x00\x00\x00\x02\xff\x0b\xc9HUH\xcc"
            b"+.O-R(\xc9W\xc8\xc9LK\xd5Q(\x01\x8a\x95\xe6e\x96\xa5\x16\x15\x03y\x89y)"
            b"\n\xa9@veIFf^:\x00\xa6\xbfr\x950\x00\x00\x00"
        )
        out = io.BytesIO()
        report.write(out)
        self.assertEqual(
            out.getvalue().decode(),
            textwrap.dedent("""\
                ProblemType: Crash
                Date: now!
                BinValue: base64
                 H4sIAAAAAAAC/wvJSFVIzCsuTy1SKMlXyMlMS9VRKAGKleZllqUWFQN5iXkpCqlA
                 dmVJRmZeOgCmv3KVMAAAAA==
                """),
        )

    def test_read_file(self) -> None:
        """Read a report with binary data."""
        bin_report = textwrap.dedent("""\
            ProblemType: Crash
            Date: now!
            File: base64
             H4sICAAAAAAC/0ZpbGUA
             c3RyhEIGBoYoRiYAM5XUCxAAAAA=
            Foo: Bar
            """).encode()

        # test with reading everything
        pr = problem_report.ProblemReport()
        skipped_keys = pr.load(io.BytesIO(bin_report))
        self.assertEqual(pr["File"], BIN_DATA)
        self.assertEqual(skipped_keys, [])

        # test with skipping binary data
        skipped_keys = pr.load(io.BytesIO(bin_report), binary=False)
        self.assertEqual(skipped_keys, ["File"])

        # test with keeping compressed binary data
        skipped_keys = pr.load(io.BytesIO(bin_report), binary="compressed")
        self.assertEqual(pr["Foo"], "Bar")
        self.assertEqual(skipped_keys, [])
        self.assertTrue(isinstance(pr["File"], problem_report.CompressedValue))
        self.assertEqual(len(pr["File"]), len(BIN_DATA))
        self.assertEqual(pr["File"].get_value(), BIN_DATA)
        self.assertEqual(pr["File"].name, "File")

    def test_read_file_legacy(self) -> None:
        """Read a report with binary data in legacy format without gzip
        header."""
        bin_report = textwrap.dedent("""\
            ProblemType: Crash
            Date: now!
            File: base64
             eJw=
             c3RyxIAMcBAFAG55BXk=
            Foo: Bar
            """).encode()

        # test with reading everything
        pr = problem_report.ProblemReport()
        skipped_keys = pr.load(io.BytesIO(bin_report))
        self.assertEqual(pr["File"], b"AB" * 10 + b"\0" * 10 + b"Z")
        self.assertEqual(skipped_keys, [])

        # test with skipping binary data
        skipped_keys = pr.load(io.BytesIO(bin_report), binary=False)
        self.assertEqual(skipped_keys, ["File"])

        # test with keeping CompressedValues
        skipped_keys = pr.load(io.BytesIO(bin_report), binary="compressed")
        self.assertEqual(skipped_keys, [])
        self.assertEqual(len(pr["File"]), 31)
        self.assertEqual(pr["File"].get_value(), b"AB" * 10 + b"\0" * 10 + b"Z")
        self.assertEqual(pr["File"].name, "File")
        out = io.BytesIO()
        pr["File"].write(out)
        out.seek(0)
        self.assertEqual(out.read(), b"AB" * 10 + b"\0" * 10 + b"Z")

    def test_iter(self) -> None:
        """problem_report.ProblemReport iteration."""
        pr = problem_report.ProblemReport()
        pr["foo"] = "bar"

        keys = list(pr.keys())
        keys.sort()
        self.assertEqual(" ".join(keys), "Date ProblemType foo")

        self.assertEqual(len([k for k in pr if k != "foo"]), 2)

    def test_modify(self) -> None:
        """reading, modifying fields, and writing back."""
        report = textwrap.dedent("""\
            ProblemType: Crash
            Date: now!
            Long:
             xxx
             .
             yyy
            Short: Bar
            File: base64
             H4sICAAAAAAC/0ZpbGUA
             c3RyxIAMcBAFAK/2p9MfAAAA
            """).encode()

        pr = problem_report.ProblemReport()
        pr.load(io.BytesIO(report), binary="compressed")

        self.assertEqual(pr["Long"], "xxx\n.\nyyy")
        self.assertEqual(pr["Short"], "Bar")
        self.assertEqual(
            pr["File"].get_value(), b"ABABABABABABABABABAB\0\0\0\0\0\0\0\0\0\0Z"
        )

        # write back unmodified
        out = io.BytesIO()
        pr.write(out)
        self.assertEqual(
            out.getvalue().decode(),
            textwrap.dedent("""\
                ProblemType: Crash
                Date: now!
                Long:
                 xxx
                 .
                 yyy
                Short: Bar
                File: base64
                 H4sICAAAAAAC/0ZpbGUAc3RyxIAMcBAFAK/2p9MfAAAA
                """),
        )

        pr["Short"] = "aaa\nbbb"
        pr["Long"] = "123"
        out = io.BytesIO()
        pr.write(out)
        self.assertEqual(
            out.getvalue().decode(),
            textwrap.dedent("""\
                ProblemType: Crash
                Date: now!
                Long: 123
                Short:
                 aaa
                 bbb
                File: base64
                 H4sICAAAAAAC/0ZpbGUAc3RyxIAMcBAFAK/2p9MfAAAA
                """),
        )

    def test_sorted_items(self) -> None:
        """Test ProblemReport.sorted_items()."""
        bin_report = textwrap.dedent("""\
            ProblemType: Crash
            Date: now!
            File: base64
             eJw=
             c3RyxIAMcBAFAG55BXk=
            _MarkForUpload: False
            Architecture: amd64
            ExecutablePath: /usr/bin/python3
            Package: python3.12-minimal
            """).encode()

        report = problem_report.ProblemReport()
        report.load(io.BytesIO(bin_report), binary=False)
        self.assertEqual(
            list(report.sorted_items()),
            [
                ("ExecutablePath", "/usr/bin/python3"),
                ("Package", "python3.12-minimal"),
                ("ProblemType", "Crash"),
                ("Architecture", "amd64"),
                ("Date", "now!"),
            ],
        )

    @unittest.skipUnless(zstandard, "zstandard Python module not available")
    def test_write_mime_binary_values(self) -> None:
        """write_mine() for binary values (gzip and zstd compressed)."""
        report = problem_report.ProblemReport(date="now!")
        report["Data.gz"] = GZIP_BIN_DATA
        report["GzipData"] = problem_report.CompressedValue(
            compressed_value=GZIP_BIN_DATA
        )
        report["ZstdData"] = problem_report.CompressedValue(
            compressed_value=ZSTD_BIN_DATA
        )

        output = io.BytesIO()
        report.write_mime(output)
        output.seek(0)

        message = email.message_from_binary_file(output)
        remaining_parts = message.walk()

        # first part is the multipart container
        part = next(remaining_parts)
        self.assertTrue(part.is_multipart())

        # second part should be an inline text/plain attachments with all short
        # fields
        part = next(remaining_parts)
        self.assertFalse(part.is_multipart())
        self.assertEqual(part.get_content_type(), "text/plain")
        self.assertEqual(part.get_content_charset(), "utf-8")
        self.assertIsNone(part.get_filename())
        self.assertEqual(
            part.get_payload(decode=True), b"ProblemType: Crash\nDate: now!\n"
        )

        # third part should be the Data.gz as attachment
        part = next(remaining_parts)
        self.assertEqual(part.get_filename(), "Data.gz")
        self.assertFalse(part.is_multipart())
        self.assertEqual(part.get_content_type(), "application/gzip")
        self.assertIsNone(part.get_content_charset())
        self.assertEqual(part.get_payload(decode=True), GZIP_BIN_DATA)

        # fourth part should be the GzipData as attachment
        part = next(remaining_parts)
        self.assertEqual(part.get_filename(), "GzipData.gz")
        self.assertFalse(part.is_multipart())
        self.assertEqual(part.get_content_type(), "application/gzip")
        self.assertIsNone(part.get_content_charset())
        self.assertEqual(part.get_payload(decode=True), GZIP_BIN_DATA)

        # fifth part should be the ZstdData as attachment
        part = next(remaining_parts)
        self.assertEqual(part.get_filename(), "ZstdData.zst")
        self.assertFalse(part.is_multipart())
        self.assertEqual(part.get_content_type(), "application/zstd")
        self.assertIsNone(part.get_content_charset())
        self.assertEqual(part.get_payload(decode=True), ZSTD_BIN_DATA)

        with self.assertRaises(StopIteration):
            next(remaining_parts)

    def test_write_mime_invalid_compressed_binary(self) -> None:
        """write_mine() for invalid compressed binary values."""
        report = problem_report.ProblemReport()
        report["InvalidData.gz"] = b"\0X"

        output = io.BytesIO()
        with self.assertRaisesRegex(
            ValueError, "^Unknown compression for InvalidData.gz$"
        ):
            report.write_mime(output)

    def test_write_mime_json(self) -> None:
        """write_mine() for JSON values."""
        package_data = json.dumps({"elfType": "coredump", "key": "A" * 1000}, indent=2)
        pro_status = json.dumps(
            {
                "_schema_version": "0.1",
                "version": "37.2ubuntu",
                "execution_status": "inactive",
                "execution_details": "No Ubuntu Pro operations are running",
            },
            indent=2,
        )
        report = problem_report.ProblemReport(date="now!")
        report["CoredumpPackageJson"] = package_data
        report["pro-status.json"] = pro_status

        output = io.BytesIO()
        report.write_mime(output)
        output.seek(0)

        message = email.message_from_binary_file(output)
        remaining_parts = message.walk()

        # first part is the multipart container
        part = next(remaining_parts)
        self.assertTrue(part.is_multipart())

        # next part should be an inline text/plain attachments with all short
        # fields
        part = next(remaining_parts)
        self.assertFalse(part.is_multipart())
        self.assertEqual(part.get_content_type(), "text/plain")
        self.assertEqual(part.get_content_charset(), "utf-8")
        self.assertIsNone(part.get_filename())
        self.assertEqual(
            part.get_payload(decode=True), b"ProblemType: Crash\nDate: now!\n"
        )

        # next part should be the CoredumpPackageJson as attachment
        part = next(remaining_parts)
        self.assertEqual(part.get_filename(), "CoredumpPackage.json")
        self.assertFalse(part.is_multipart())
        self.assertEqual(part.get_content_type(), "application/json")
        self.assertIsNone(part.get_content_charset())
        self.assertEqual(part.get_payload(decode=True), package_data.encode())

        # next part should be the pro-status.json as attachment
        part = next(remaining_parts)
        self.assertEqual(part.get_filename(), "pro-status.json")
        self.assertFalse(part.is_multipart())
        self.assertEqual(part.get_content_type(), "application/json")
        self.assertIsNone(part.get_content_charset())
        self.assertEqual(part.get_payload(decode=True), pro_status.encode())

        with self.assertRaises(StopIteration):
            next(remaining_parts)

    def test_write_mime_text(self) -> None:
        """write_mime() for text values."""
        pr = problem_report.ProblemReport(date="now!")
        pr["Simple"] = "bar"
        pr["SimpleUTF8"] = "1äö2Φ3".encode()
        pr["SimpleUnicode"] = "1äö2Φ3"
        pr["TwoLineUnicode"] = "pi-π\nnu-η\n"
        pr["TwoLineUTF8"] = "pi-π\nnu-η\n".encode()
        pr["SimpleLineEnd"] = "bar\n"
        pr["TwoLine"] = "first\nsecond\n"
        pr["InlineMargin"] = "first\nsecond\nthird\nfourth\nfifth\n"
        pr["Multiline.txt"] = " foo   bar\nbaz\n  blip  \nline4\nline♥5!!\nłıµ€ ⅝\n"

        # still small enough for inline text
        pr["Largeline"] = "A" * 999
        pr["LargeMultiline"] = f"{'A' * 120}\n{'B' * 90}"

        # too big for inline text, these become attachments
        pr["Hugeline"] = "A" * 10000
        pr["HugeMultiline"] = f"{'A' * 900}\n{'B' * 900}\n{'C' * 900}"
        out = io.BytesIO()
        pr.write_mime(out)
        out.seek(0)

        msg = email.message_from_binary_file(out)
        parts = list(msg.walk())
        self.assertEqual(len(parts), 5)

        # first part is the multipart container
        self.assertTrue(parts[0].is_multipart())

        # second part should be an inline text/plain attachments with all short
        # fields
        self.assertTrue(not parts[1].is_multipart())
        self.assertEqual(parts[1].get_content_type(), "text/plain")
        self.assertEqual(parts[1].get_content_charset(), "utf-8")
        self.assertIsNone(parts[1].get_filename())
        expected = textwrap.dedent(f"""\
            ProblemType: Crash
            Date: now!
            InlineMargin:
             first
             second
             third
             fourth
             fifth
            LargeMultiline:
             {'A' * 120}
             {'B' * 90}
            Largeline: {'A' * 999}
            Simple: bar
            SimpleLineEnd: bar
            SimpleUTF8: 1äö2Φ3
            SimpleUnicode: 1äö2Φ3
            TwoLine:
             first
             second
            TwoLineUTF8:
             pi-π
             nu-η
            TwoLineUnicode:
             pi-π
             nu-η
            """).encode("UTF-8")
        self.assertEqual(parts[1].get_payload(decode=True), expected)

        # third part should be the HugeMultiline: field as attachment
        self.assertTrue(not parts[2].is_multipart())
        self.assertEqual(parts[2].get_content_type(), "text/plain")
        self.assertEqual(parts[2].get_content_charset(), "utf-8")
        self.assertEqual(parts[2].get_filename(), "HugeMultiline.txt")
        self.assertEqual(
            parts[2].get_payload(decode=True), pr["HugeMultiline"].encode()
        )

        # fourth part should be the Hugeline: field as attachment
        self.assertTrue(not parts[3].is_multipart())
        self.assertEqual(parts[3].get_content_type(), "text/plain")
        self.assertEqual(parts[3].get_content_charset(), "utf-8")
        self.assertEqual(parts[3].get_filename(), "Hugeline.txt")
        self.assertEqual(parts[3].get_payload(decode=True), pr["Hugeline"].encode())

        # fifth part should be the Multiline: field as attachment
        self.assertTrue(not parts[4].is_multipart())
        self.assertEqual(parts[4].get_content_type(), "text/plain")
        self.assertEqual(parts[4].get_content_charset(), "utf-8")
        self.assertEqual(parts[4].get_filename(), "Multiline.txt")
        expected = textwrap.dedent(f"""\
             foo   bar
            baz
              blip{'  '}
            line4
            line♥5!!
            łıµ€ ⅝
            """).encode("UTF-8")
        self.assertEqual(parts[4].get_payload(decode=True), expected)

    def test_write_mime_extra_headers(self) -> None:
        """write_mime() with extra headers."""
        pr = problem_report.ProblemReport(date="now!")
        pr["Simple"] = "bar"
        pr["TwoLine"] = "first\nsecond\n"
        out = io.BytesIO()
        pr.write_mime(out, extra_headers={"Greeting": "hello world", "Foo": "Bar"})
        out.seek(0)

        msg = email.message_from_binary_file(out)
        self.assertEqual(msg["Greeting"], "hello world")
        self.assertEqual(msg["Foo"], "Bar")
        parts = list(msg.walk())
        self.assertEqual(len(parts), 2)

        # first part is the multipart container
        self.assertTrue(parts[0].is_multipart())

        # second part should be an inline text/plain attachments with all short
        # fields
        self.assertTrue(not parts[1].is_multipart())
        self.assertEqual(parts[1].get_content_type(), "text/plain")
        self.assertIn(b"Simple: bar", parts[1].get_payload(decode=True))

    def test_write_mime_order(self) -> None:
        """write_mime() with keys ordered."""
        pr = problem_report.ProblemReport(date="now!")
        pr["SecondText"] = "What"
        pr["FirstText"] = "Who"
        pr["FourthText"] = "Today"
        pr["ThirdText"] = "I Don't Know"
        out = io.BytesIO()
        pr.write_mime(
            out,
            priority_fields=[
                "FirstText",
                "SecondText",
                "ThirdText",
                "Unknown",
                "FourthText",
            ],
        )
        out.seek(0)

        msg = email.message_from_binary_file(out)
        parts = list(msg.walk())
        self.assertEqual(len(parts), 2)

        # first part is the multipart container
        self.assertTrue(parts[0].is_multipart())

        # second part should be an inline text/plain attachments with all short
        # fields
        self.assertTrue(not parts[1].is_multipart())
        self.assertEqual(parts[1].get_content_type(), "text/plain")
        self.assertEqual(parts[1].get_content_charset(), "utf-8")
        self.assertIsNone(parts[1].get_filename())
        self.assertEqual(
            parts[1].get_payload(decode=True),
            textwrap.dedent("""\
                FirstText: Who
                SecondText: What
                ThirdText: I Don't Know
                FourthText: Today
                ProblemType: Crash
                Date: now!
                """).encode(),
        )

    def test_updating(self) -> None:
        """new_keys() and write() with only_new=True."""
        pr = problem_report.ProblemReport()
        self.assertEqual(pr.new_keys(), {"ProblemType", "Date"})
        pr.load(io.BytesIO(textwrap.dedent("""\
            ProblemType: Crash
            Date: now!
            Foo: bar
            Baz: blob
            """).encode()))

        self.assertEqual(pr.new_keys(), set())

        pr["Foo"] = "changed"
        pr["NewKey"] = "new new"
        self.assertEqual(pr.new_keys(), {"NewKey"})

        out = io.BytesIO()
        pr.write(out, only_new=True)
        self.assertEqual(out.getvalue(), b"NewKey: new new\n")

    def test_import_dict(self) -> None:
        """Import a dictionary with update()."""
        pr = problem_report.ProblemReport()
        pr["oldtext"] = "Hello world"
        pr["oldbin"] = BIN_DATA
        pr["overwrite"] = "I am crap"

        d = {
            "newtext": "Goodbye world",
            "newbin": "11\000\001\002\xffZZ",
            "overwrite": "I am good",
        }

        pr.update(d)
        self.assertEqual(pr["oldtext"], "Hello world")
        self.assertEqual(pr["oldbin"], BIN_DATA)
        self.assertEqual(pr["newtext"], "Goodbye world")
        self.assertEqual(pr["newbin"], "11\000\001\002\xffZZ")
        self.assertEqual(pr["overwrite"], "I am good")

    def test_load_key_filter(self) -> None:
        """Load a report with filtering keys."""
        report = textwrap.dedent("""\
            ProblemType: Crash
            DataNo: nonono
            GoodFile: base64
             H4sICAAAAAAC/0FmaWxlAA==
             c3RyhEIGBoYoRiYAM5XUCxAAAAA=
            DataYes: yesyes
            BadFile: base64
             H4sICAAAAAAC/0ZpbGUA
             S8vPZ0hKLAIACq50HgcAAAA=
            """).encode()
        pr = problem_report.ProblemReport()
        pr.load(io.BytesIO(report), key_filter=["DataYes", "GoodFile"])
        self.assertEqual(pr["DataYes"], "yesyes")
        self.assertEqual(pr["GoodFile"], BIN_DATA)
        self.assertEqual(sorted(pr.keys()), ["DataYes", "GoodFile"])

    def test_get_on_disk_size(self) -> None:
        """Test CompressedValue.get_on_disk_size()."""
        compressed_value = problem_report.CompressedValue(b"somedata")
        self.assertEqual(compressed_value.get_compressed_size(), 28)
        assert compressed_value.compressed_value is not None
        base64_encoded = base64.b64encode(compressed_value.compressed_value)
        self.assertEqual(compressed_value.get_on_disk_size(), len(base64_encoded))


class TestEntryParser(unittest.TestCase):
    # pylint: disable=protected-access
    """Test _EntryParser class."""

    def test_parse(self) -> None:
        """Test parsing a report file with _EntryParser."""
        report_file = io.BytesIO(textwrap.dedent("""\
            First: single line
            Second: multi
             line
            """).encode())
        entries = []
        for entry in problem_report._EntryParser(report_file):
            entries.append(list(entry))
        self.assertEqual(
            entries, [[b"First: single line\n"], [b"Second: multi\n", b" line\n"]]
        )

    def test_skip_entries(self) -> None:
        """Test skipping reading one entry."""
        report_file = io.BytesIO(textwrap.dedent("""\
            First: this entry
             will be skipped
            Second: single line
            """).encode())
        iterator = problem_report._EntryParser(report_file)
        next(iterator)
        self.assertEqual(list(next(iterator)), [b"Second: single line\n"])

    def test_skip_partial_entries(self) -> None:
        """Test skipping reading one entry."""
        report_file = io.BytesIO(textwrap.dedent("""\
            First: this line is read,
             but these lines
             will be skipped
            Second: single line
            """).encode())
        iterator = problem_report._EntryParser(report_file)
        self.assertEqual(next(next(iterator)), b"First: this line is read,\n")
        self.assertEqual(list(next(iterator)), [b"Second: single line\n"])

    def test_skip_last_entry(self) -> None:
        """Test skipping reading the last entry."""
        report_file = io.BytesIO(textwrap.dedent("""\
            First: this line is read
            Second: this line is skipped
            """).encode())
        iterator = problem_report._EntryParser(report_file)
        self.assertEqual(list(next(iterator)), [b"First: this line is read\n"])
        next(iterator)
        with self.assertRaises(StopIteration):
            next(iterator)
