import importlib.util
import json
import sys
import unittest
from decimal import Decimal
from pathlib import Path
from unittest import mock


SCRIPT_PATH = (
    Path(__file__).resolve().parents[1] / "scripts" / "update_estimates.py"
)
SPEC = importlib.util.spec_from_file_location("update_estimates", SCRIPT_PATH)
MODULE = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
sys.modules[SPEC.name] = MODULE
SPEC.loader.exec_module(MODULE)


class ParseHoursInputTest(unittest.TestCase):
    def test_scalar_hours_are_reused_for_each_todo(self) -> None:
        estimates = MODULE.parse_hours_input("2.5", 3)

        self.assertEqual(
            estimates,
            [
                (Decimal("2.5"), 9000),
                (Decimal("2.5"), 9000),
                (Decimal("2.5"), 9000),
            ],
        )

    def test_json_array_hours_map_one_to_one(self) -> None:
        estimates = MODULE.parse_hours_input("[1,2.5,\"4\"]", 3)

        self.assertEqual(
            estimates,
            [
                (Decimal("1"), 3600),
                (Decimal("2.5"), 9000),
                (Decimal("4"), 14400),
            ],
        )

    def test_json_array_length_must_match_active_todos(self) -> None:
        with self.assertRaises(MODULE.ScriptError) as caught:
            MODULE.parse_hours_input("[1,2.5]", 3)

        self.assertIn("expected 3, got 2", str(caught.exception))


class BuildPlanTest(unittest.TestCase):
    def test_rejects_duplicate_normalized_basecamp_titles(self) -> None:
        todos = [
            MODULE.TodoItem(id="1", title="Ship estimate"),
            MODULE.TodoItem(id="2", title="Ship estimate [1h]"),
        ]
        estimates = [
            (Decimal("1"), 3600),
            (Decimal("2"), 7200),
        ]
        everhour = mock.Mock()
        everhour.match_task.return_value = {"id": "abc123", "name": "Ship estimate"}

        with self.assertRaises(MODULE.ScriptError) as caught:
            MODULE.build_plan(todos, estimates, everhour)

        self.assertIn("duplicates todo 1", str(caught.exception))
        everhour.match_task.assert_called_once_with("Ship estimate", source_id="1")

    def test_rejects_duplicate_everhour_task_matches(self) -> None:
        todos = [
            MODULE.TodoItem(id="1", title="First task"),
            MODULE.TodoItem(id="2", title="Second task"),
        ]
        estimates = [
            (Decimal("1"), 3600),
            (Decimal("2"), 7200),
        ]
        everhour = mock.Mock()
        everhour.match_task.side_effect = [
            {"id": "abc123", "name": "Shared task"},
            {"id": "abc123", "name": "Shared task"},
        ]

        with self.assertRaises(MODULE.ScriptError) as caught:
            MODULE.build_plan(todos, estimates, everhour)

        self.assertIn("todo 1 and todo 2", str(caught.exception))
        self.assertEqual(everhour.match_task.call_count, 2)

    def test_passes_basecamp_todo_id_to_everhour_matcher(self) -> None:
        todos = [MODULE.TodoItem(id="1", title="Ship estimate")]
        estimates = [(Decimal("1"), 3600)]
        everhour = mock.Mock()
        everhour.match_task.return_value = {
            "id": "abc123",
            "name": "Ship estimate",
        }

        plans = MODULE.build_plan(todos, estimates, everhour)

        everhour.match_task.assert_called_once_with("Ship estimate", source_id="1")
        self.assertEqual(plans[0].everhour_task_id, "abc123")


class EverhourClientMatchTaskTest(unittest.TestCase):
    def test_duplicate_open_matches_are_resolved_by_basecamp_todo_id(self) -> None:
        client = MODULE.EverhourClient(api_key="key", project_id="project")
        client.list_open_tasks = mock.Mock(
            return_value={
                "ship estimate": [
                    {"id": "b3:1", "name": "Ship estimate"},
                    {"id": "b3:2", "name": "Ship estimate"},
                ],
            }
        )
        client.search_tasks = mock.Mock(return_value=[])

        task = client.match_task("Ship estimate", source_id="2")

        self.assertEqual(task["id"], "b3:2")
        client.search_tasks.assert_not_called()

    def test_duplicate_open_matches_still_fail_without_todo_id_match(self) -> None:
        client = MODULE.EverhourClient(api_key="key", project_id="project")
        client.list_open_tasks = mock.Mock(
            return_value={
                "ship estimate": [
                    {"id": "b3:1", "name": "Ship estimate"},
                    {"id": "b3:2", "name": "Ship estimate"},
                ],
            }
        )
        client.search_tasks = mock.Mock(
            return_value=[
                {"id": "b3:1", "name": "Ship estimate"},
                {"id": "b3:2", "name": "Ship estimate"},
            ]
        )

        with self.assertRaises(MODULE.ScriptError) as caught:
            client.match_task("Ship estimate", source_id="3")

        self.assertIn("Basecamp todo 3", str(caught.exception))

    def test_duplicate_search_matches_are_resolved_by_basecamp_todo_id(self) -> None:
        client = MODULE.EverhourClient(api_key="key", project_id="project")
        client.list_open_tasks = mock.Mock(return_value={})
        client.search_tasks = mock.Mock(
            return_value=[
                {"id": "b3:1", "name": "Ship estimate"},
                {"id": "b3:2", "name": "Ship estimate"},
            ]
        )

        task = client.match_task("Ship estimate", source_id="2")

        self.assertEqual(task["id"], "b3:2")
        client.search_tasks.assert_called_once_with("Ship estimate")

    def test_basecamp_todo_id_can_match_task_url(self) -> None:
        task = {
            "id": "opaque",
            "name": "Ship estimate",
            "url": "https://3.basecamp.com/1/buckets/2/todos/123",
        }

        self.assertTrue(MODULE.task_matches_source_id(task, "123"))


class UpdateBasecampTitleTest(unittest.TestCase):
    def test_fetches_current_todo_then_puts_with_only_changed_content(self) -> None:
        todo = MODULE.TodoItem(
            id="123",
            title="Ship estimate",
            api_url="https://3.basecampapi.com/1/buckets/2/todos/123.json",
        )
        current_payload = {
            "id": 123,
            "content": "Ship estimate",
            "description": "<div>Original notes with image</div>",
            "assignees": [{"id": 7}, {"id": 8}],
            "completion_subscribers": [{"id": 9}],
            "due_on": "2026-05-01",
            "starts_on": None,
        }

        with mock.patch.object(MODULE, "run_basecamp_json") as run_basecamp_json:
            run_basecamp_json.side_effect = [current_payload, {}]
            MODULE.update_basecamp_title("basecamp", todo, "Ship estimate [2h]")

        self.assertEqual(run_basecamp_json.call_count, 2)
        get_call, put_call = run_basecamp_json.call_args_list

        self.assertEqual(
            get_call.args,
            (
                "basecamp",
                "api",
                "get",
                "https://3.basecampapi.com/1/buckets/2/todos/123.json",
            ),
        )

        self.assertEqual(put_call.args[:5], (
            "basecamp",
            "api",
            "put",
            "https://3.basecampapi.com/1/buckets/2/todos/123.json",
            "-d",
        ))
        body = json.loads(put_call.args[5])
        self.assertEqual(body["content"], "Ship estimate [2h]")
        self.assertEqual(body["description"], "<div>Original notes with image</div>")
        self.assertEqual(body["assignee_ids"], [7, 8])
        self.assertEqual(body["completion_subscriber_ids"], [9])
        self.assertEqual(body["due_on"], "2026-05-01")
        self.assertNotIn("starts_on", body)

    def test_omits_fields_absent_from_current_todo(self) -> None:
        todo = MODULE.TodoItem(
            id="123",
            title="Ship estimate",
            api_url="https://3.basecampapi.com/1/buckets/2/todos/123.json",
        )
        current_payload = {
            "id": 123,
            "content": "Ship estimate",
            "description": "",
            "assignees": [],
            "completion_subscribers": [],
            "due_on": None,
            "starts_on": None,
        }

        with mock.patch.object(MODULE, "run_basecamp_json") as run_basecamp_json:
            run_basecamp_json.side_effect = [current_payload, {}]
            MODULE.update_basecamp_title("basecamp", todo, "Ship estimate [2h]")

        put_call = run_basecamp_json.call_args_list[1]
        body = json.loads(put_call.args[5])
        self.assertEqual(body, {"content": "Ship estimate [2h]"})

    def test_rejects_missing_basecamp_api_url(self) -> None:
        todo = MODULE.TodoItem(id="123", title="Ship estimate")

        with self.assertRaises(MODULE.ScriptError) as caught:
            MODULE.update_basecamp_title("basecamp", todo, "Ship estimate [2h]")

        self.assertIn("did not include an API URL", str(caught.exception))


class BuildBasecampUpdatePayloadTest(unittest.TestCase):
    def test_extracts_assignee_and_subscriber_ids_and_preserves_dates(self) -> None:
        current = {
            "content": "Old",
            "description": "<p>note</p>",
            "assignees": [{"id": 1}, {"id": 2}],
            "completion_subscribers": [{"id": 3}],
            "due_on": "2026-05-01",
            "starts_on": "2026-04-01",
        }

        payload = MODULE.build_basecamp_update_payload(current, "New title")

        self.assertEqual(
            payload,
            {
                "content": "New title",
                "description": "<p>note</p>",
                "assignee_ids": [1, 2],
                "completion_subscriber_ids": [3],
                "due_on": "2026-05-01",
                "starts_on": "2026-04-01",
            },
        )

    def test_skips_empty_or_missing_fields(self) -> None:
        payload = MODULE.build_basecamp_update_payload({"content": "Old"}, "New title")

        self.assertEqual(payload, {"content": "New title"})


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