# Tools for A Feminist Audit of Pop Music.
# Copyright 2026 imacat.  All rights reserved.
# Authors:
#   imacat@mail.imacat.idv.tw (imacat), 2026/8/6
"""Unit tests for the coding run comparison module."""
import io
import json
import tempfile
import unittest
from contextlib import redirect_stderr
from pathlib import Path
from typing import Any

from pop_fem_audit_tools.commands import compare_codings


class TestCompareCodings(unittest.TestCase):
    """Test cases for the coding run comparison."""

    def setUp(self) -> None:
        """Create a temporary directory with two run directories."""
        tmp: tempfile.TemporaryDirectory[str] \
            = tempfile.TemporaryDirectory()
        self.addCleanup(tmp.cleanup)
        self.__dir: Path = Path(tmp.name)
        self.__run1: Path = self.__dir / "run1"
        self.__run2: Path = self.__dir / "run2"
        self.__run1.mkdir()
        self.__run2.mkdir()
        self.__output_dir: Path = self.__dir / "output"
        self.__disagreements_json: Path \
            = self.__output_dir \
            / compare_codings.DISAGREEMENTS_JSON

    @staticmethod
    def __write_output(
            run_dir: Path, records: list[dict[str, Any]]) -> None:
        """Write the ``output.jsonl`` file of one run.

        :param run_dir: The run's archive directory.
        :param records: The envelope records, in file order.
        :return: None.
        """
        lines: list[str] = [
            json.dumps(x, ensure_ascii=False) for x in records]
        (run_dir / "output.jsonl").write_text(
            "\n".join(lines) + "\n", encoding="utf-8")

    def __write_codings(
            self, run_dir: Path,
            codings: dict[str, dict[str, list[str]]]) -> None:
        """Write the coding of every song of one run.

        :param run_dir: The run's archive directory.
        :param codings: The keyword assignments of every song,
            keyed by the song ID, in file order.
        :return: None.
        """
        self.__write_output(run_dir, [
            {"id": song_id, "text": json.dumps(
                coding, ensure_ascii=False),
             "stop_reason": "end_turn", "usage": {}}
            for song_id, coding in codings.items()])

    def __run_compare(self) -> tuple[int, str]:
        """Run the comparison with captured standard error.

        :return: A tuple of the exit status and the standard
            error.
        """
        argv: list[str] = [
            str(self.__run1), str(self.__run2),
            str(self.__output_dir)]
        stderr: io.StringIO = io.StringIO()
        with redirect_stderr(stderr):
            status: int = compare_codings.main(argv)
        return status, stderr.getvalue()

    def __read_disagreements(self) -> dict[str, Any]:
        """Read the disagreement JSON file.

        :return: The parsed disagreements.
        """
        return json.loads(
            self.__disagreements_json.read_text(encoding="utf-8"))

    def __read_disagreement_text(self) -> str:
        """Read the disagreement JSON file verbatim.

        :return: The file content, as written.
        """
        return self.__disagreements_json.read_text(
            encoding="utf-8")

    def test_keyword_in_one_run_only_disagrees(self) -> None:
        """Test that a keyword assigned by exactly one run is a
        disagreement carrying that run's quotes."""
        self.__write_codings(self.__run1, {
            "song-1": {"shared": ["q1"], "only-1": ["q2"]}})
        self.__write_codings(self.__run2, {
            "song-1": {"shared": ["q3"], "only-2": ["q4"]}})
        status: int
        status, _ = self.__run_compare()
        self.assertEqual(status, 0)
        self.assertEqual(self.__read_disagreements(), {
            "song-1": {"only-1": ["q2"], "only-2": ["q4"]}})

    def test_quotes_do_not_take_part_in_comparison(self) -> None:
        """Test that identical key sets with different quotes
        yield no disagreement at all."""
        self.__write_codings(self.__run1, {
            "song-1": {"shared": ["one quote"]}})
        self.__write_codings(self.__run2, {
            "song-1": {"shared": ["another", "quote"]}})
        status: int
        stderr: str
        status, stderr = self.__run_compare()
        self.assertEqual(status, 0)
        self.assertEqual(self.__read_disagreements(), {})
        self.assertIn("0 of 1 songs disagree on 0 keywords.",
                      stderr)

    def test_agreeing_songs_omitted(self) -> None:
        """Test that only the songs with at least one
        disagreement are written."""
        self.__write_codings(self.__run1, {
            "song-1": {"shared": ["q"]},
            "song-2": {"shared": ["q"], "only-1": ["q"]}})
        self.__write_codings(self.__run2, {
            "song-1": {"shared": ["q"]},
            "song-2": {"shared": ["q"]}})
        status: int
        status, _ = self.__run_compare()
        self.assertEqual(status, 0)
        self.assertEqual(
            list(self.__read_disagreements().keys()), ["song-2"])

    def test_songs_in_ascending_song_number_order(self) -> None:
        """Test that the songs are ordered by ascending song
        number, not by the song ID as text."""
        self.__write_codings(self.__run1, {
            "song-10": {"only-1": ["q"]},
            "song-2": {"only-1": ["q"]},
            "song-1": {"only-1": ["q"]}})
        self.__write_codings(self.__run2, {
            "song-1": {}, "song-2": {}, "song-10": {}})
        status: int
        status, _ = self.__run_compare()
        self.assertEqual(status, 0)
        self.assertEqual(
            list(self.__read_disagreements().keys()),
            ["song-1", "song-2", "song-10"])

    def test_keywords_in_lexicographic_order(self) -> None:
        """Test that a song's disagreed keywords are ordered
        lexicographically, whichever run assigned them."""
        self.__write_codings(self.__run1, {
            "song-1": {"zeta": ["q"], "alpha": ["q"]}})
        self.__write_codings(self.__run2, {
            "song-1": {"mu": ["q"], "beta": ["q"]}})
        status: int
        status, _ = self.__run_compare()
        self.assertEqual(status, 0)
        self.assertEqual(
            list(self.__read_disagreements()["song-1"].keys()),
            ["alpha", "beta", "mu", "zeta"])

    def test_summary_line_counts(self) -> None:
        """Test the closing summary line's song and keyword
        counts."""
        self.__write_codings(self.__run1, {
            "song-1": {"only-1": ["q"], "also-1": ["q"]},
            "song-2": {"shared": ["q"]},
            "song-3": {}})
        self.__write_codings(self.__run2, {
            "song-1": {},
            "song-2": {"shared": ["q"]},
            "song-3": {"only-2": ["q"]}})
        status: int
        stderr: str
        status, stderr = self.__run_compare()
        self.assertEqual(status, 0)
        self.assertIn("Done.  2 of 3 songs disagree on 3"
                      " keywords.", stderr)
        self.assertIn("elapsed.", stderr)

    def test_control_character_in_quote_kept(self) -> None:
        """Test that a quote holding a line-separating control
        character neither truncates the record nor is lost."""
        quote: str = "first line\u0085second line"
        self.__write_codings(self.__run1, {
            "song-1": {"only-1": [quote]}})
        self.__write_codings(self.__run2, {"song-1": {}})
        status: int
        status, _ = self.__run_compare()
        self.assertEqual(status, 0)
        self.assertEqual(
            self.__read_disagreements(),
            {"song-1": {"only-1": [quote]}})

    def test_non_ascii_written_verbatim(self) -> None:
        """Test that the output is UTF-8 with the non-ASCII text
        unescaped, indented by two spaces, and newline
        terminated."""
        self.__write_codings(self.__run1, {
            "song-1": {"only-1": ["女性力量"]}})
        self.__write_codings(self.__run2, {"song-1": {}})
        status: int
        status, _ = self.__run_compare()
        self.assertEqual(status, 0)
        text: str = self.__read_disagreement_text()
        self.assertIn("女性力量", text)
        self.assertIn("\n  \"song-1\": {", text)
        self.assertTrue(text.endswith("}\n"))

    def test_existing_output_dir_reused(self) -> None:
        """Test that an already-existing output directory is
        written into rather than rejected."""
        self.__output_dir.mkdir(parents=True)
        (self.__output_dir / "keep.txt").write_text(
            "kept", encoding="utf-8")
        self.__write_codings(self.__run1, {
            "song-1": {"only-1": ["q"]}})
        self.__write_codings(self.__run2, {"song-1": {}})
        status: int
        status, _ = self.__run_compare()
        self.assertEqual(status, 0)
        self.assertTrue(self.__disagreements_json.exists())
        self.assertEqual(
            (self.__output_dir / "keep.txt").read_text(
                encoding="utf-8"),
            "kept")

    def test_mismatched_song_sets_rejected(self) -> None:
        """Test that runs covering different songs fail without
        writing the disagreement file."""
        self.__write_codings(self.__run1, {
            "song-1": {"only-1": ["q"]}})
        self.__write_codings(self.__run2, {
            "song-2": {"only-2": ["q"]}})
        status: int
        stderr: str
        status, stderr = self.__run_compare()
        self.assertEqual(status, 1)
        self.assertIn("song-1", stderr)
        self.assertIn("song-2", stderr)
        self.assertFalse(self.__disagreements_json.exists())

    def test_extra_song_in_one_run_rejected(self) -> None:
        """Test that one run covering an extra song fails."""
        self.__write_codings(self.__run1, {
            "song-1": {}, "song-2": {}})
        self.__write_codings(self.__run2, {"song-1": {}})
        status: int
        stderr: str
        status, stderr = self.__run_compare()
        self.assertEqual(status, 1)
        self.assertIn("song-2", stderr)
        self.assertFalse(self.__disagreements_json.exists())

    def test_non_object_text_rejected(self) -> None:
        """Test that a "text" field parsing to something other
        than a JSON object fails the run."""
        self.__write_output(self.__run1, [
            {"id": "song-1", "text": json.dumps(["only-1"])}])
        self.__write_codings(self.__run2, {"song-1": {}})
        status: int
        stderr: str
        status, stderr = self.__run_compare()
        self.assertEqual(status, 1)
        self.assertIn("song-1", stderr)
        self.assertFalse(self.__disagreements_json.exists())

    def test_unparsable_text_rejected(self) -> None:
        """Test that a "text" field that is not JSON at all, such
        as a refusal, fails the run."""
        self.__write_output(self.__run1, [
            {"id": "song-1", "text": "I cannot help with that."}])
        self.__write_codings(self.__run2, {"song-1": {}})
        status: int
        stderr: str
        status, stderr = self.__run_compare()
        self.assertEqual(status, 1)
        self.assertIn("song-1", stderr)
        self.assertFalse(self.__disagreements_json.exists())

    def test_duplicate_keyword_in_text_rejected(self) -> None:
        """Test that a "text" JSON object with a duplicate keyword
        fails the run."""
        self.__write_output(self.__run1, [
            {"id": "song-1",
             "text": '{"only-1": ["q"], "only-1": ["r"]}'}])
        self.__write_codings(self.__run2, {"song-1": {}})
        status: int
        stderr: str
        status, stderr = self.__run_compare()
        self.assertEqual(status, 1)
        self.assertIn("duplicate key", stderr)
        self.assertFalse(self.__disagreements_json.exists())

    def test_duplicate_key_in_envelope_rejected(self) -> None:
        """Test that an envelope record with a duplicate key fails
        the run."""
        (self.__run1 / "output.jsonl").write_text(
            '{"id": "song-1", "id": "song-2", "text": "{}"}\n',
            encoding="utf-8")
        self.__write_codings(self.__run2, {"song-1": {}})
        status: int
        stderr: str
        status, stderr = self.__run_compare()
        self.assertEqual(status, 1)
        self.assertIn("duplicate key", stderr)
        self.assertFalse(self.__disagreements_json.exists())

    def test_duplicate_song_id_rejected(self) -> None:
        """Test that the same song ID appearing twice in one run
        fails the run."""
        self.__write_output(self.__run1, [
            {"id": "song-1", "text": "{}"},
            {"id": "song-1", "text": "{}"}])
        self.__write_codings(self.__run2, {"song-1": {}})
        status: int
        stderr: str
        status, stderr = self.__run_compare()
        self.assertEqual(status, 1)
        self.assertIn("duplicate song ID", stderr)
        self.assertFalse(self.__disagreements_json.exists())

    def test_malformed_song_id_rejected(self) -> None:
        """Test that a song ID not in the ``song-<N>`` form fails
        the run."""
        self.__write_output(self.__run1, [
            {"id": "track-1", "text": "{}"}])
        self.__write_codings(self.__run2, {"song-1": {}})
        status: int
        stderr: str
        status, stderr = self.__run_compare()
        self.assertEqual(status, 1)
        self.assertIn("track-1", stderr)
        self.assertFalse(self.__disagreements_json.exists())

    def test_missing_output_file_reported(self) -> None:
        """Test that a run directory without ``output.jsonl``
        fails with the path in the message."""
        self.__write_codings(self.__run2, {"song-1": {}})
        status: int
        stderr: str
        status, stderr = self.__run_compare()
        self.assertEqual(status, 1)
        self.assertIn("output.jsonl", stderr)
        self.assertFalse(self.__disagreements_json.exists())

    def test_blank_lines_skipped(self) -> None:
        """Test that blank lines in ``output.jsonl`` are
        ignored."""
        (self.__run1 / "output.jsonl").write_text(
            '\n{"id": "song-1", "text": "{\\"only-1\\": [\\"q\\"]}"}'
            "\n\n",
            encoding="utf-8")
        self.__write_codings(self.__run2, {"song-1": {}})
        status: int
        status, _ = self.__run_compare()
        self.assertEqual(status, 0)
        self.assertEqual(
            self.__read_disagreements(),
            {"song-1": {"only-1": ["q"]}})

    def test_compare_returns_agreed_keyword_names(self) -> None:
        """Test that the comparison function returns the agreed
        keyword names alone, sorted, without their quotes."""
        codings1: dict[str, dict[str, list[str]]] = {
            "song-1": {"zeta": ["a", "b"], "alpha": ["c"],
                       "only-1": ["d"]}}
        codings2: dict[str, dict[str, list[str]]] = {
            "song-1": {"zeta": ["b", "e"], "alpha": ["f"]}}
        agreed: dict[str, list[str]]
        disagreed: dict[str, dict[str, list[str]]]
        agreed, disagreed = compare_codings.compare_codings(
            codings1, codings2)
        self.assertEqual(agreed, {"song-1": ["alpha", "zeta"]})
        self.assertEqual(
            disagreed, {"song-1": {"only-1": ["d"]}})

    def test_compare_omits_songs_without_agreement(self) -> None:
        """Test that a song with no agreed keyword has no entry in
        the agreed half."""
        agreed: dict[str, list[str]]
        agreed = compare_codings.compare_codings(
            {"song-1": {"only-1": ["q"]}, "song-2": {"both": ["q"]}},
            {"song-1": {}, "song-2": {"both": ["q"]}})[0]
        self.assertEqual(list(agreed.keys()), ["song-2"])

    def test_compare_rejects_different_song_sets(self) -> None:
        """Test that the comparison function rejects runs that do
        not cover the same songs."""
        with self.assertRaises(ValueError):
            compare_codings.compare_codings(
                {"song-1": {}}, {"song-2": {}})
