# This file is part of beets.
# Copyright 2016, Adrian Sampson and Diego Moreda.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.

import codecs
from typing import ClassVar
from unittest.mock import patch

from beets.dbcore.query import TrueQuery
from beets.importer import Action
from beets.library import Item
from beets.test import _common
from beets.test.helper import (
    AutotagImportTestCase,
    AutotagStub,
    BeetsTestCase,
    IOMixin,
    PluginMixin,
    TerminalImportMixin,
)


class ModifyFileMocker:
    """Helper for modifying a file, replacing or editing its contents. Used for
    mocking the calls to the external editor during testing.
    """

    def __init__(self, contents=None, replacements=None):
        """`self.contents` and `self.replacements` are initialized here, in
        order to keep the rest of the functions of this class with the same
        signature as `EditPlugin.get_editor()`, making mocking easier.
            - `contents`: string with the contents of the file to be used for
            `overwrite_contents()`
            - `replacement`: dict with the in-place replacements to be used for
            `replace_contents()`, in the form {'previous string': 'new string'}

        TODO: check if it can be solved more elegantly with a decorator
        """
        self.contents = contents
        self.replacements = replacements
        self.action = self.overwrite_contents
        if replacements:
            self.action = self.replace_contents

    # The two methods below mock the `edit` utility function in the plugin.

    def overwrite_contents(self, filename, log):
        """Modify `filename`, replacing its contents with `self.contents`. If
        `self.contents` is empty, the file remains unchanged.
        """
        if self.contents:
            with codecs.open(filename, "w", encoding="utf-8") as f:
                f.write(self.contents)

    def replace_contents(self, filename, log):
        """Modify `filename`, reading its contents and replacing the strings
        specified in `self.replacements`.
        """
        with codecs.open(filename, "r", encoding="utf-8") as f:
            contents = f.read()
        for old, new_ in self.replacements.items():
            contents = contents.replace(old, new_)
        with codecs.open(filename, "w", encoding="utf-8") as f:
            f.write(contents)


class EditMixin(PluginMixin):
    """Helper containing some common functionality used for the Edit tests."""

    plugin = "edit"

    def assertItemFieldsModified(
        self, library_items, items, fields=[], allowed=["path"]
    ):
        """Assert that items in the library (`lib_items`) have different values
        on the specified `fields` (and *only* on those fields), compared to
        `items`.

        An empty `fields` list results in asserting that no modifications have
        been performed. `allowed` is a list of field changes that are ignored
        (they may or may not have changed; the assertion doesn't care).
        """
        for lib_item, item in zip(library_items, items):
            diff_fields = [
                field
                for field in lib_item._fields
                if lib_item[field] != item[field]
            ]
            assert set(diff_fields).difference(allowed) == set(fields)

    def run_mocked_interpreter(self, modify_file_args={}, stdin=[]):
        """Run the edit command during an import session, with mocked stdin and
        yaml writing.
        """
        m = ModifyFileMocker(**modify_file_args)
        with patch("beetsplug.edit.edit", side_effect=m.action):
            for char in stdin:
                self.importer.add_choice(char)
            self.importer.run()

    def run_mocked_command(self, modify_file_args={}, stdin=[], args=[]):
        """Run the edit command, with mocked stdin and yaml writing, and
        passing `args` to `run_command`."""
        m = ModifyFileMocker(**modify_file_args)
        with patch("beetsplug.edit.edit", side_effect=m.action):
            for char in stdin:
                self.io.addinput(char)
            self.run_command("edit", *args)


@_common.slow_test()
@patch("beets.library.Item.write")
class EditCommandTest(IOMixin, EditMixin, BeetsTestCase):
    """Black box tests for `beetsplug.edit`. Command line interaction is
    simulated using mocked stdin, and yaml editing via an external editor is
    simulated using `ModifyFileMocker`.
    """

    ALBUM_COUNT = 1
    TRACK_COUNT = 10

    def setUp(self):
        super().setUp()
        # Add an album, storing the original fields for comparison.
        self.album = self.add_album_fixture(track_count=self.TRACK_COUNT)
        self.album_orig = {f: self.album[f] for f in self.album._fields}
        self.items_orig = [
            {f: item[f] for f in item._fields} for item in self.album.items()
        ]

    def test_title_edit_discard(self, mock_write):
        """Edit title for all items in the library, then discard changes."""
        # Edit track titles.
        self.run_mocked_command(
            {"replacements": {"t\u00eftle": "modified t\u00eftle"}},
            # Cancel.
            ["c"],
        )

        assert mock_write.call_count == 0
        self.assertItemFieldsModified(self.album.items(), self.items_orig, [])

    def test_title_edit_apply(self, mock_write):
        """Edit title for all items in the library, then apply changes."""
        # Edit track titles.
        self.run_mocked_command(
            {"replacements": {"t\u00eftle": "modified t\u00eftle"}},
            # Apply changes.
            ["a"],
        )

        assert mock_write.call_count == self.TRACK_COUNT
        self.assertItemFieldsModified(
            self.album.items(), self.items_orig, ["title", "mtime"]
        )

    def test_single_title_edit_apply(self, mock_write):
        """Edit title for one item in the library, then apply changes."""
        # Edit one track title.
        self.run_mocked_command(
            {"replacements": {"t\u00eftle 9": "modified t\u00eftle 9"}},
            # Apply changes.
            ["a"],
        )

        assert mock_write.call_count == 1
        # No changes except on last item.
        self.assertItemFieldsModified(
            list(self.album.items())[:-1], self.items_orig[:-1], []
        )
        assert list(self.album.items())[-1].title == "modified t\u00eftle 9"

    def test_title_edit_keep_editing_then_apply(self, mock_write):
        """Edit titles, keep editing once, then apply changes."""
        self.run_mocked_command(
            {"replacements": {"t\u00eftle": "modified t\u00eftle"}},
            # keep Editing, then Apply
            ["e", "a"],
        )

        assert mock_write.call_count == self.TRACK_COUNT
        self.assertItemFieldsModified(
            self.album.items(),
            self.items_orig,
            ["title", "mtime"],
        )

    def test_title_edit_keep_editing_then_cancel(self, mock_write):
        """Edit titles, keep editing once, then cancel."""
        self.run_mocked_command(
            {"replacements": {"t\u00eftle": "modified t\u00eftle"}},
            # keep Editing, then Cancel
            ["e", "c"],
        )

        assert mock_write.call_count == 0
        self.assertItemFieldsModified(
            self.album.items(),
            self.items_orig,
            [],
        )

    def test_noedit(self, mock_write):
        """Do not edit anything."""
        # Do not edit anything.
        self.run_mocked_command(
            {"contents": None},
            # No stdin.
            [],
        )

        assert mock_write.call_count == 0
        self.assertItemFieldsModified(self.album.items(), self.items_orig, [])

    def test_album_edit_apply(self, mock_write):
        """Edit the album field for all items in the library, apply changes.
        By design, the album should not be updated.""
        """
        # Edit album.
        self.run_mocked_command(
            {"replacements": {"\u00e4lbum": "modified \u00e4lbum"}},
            # Apply changes.
            ["a"],
        )

        assert mock_write.call_count == self.TRACK_COUNT
        self.assertItemFieldsModified(
            self.album.items(), self.items_orig, ["album", "mtime"]
        )
        # Ensure album is *not* modified.
        self.album.load()
        assert self.album.album == "\u00e4lbum"

    def test_single_edit_add_field(self, mock_write):
        """Edit the yaml file appending an extra field to the first item, then
        apply changes."""
        # Append "foo: bar" to item with id == 2. ("id: 1" would match both
        # "id: 1" and "id: 10")
        self.run_mocked_command(
            {"replacements": {"id: 2": "id: 2\nfoo: bar"}},
            # Apply changes.
            ["a"],
        )

        assert self.lib.items("id:2")[0].foo == "bar"
        # Even though a flexible attribute was written (which is not directly
        # written to the tags), write should still be called since templates
        # might use it.
        assert mock_write.call_count == 1

    def test_a_album_edit_apply(self, mock_write):
        """Album query (-a), edit album field, apply changes."""
        self.run_mocked_command(
            {"replacements": {"\u00e4lbum": "modified \u00e4lbum"}},
            # Apply changes.
            ["a"],
            args=["-a"],
        )

        self.album.load()
        assert mock_write.call_count == self.TRACK_COUNT
        assert self.album.album == "modified \u00e4lbum"
        self.assertItemFieldsModified(
            self.album.items(), self.items_orig, ["album", "mtime"]
        )

    def test_a_albumartist_edit_apply(self, mock_write):
        """Album query (-a), edit albumartist field, apply changes."""
        self.run_mocked_command(
            {"replacements": {"album artist": "modified album artist"}},
            # Apply changes.
            ["a"],
            args=["-a"],
        )

        self.album.load()
        assert mock_write.call_count == self.TRACK_COUNT
        assert self.album.albumartist == "the modified album artist"
        self.assertItemFieldsModified(
            self.album.items(), self.items_orig, ["albumartist", "mtime"]
        )

    def test_malformed_yaml(self, mock_write):
        """Edit the yaml file incorrectly (resulting in a malformed yaml
        document)."""
        # Edit the yaml file to an invalid file.
        self.run_mocked_command(
            {"contents": "!MALFORMED"},
            # Edit again to fix? No.
            ["n"],
        )

        assert mock_write.call_count == 0

    def test_invalid_yaml(self, mock_write):
        """Edit the yaml file incorrectly (resulting in a well-formed but
        invalid yaml document)."""
        # Edit the yaml file to an invalid but parseable file.
        self.run_mocked_command(
            {"contents": "wellformed: yes, but invalid"},
            # No stdin.
            [],
        )

        assert mock_write.call_count == 0


@_common.slow_test()
class EditDuringImporterTestCase(
    EditMixin, TerminalImportMixin, AutotagImportTestCase
):
    """TODO"""

    matching = AutotagStub.GOOD

    IGNORED: ClassVar[list[str]] = ["added", "album_id", "id", "mtime", "path"]

    def setUp(self):
        super().setUp()
        # Create some mediafiles, and store them for comparison.
        self.prepare_album_for_import(1)
        self.items_orig = [Item.from_path(f.path) for f in self.import_media]


@_common.slow_test()
class EditDuringImporterNonSingletonTest(EditDuringImporterTestCase):
    def setUp(self):
        super().setUp()
        self.importer = self.setup_importer()

    def test_edit_apply_asis(self):
        """Edit the album field for all items in the library, apply changes,
        using the original item tags.
        """
        # Edit track titles.
        self.run_mocked_interpreter(
            {"replacements": {"Tag Track": "Edited Track"}},
            # eDit, Apply changes.
            ["d", "a"],
        )

        # Check that only the 'title' field is modified.
        self.assertItemFieldsModified(
            self.lib.items(),
            self.items_orig,
            ["title", "albumartist", "albumartists"],
            [*self.IGNORED, "mb_albumartistid", "mb_albumartistids"],
        )
        assert all("Edited Track" in i.title for i in self.lib.items())

        # Ensure album is *not* fetched from a candidate.
        assert self.lib.albums()[0].mb_albumid == ""

    def test_edit_discard_asis(self):
        """Edit the album field for all items in the library, discard changes,
        using the original item tags.
        """
        # Edit track titles.
        self.run_mocked_interpreter(
            {"replacements": {"Tag Track": "Edited Track"}},
            # eDit, Cancel, Use as-is.
            ["d", "c", "u"],
        )

        # Check that nothing is modified, the album is imported ASIS.
        self.assertItemFieldsModified(
            self.lib.items(),
            self.items_orig,
            [],
            [*self.IGNORED, "albumartist", "mb_albumartistid"],
        )
        assert all("Tag Track" in i.title for i in self.lib.items())

        # Ensure album is *not* fetched from a candidate.
        assert self.lib.albums()[0].mb_albumid == ""

    def test_edit_apply_candidate(self):
        """Edit the album field for all items in the library, apply changes,
        using a candidate.
        """
        # Edit track titles.
        self.run_mocked_interpreter(
            {"replacements": {"Applied Track": "Edited Track"}},
            # edit Candidates, 1, Apply changes.
            ["c", "1", "a"],
        )

        # Check that 'title' field is modified, and other fields come from
        # the candidate.
        assert all("Edited Track " in i.title for i in self.lib.items())
        assert all("match " in i.mb_trackid for i in self.lib.items())

        # Ensure album is fetched from a candidate.
        assert "albumid" in self.lib.albums()[0].mb_albumid

    def test_edit_retag_apply(self):
        """Import the album using a candidate, then retag and edit and apply
        changes.
        """
        self.run_mocked_interpreter(
            {},
            # 1, Apply changes.
            ["1", Action.APPLY],
        )

        # Retag and edit track titles.  On retag, the importer will reset items
        # ids but not the db connections.
        self.importer.paths = []
        self.importer.query = TrueQuery()
        self.run_mocked_interpreter(
            {"replacements": {"Applied Track": "Edited Track"}},
            # eDit, Apply changes.
            ["d", "a"],
        )

        # Check that 'title' field is modified, and other fields come from
        # the candidate.
        assert all("Edited Track " in i.title for i in self.lib.items())
        assert all("match " in i.mb_trackid for i in self.lib.items())

        # Ensure album is fetched from a candidate.
        assert "albumid" in self.lib.albums()[0].mb_albumid

    def test_edit_discard_candidate(self):
        """Edit the album field for all items in the library, discard changes,
        using a candidate.
        """
        # Edit track titles.
        self.run_mocked_interpreter(
            {"replacements": {"Applied Track": "Edited Track"}},
            # edit Candidates, 1, Apply changes.
            ["c", "1", "a"],
        )

        # Check that 'title' field is modified, and other fields come from
        # the candidate.
        assert all("Edited Track " in i.title for i in self.lib.items())
        assert all("match " in i.mb_trackid for i in self.lib.items())

        # Ensure album is fetched from a candidate.
        assert "albumid" in self.lib.albums()[0].mb_albumid

    def test_edit_apply_candidate_singleton(self):
        """Edit the album field for all items in the library, apply changes,
        using a candidate and singleton mode.
        """
        # Edit track titles.
        self.run_mocked_interpreter(
            {"replacements": {"Applied Track": "Edited Track"}},
            # edit Candidates, 1, Apply changes, aBort.
            ["c", "1", "a", "b"],
        )

        # Check that 'title' field is modified, and other fields come from
        # the candidate.
        assert all("Edited Track " in i.title for i in self.lib.items())
        assert all("match " in i.mb_trackid for i in self.lib.items())


@_common.slow_test()
class EditDuringImporterSingletonTest(EditDuringImporterTestCase):
    def setUp(self):
        super().setUp()
        self.importer = self.setup_singleton_importer()

    def test_edit_apply_asis_singleton(self):
        """Edit the album field for all items in the library, apply changes,
        using the original item tags and singleton mode.
        """
        # Edit track titles.
        self.run_mocked_interpreter(
            {"replacements": {"Tag Track": "Edited Track"}},
            # eDit, Apply changes, aBort.
            ["d", "a", "b"],
        )

        # Check that only the 'title' field is modified.
        self.assertItemFieldsModified(
            self.lib.items(),
            self.items_orig,
            ["title"],
            [*self.IGNORED, "albumartist", "mb_albumartistid"],
        )
        assert all("Edited Track" in i.title for i in self.lib.items())
