#!/usr/bin/env python3

# Unit tests for decopy
# 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.

import contextlib
import io
import sys
import tempfile
import typing as t
import unittest
from pathlib import Path
from unittest.mock import DEFAULT, patch

import debian.copyright as dc

from decopy import cmdoptions
from decopy.common.core_types import CopyrightHolder
from decopy.dep5doc import dep5, output
from decopy.dep5doc.renderer import Dep5Renderer, renderer_for
from decopy.fstree import nodes, tree


class TestOutput(unittest.TestCase):
    def setUp(self):
        # Create one root with one dir with one file
        self.tree = tree.RootInfo(root=".")
        self.groups = {}

    def test_empty(self):
        groups: dict = {}
        copyright_ = None
        options = cmdoptions.process_options([])

        with patch.multiple(
            sys,
            stdout=DEFAULT,
            stderr=DEFAULT,
            new_callable=io.StringIO,
        ) as _sys:
            output.generate_output(
                groups, self.tree, copyright_, options, Dep5Renderer()
            )
        mocks = t.cast(dict, _sys)
        out = mocks["stdout"].getvalue()
        err = mocks["stderr"].getvalue()
        self.assertFalse(len(err), f"Unexpected stderr: {err}")
        self.assertEqual(
            out,
            "Format: https://www.debian.org/doc/"
            "packaging-manuals/copyright-format/1.0/\n"
            "Source: TODO\n",
            f"Unexpected stdout: {out}",
        )

    def test_empty_partial(self):
        groups: dict = {}
        copyright_ = None
        options = cmdoptions.process_options(["--mode=partial", "."])

        with patch.multiple(
            sys,
            stdout=DEFAULT,
            stderr=DEFAULT,
            new_callable=io.StringIO,
        ) as _sys:
            output.generate_output(
                groups, self.tree, copyright_, options, Dep5Renderer()
            )
        mocks = t.cast(dict, _sys)
        out = mocks["stdout"].getvalue()
        err = mocks["stderr"].getvalue()
        self.assertFalse(len(err), f"Unexpected stderr: {err}")
        self.assertEqual(
            out,
            "Format: https://www.debian.org/doc/"
            "packaging-manuals/copyright-format/1.0/\n"
            "Source: TODO\n"
            "Comment: *** only: . ***\n",
            f"Unexpected stdout: {out}",
        )


_CURRENT_FORMAT = (
    "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n"
)


class TestGenerateOutputDocument(unittest.TestCase):
    """Characterization tests for the full generate_output document.

    These pin the document assembly (header, blank-line separators, file
    paragraphs ordered by sort_key, trailing license stanzas) before the
    Renderer refactor moves that orchestration behind a protocol.
    """

    def setUp(self):
        self.tree = tree.RootInfo(root=".")

    def _run(self, groups, copyright_, args):
        options = cmdoptions.process_options(args)
        with patch.multiple(
            sys,
            stdout=DEFAULT,
            stderr=DEFAULT,
            new_callable=io.StringIO,
        ) as _sys:
            output.generate_output(
                groups, self.tree, copyright_, options, Dep5Renderer()
            )
        mocks = t.cast(dict, _sys)
        return (
            mocks["stdout"].getvalue(),
            mocks["stderr"].getvalue(),
        )

    def _two_groups(self, options):
        """An MIT group (two files under src/) and a GPL-2 group (README)."""
        root = nodes.DirInfo(parent=None, name="")
        src = nodes.DirInfo(parent=root, name="src")
        root.file_list["src"] = src
        mit = dep5.Group(position=1)
        for name in ("a.c", "b.c"):
            f = nodes.FileInfo(parent=src, name=name)
            f.add_licenses(["MIT"])
            f.add_copyrights([CopyrightHolder("Alice", "alice@x")])
            mit.add_file(f)
        gpl = dep5.Group(position=0)
        readme = nodes.FileInfo(parent=root, name="README")
        readme.add_licenses(["GPL-2"])
        readme.add_copyrights([CopyrightHolder("Bob", "bob@x")])
        gpl.add_file(readme)
        return {mit.get_key(options): mit, gpl.get_key(options): gpl}

    def test_full_document_no_glob(self):
        args = ["--no-progress", "--root", "tests/testdata/one", "--no-glob"]
        options = cmdoptions.process_options(args)
        out, err = self._run(self._two_groups(options), None, args)
        self.assertEqual(err, "")
        self.assertEqual(
            out,
            _CURRENT_FORMAT + "Source: TODO\n"
            "\n"
            "Files: src/a.c\n"
            "       src/b.c\n"
            "Copyright: Alice <alice@x>\n"
            "License: MIT\n"
            "\n"
            "Files: README\n"
            "Copyright: Bob <bob@x>\n"
            "License: GPL-2\n"
            "\n"
            "License: GPL-2\n"
            "Comment: Add the corresponding license text here\n"
            "\n"
            "License: MIT\n"
            "Comment: Add the corresponding license text here\n",
        )

    def test_glob_collapses_full_subtree_to_star(self):
        args = ["--no-progress", "--root", "tests/testdata/one", "--glob"]
        options = cmdoptions.process_options(args)
        root = nodes.DirInfo(parent=None, name="")
        src = nodes.DirInfo(parent=root, name="src")
        root.file_list["src"] = src
        group = dep5.Group(position=0)
        for name in ("a.c", "b.c"):
            f = nodes.FileInfo(parent=src, name=name)
            f.add_licenses(["MIT"])
            group.add_file(f)
        out, err = self._run({group.get_key(options): group}, None, args)
        self.assertEqual(err, "")
        self.assertIn("Files: *\n", out)
        self.assertNotIn("src/a.c", out)

    def test_empty_copyright_object_renders_todo_source(self):
        out, err = self._run(
            {}, dep5.Copyright(), ["--no-progress", "--root", "tests/testdata/one"]
        )
        self.assertEqual(err, "")
        self.assertEqual(out, _CURRENT_FORMAT + "Source: TODO\n")

    def test_existing_copyright_header_overrides_format_keeps_fields(self):
        text = (
            "Format: https://example.org/old-format\n"
            "Upstream-Name: example\n"
            "Source: https://example.org/dl\n"
        )
        # Build outside the stdout/stderr patch so the loader's "format not
        # known" warning is not captured by the output assertion.
        with contextlib.redirect_stderr(io.StringIO()):
            copyright_ = dep5.Copyright(dc.Copyright(io.StringIO(text), strict=False))
        out, _ = self._run(
            {}, copyright_, ["--no-progress", "--root", "tests/testdata/one"]
        )
        self.assertEqual(
            out,
            _CURRENT_FORMAT + "Upstream-Name: example\n"
            "Source: https://example.org/dl\n",
        )


class TestFillLicensesIntegration(unittest.TestCase):
    """End-to-end: the License-stanza body the resolver fills, driven through
    generate_output with the real renderer (renderer_for), as main() does."""

    def setUp(self):
        self.tree = tree.RootInfo(root=".")

    def _run(self, licence, extra_args):
        args = ["--no-progress", "--root", "tests/testdata/one", "--no-glob"]
        args += extra_args
        options = cmdoptions.process_options(args)
        root = nodes.DirInfo(parent=None, name="")
        readme = nodes.FileInfo(parent=root, name="README")
        readme.add_licenses([licence])
        readme.add_copyrights([CopyrightHolder("Bob", "bob@x")])
        group = dep5.Group(position=0)
        group.add_file(readme)
        groups = {group.get_key(options): group}
        with patch.multiple(
            sys, stdout=DEFAULT, stderr=DEFAULT, new_callable=io.StringIO
        ) as _sys:
            output.generate_output(
                groups, self.tree, None, options, renderer_for(options)
            )
        mocks = t.cast(dict, _sys)
        return mocks["stdout"].getvalue(), mocks["stderr"].getvalue()

    def test_gpl2_stanza_carries_common_licenses_reference(self):
        out, err = self._run("GPL-2", [])
        self.assertEqual(err, "")
        self.assertIn("License: GPL-2\n", out)
        self.assertIn("/usr/share/common-licenses/GPL-2'", out)
        self.assertNotIn("Add the corresponding license text here", out)

    def test_no_fill_licenses_keeps_placeholder(self):
        out, err = self._run("GPL-2", ["--no-fill-licenses"])
        self.assertEqual(err, "")
        self.assertIn(
            "License: GPL-2\nComment: Add the corresponding license text here", out
        )
        self.assertNotIn("/usr/share/common-licenses", out)

    def test_embedded_full_text_stanza(self):
        # A short licence not in common-licenses gets its verbatim text embedded.
        out, err = self._run("Expat", [])
        self.assertEqual(err, "")
        self.assertIn("License: Expat\n", out)
        self.assertIn("Permission is hereby granted, free of charge", out)
        self.assertNotIn("Add the corresponding license text here", out)

    def test_spdx_converts_name_but_body_keys_on_dep5(self):
        out, err = self._run("GPL-2", ["--spdx"])
        self.assertEqual(err, "")
        # The stanza name is SPDX-converted...
        self.assertIn("License: GPL-2.0-only\n", out)
        self.assertNotIn("License: GPL-2\n", out)
        # ...but the body still resolves from the DEP-5 name (GPL-2 reference).
        self.assertIn("/usr/share/common-licenses/GPL-2'", out)


class TestGenerateOutputDebugLogging(unittest.TestCase):
    """The 'Generated ...' debug logs fire only when writing to a real file.

    Writing to stdout must not echo the same text into the log stream.
    """

    def setUp(self):
        self.tree = tree.RootInfo(root=".")

    def _one_group(self, options):
        root = nodes.DirInfo(parent=None, name="")
        group = dep5.Group(position=0)
        f = nodes.FileInfo(parent=root, name="README")
        f.add_licenses(["MIT"])
        f.add_copyrights([CopyrightHolder("Alice", "alice@x")])
        group.add_file(f)
        return {group.get_key(options): group}

    def _generated_labels(self, args):
        options = cmdoptions.process_options(args)
        groups = self._one_group(options)
        with patch.object(output.logger, "debug") as mock_debug:
            output.generate_output(groups, self.tree, None, options, Dep5Renderer())
        return [
            call.args[0] % call.args[1:]
            for call in mock_debug.call_args_list
            if call.args and "Generated" in call.args[0]
        ]

    def test_file_output_logs_every_generated_block(self):
        with tempfile.TemporaryDirectory() as tmp:
            path = str(Path(tmp) / "copyright")
            labels = self._generated_labels(
                ["--no-progress", "--root", "tests/testdata/one", "-o", path]
            )
        joined = "\n".join(labels)
        self.assertIn("Generated header", joined)
        self.assertIn("Generated group", joined)
        self.assertIn("Generated license block", joined)

    def test_stdout_output_logs_no_generated_blocks(self):
        with patch.multiple(
            sys, stdout=DEFAULT, stderr=DEFAULT, new_callable=io.StringIO
        ):
            labels = self._generated_labels(
                ["--no-progress", "--root", "tests/testdata/one"]
            )
        self.assertEqual(labels, [])


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