#
# Copyright 2009- ECMWF.
#
# This software is licensed under the terms of the Apache Licence version 2.0
# which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
# In applying this licence, ECMWF does not waive the privileges and immunities
# granted to it by virtue of its status as an intergovernmental organisation
# nor does it submit to any jurisdiction.
#

import copy
import unittest

import ecflow as ecf


class TestTaskVec(unittest.TestCase):
    """Tests for py::class_<std::vector<task_ptr>> exposed as ecf.TaskVec in ExportTask.cpp.

    Exposed API
    -----------
    Constructors
        TaskVec()                    -- default constructor creates an empty vector

    Instance methods (from vector_indexing_suite)
        __len__                      -- number of tasks
        __getitem__(int)             -- index access; raises IndexError for out-of-range
        __setitem__(int, task_ptr)   -- index assignment
        __delitem__(int)             -- index deletion
        __iter__                     -- iterate over contained tasks
        __contains__(task_ptr)       -- membership test
        append(task_ptr)             -- append a task
    """

    # ------------------------------------------------------------------
    # Constructor
    # ------------------------------------------------------------------

    def test_default_constructor_creates_empty_vec(self):
        """TaskVec() constructs an empty vector."""
        tv = ecf.TaskVec()
        self.assertIsInstance(tv, ecf.TaskVec)

    def test_default_constructor_has_zero_len(self):
        """A freshly constructed TaskVec has length zero."""
        self.assertEqual(len(ecf.TaskVec()), 0)

    # ------------------------------------------------------------------
    # __len__ and append
    # ------------------------------------------------------------------

    def test_len_grows_with_append(self):
        """__len__ reflects the number of tasks appended."""
        tv = ecf.TaskVec()
        tv.append(ecf.Task("a"))
        tv.append(ecf.Task("b"))
        self.assertEqual(len(tv), 2)

    # ------------------------------------------------------------------
    # __getitem__
    # ------------------------------------------------------------------

    def test_getitem_returns_task_by_index(self):
        """__getitem__ returns the task at the given index."""
        tv = ecf.TaskVec()
        tv.append(ecf.Task("a"))
        tv.append(ecf.Task("b"))
        self.assertEqual(tv[0].name(), "a")
        self.assertEqual(tv[1].name(), "b")

    def test_getitem_out_of_range_raises(self):
        """__getitem__ raises IndexError for an out-of-range index."""
        tv = ecf.TaskVec()
        with self.assertRaises(IndexError):
            _ = tv[0]

    # ------------------------------------------------------------------
    # __iter__
    # ------------------------------------------------------------------

    def test_iter_yields_all_tasks_in_order(self):
        """__iter__ yields tasks in insertion order."""
        tv = ecf.TaskVec()
        tv.append(ecf.Task("x"))
        tv.append(ecf.Task("y"))
        names = [t.name() for t in tv]
        self.assertEqual(names, ["x", "y"])

    def test_iter_on_empty_vec_yields_nothing(self):
        """Iterating an empty TaskVec yields no items."""
        self.assertEqual(list(ecf.TaskVec()), [])


class TestSubmittable(unittest.TestCase):
    """Tests for py::class_<Submittable, py::bases<Node>, boost::noncopyable> exposed as ecf.Submittable in ExportTask.cpp.

    Exposed API
    -----------
    Constructors
        (abstract -- no direct construction; use Task)

    Instance methods
        get_jobs_password()          -> str  -- password generated by server; empty on fresh node
        get_process_or_remote_id()   -> str  -- process/remote id of running job; empty on fresh node
        get_try_no()                 -> str  -- current try number as string; '0' on fresh node
        get_int_try_no()             -> int  -- current try number as integer; 0 on fresh node
        get_aborted_reason()         -> str  -- abort reason if node was aborted; empty on fresh node
    """

    # ------------------------------------------------------------------
    # Constructor: abstract, no direct construction
    # ------------------------------------------------------------------

    def test_direct_construction_raises(self):
        """Submittable cannot be instantiated directly from Python."""
        with self.assertRaises(RuntimeError):
            ecf.Submittable("s")

    # ------------------------------------------------------------------
    # get_jobs_password()
    # ------------------------------------------------------------------

    def test_get_jobs_password_returns_empty_string_on_fresh_task(self):
        """A freshly created Task has an empty jobs password."""
        t = ecf.Task("t1")
        self.assertEqual(t.get_jobs_password(), "")

    def test_get_jobs_password_returns_str(self):
        """get_jobs_password() always returns a str."""
        self.assertIsInstance(ecf.Task("t1").get_jobs_password(), str)

    # ------------------------------------------------------------------
    # get_process_or_remote_id()
    # ------------------------------------------------------------------

    def test_get_process_or_remote_id_returns_empty_string_on_fresh_task(self):
        """A freshly created Task has no process or remote id."""
        self.assertEqual(ecf.Task("t1").get_process_or_remote_id(), "")

    def test_get_process_or_remote_id_returns_str(self):
        """get_process_or_remote_id() always returns a str."""
        self.assertIsInstance(ecf.Task("t1").get_process_or_remote_id(), str)

    # ------------------------------------------------------------------
    # get_try_no()
    # ------------------------------------------------------------------

    def test_get_try_no_returns_zero_string_on_fresh_task(self):
        """A freshly created Task has try number '0' as a string."""
        self.assertEqual(ecf.Task("t1").get_try_no(), "0")

    def test_get_try_no_returns_str(self):
        """get_try_no() returns a str, not an int."""
        self.assertIsInstance(ecf.Task("t1").get_try_no(), str)

    # ------------------------------------------------------------------
    # get_int_try_no()
    # ------------------------------------------------------------------

    def test_get_int_try_no_returns_zero_on_fresh_task(self):
        """A freshly created Task has try number 0 as an integer."""
        self.assertEqual(ecf.Task("t1").get_int_try_no(), 0)

    def test_get_int_try_no_returns_int(self):
        """get_int_try_no() returns an int."""
        self.assertIsInstance(ecf.Task("t1").get_int_try_no(), int)

    def test_get_try_no_and_get_int_try_no_agree(self):
        """get_try_no() and get_int_try_no() return the same numeric value."""
        t = ecf.Task("t1")
        self.assertEqual(int(t.get_try_no()), t.get_int_try_no())

    # ------------------------------------------------------------------
    # get_aborted_reason()
    # ------------------------------------------------------------------

    def test_get_aborted_reason_returns_empty_string_on_fresh_task(self):
        """A freshly created Task has no aborted reason."""
        self.assertEqual(ecf.Task("t1").get_aborted_reason(), "")

    def test_get_aborted_reason_returns_str(self):
        """get_aborted_reason() always returns a str."""
        self.assertIsInstance(ecf.Task("t1").get_aborted_reason(), str)


class TestTask(unittest.TestCase):
    """Tests for py::class_<Task, py::bases<Submittable>, task_ptr> exposed as ecf.Task in ExportTask.cpp.

    Exposed API
    -----------
    Constructors
        Task(str)                    -- create from name string alone
        Task(str, list, **kwargs)    -- create with child attributes and keyword variables

    Instance methods
        (all Submittable and Node methods are inherited)

    Operators
        __eq__       -- value-based equality (compares full subtree)
        __ne__       -- implicit complement of __eq__
        __hash__     -- identity-based (boost.python C-extension type)
        __str__      -- serialised definition string containing the task name
        __copy__     -- copy.copy() returns a value-equal, identity-distinct instance
        __enter__    -- returns self (context manager support)
        __exit__     -- returns False (context manager support)
        __len__      -- number of alias children (0 for a fresh task)
        __iter__     -- iterate over aliases (empty for a fresh task)

    Properties
        aliases      -- iterable of Alias objects (empty for a fresh task)
        nodes        -- same range as aliases
    """

    # ------------------------------------------------------------------
    # Constructor: Task(str)
    # ------------------------------------------------------------------

    def test_ctor_str_sets_name(self):
        """Task(str) stores the name verbatim."""
        self.assertEqual(ecf.Task("my_task").name(), "my_task")

    def test_ctor_str_underscore_accepted(self):
        """Underscores are valid in task names."""
        t = ecf.Task("task_1")
        self.assertEqual(t.name(), "task_1")

    def test_ctor_str_dot_accepted(self):
        """Dots are valid in task names."""
        t = ecf.Task("task.1")
        self.assertEqual(t.name(), "task.1")

    def test_ctor_str_digits_accepted(self):
        """Digit-only names are valid."""
        t = ecf.Task("123")
        self.assertEqual(t.name(), "123")

    def test_ctor_str_empty_name_raises(self):
        """An empty name raises RuntimeError."""
        with self.assertRaises(RuntimeError):
            ecf.Task("")

    def test_ctor_str_name_with_space_raises(self):
        """A name containing a space raises RuntimeError."""
        with self.assertRaises(RuntimeError):
            ecf.Task("bad name")

    def test_ctor_str_name_with_slash_raises(self):
        """A name containing a slash raises RuntimeError."""
        with self.assertRaises(RuntimeError):
            ecf.Task("bad/name")

    # ------------------------------------------------------------------
    # Constructor: Task(str, list, **kwargs)
    # ------------------------------------------------------------------

    def test_ctor_with_variable_list_adds_variable(self):
        """Task(str, [Variable(...)]) adds the variable to the task."""
        t = ecf.Task("t1", [ecf.Variable("X", "1")])
        self.assertEqual(t.find_variable("X").value(), "1")

    def test_ctor_with_kwargs_adds_variables(self):
        """Task(str, **kwargs) adds each kwarg as a variable."""
        t = ecf.Task("t1", A="hello", B="world")
        self.assertEqual(t.find_variable("A").value(), "hello")
        self.assertEqual(t.find_variable("B").value(), "world")

    def test_ctor_with_list_and_kwargs_adds_all(self):
        """Task(str, list, **kwargs) adds list attributes and kwarg variables."""
        t = ecf.Task("t1", [ecf.Variable("X", "1")], Y="2")
        self.assertEqual(t.find_variable("X").value(), "1")
        self.assertEqual(t.find_variable("Y").value(), "2")

    def test_ctor_with_trigger_in_list(self):
        """Task(str, [Trigger(...)]) sets the trigger on the task."""
        t = ecf.Task("t1", [ecf.Trigger("t0 == complete")])
        self.assertIsNotNone(t.get_trigger())
        self.assertIn("t0 == complete", t.get_trigger().get_expression())

    def test_ctor_with_empty_list_creates_bare_task(self):
        """Task(str, []) creates a task with no extra attributes."""
        t = ecf.Task("t1", [])
        self.assertEqual(t.name(), "t1")
        self.assertEqual(list(t.variables), [])

    # ------------------------------------------------------------------
    # __eq__ and __ne__
    # ------------------------------------------------------------------

    def test_eq_two_identical_tasks_are_equal(self):
        """Two Task objects with the same name and no attributes are value-equal."""
        self.assertEqual(ecf.Task("t1"), ecf.Task("t1"))

    def test_eq_tasks_with_different_names_are_not_equal(self):
        """Two Task objects with different names are not equal."""
        self.assertNotEqual(ecf.Task("t1"), ecf.Task("t2"))

    def test_eq_tasks_with_different_variables_are_not_equal(self):
        """Two tasks that differ only in a variable are not equal."""
        t1 = ecf.Task("t1")
        t1.add_variable("X", "1")
        t2 = ecf.Task("t1")
        self.assertNotEqual(t1, t2)

    def test_ne_is_complement_of_eq(self):
        """__ne__ is the logical complement of __eq__."""
        t_a = ecf.Task("t1")
        t_b = ecf.Task("t1")
        t_c = ecf.Task("t2")
        self.assertFalse(t_a != t_b)
        self.assertTrue(t_a != t_c)

    def test_eq_task_with_itself(self):
        """A task is equal to itself."""
        t = ecf.Task("t1")
        self.assertEqual(t, t)

    # ------------------------------------------------------------------
    # __hash__
    # ------------------------------------------------------------------

    def test_hash_is_identity_based_not_value_based(self):
        """Two value-equal tasks kept simultaneously have different hashes."""
        t1 = ecf.Task("t1")
        t2 = ecf.Task("t1")
        self.assertEqual(t1, t2)
        self.assertNotEqual(hash(t1), hash(t2))

    def test_hash_same_object_is_consistent(self):
        """The hash of a single task is stable across calls."""
        t = ecf.Task("t1")
        self.assertEqual(hash(t), hash(t))

    def test_hash_usable_as_dict_key(self):
        """A task can be used as a dictionary key."""
        t = ecf.Task("t1")
        d = {t: "value"}
        self.assertEqual(d[t], "value")

    def test_hash_usable_in_set(self):
        """Two value-equal tasks kept simultaneously are distinct set members."""
        t1 = ecf.Task("t1")
        t2 = ecf.Task("t1")
        s = {t1, t2}
        self.assertEqual(len(s), 2)

    # ------------------------------------------------------------------
    # __str__
    # ------------------------------------------------------------------

    def test_str_is_a_string(self):
        """str(Task) returns a Python str."""
        self.assertIsInstance(str(ecf.Task("t1")), str)

    def test_str_is_non_empty(self):
        """str(Task) is non-empty."""
        self.assertTrue(len(str(ecf.Task("t1"))) > 0)

    def test_str_contains_task_name(self):
        """str(Task) contains the task name."""
        self.assertIn("t1", str(ecf.Task("t1")))

    def test_str_contains_task_keyword(self):
        """str(Task) contains the 'task' keyword."""
        self.assertIn("task", str(ecf.Task("t1")))

    # ------------------------------------------------------------------
    # __copy__
    # ------------------------------------------------------------------

    def test_copy_is_value_equal(self):
        """copy.copy(Task) returns a value-equal instance."""
        t = ecf.Task("t1")
        self.assertEqual(copy.copy(t), t)

    def test_copy_is_identity_distinct(self):
        """copy.copy(Task) returns a different object."""
        t = ecf.Task("t1")
        self.assertIsNot(copy.copy(t), t)

    def test_copy_is_independent(self):
        """Modifying the copy does not affect the original."""
        t = ecf.Task("t1")
        t_copy = copy.copy(t)
        t_copy.add_variable("NEW", "val")
        self.assertTrue(t.find_variable("NEW").empty())

    def test_copy_preserves_attributes(self):
        """copy.copy carries all attributes into the new instance."""
        t = ecf.Task("t1")
        t.add_variable("X", "42")
        t_copy = copy.copy(t)
        self.assertEqual(t_copy.find_variable("X").value(), "42")

    # ------------------------------------------------------------------
    # __enter__ and __exit__  (context manager)
    # ------------------------------------------------------------------

    def test_enter_returns_task_itself(self):
        """__enter__ returns the task, enabling 'with Task(...) as t:' syntax."""
        t = ecf.Task("t1")
        self.assertIs(t.__enter__(), t)

    def test_exit_returns_false(self):
        """__exit__ returns False (does not suppress exceptions)."""
        t = ecf.Task("t1")
        self.assertFalse(t.__exit__(None, None, None))

    def test_with_statement_binds_task(self):
        """The with-statement binds the task to the 'as' target."""
        with ecf.Task("t1") as t:
            self.assertEqual(t.name(), "t1")

    def test_with_statement_allows_attribute_addition(self):
        """Attributes added inside a with-block are present after the block."""
        with ecf.Task("t1") as t:
            t.add_variable("X", "1")
        self.assertEqual(t.find_variable("X").value(), "1")

    # ------------------------------------------------------------------
    # __len__  (alias count)
    # ------------------------------------------------------------------

    def test_len_is_zero_for_fresh_task(self):
        """A freshly created Task has zero aliases."""
        self.assertEqual(len(ecf.Task("t1")), 0)

    def test_len_returns_int(self):
        """__len__ returns an int."""
        self.assertIsInstance(len(ecf.Task("t1")), int)

    # ------------------------------------------------------------------
    # __iter__  (over aliases)
    # ------------------------------------------------------------------

    def test_iter_is_empty_for_fresh_task(self):
        """Iterating a fresh Task yields no aliases."""
        self.assertEqual(list(ecf.Task("t1")), [])

    # ------------------------------------------------------------------
    # Property: aliases
    # ------------------------------------------------------------------

    def test_aliases_property_is_empty_for_fresh_task(self):
        """The aliases property of a fresh Task is an empty iterable."""
        self.assertEqual(list(ecf.Task("t1").aliases), [])

    # ------------------------------------------------------------------
    # Property: nodes
    # ------------------------------------------------------------------

    def test_nodes_property_is_empty_for_fresh_task(self):
        """The nodes property of a fresh Task is an empty iterable."""
        self.assertEqual(list(ecf.Task("t1").nodes), [])

    def test_nodes_and_aliases_return_same_content(self):
        """nodes and aliases properties expose the same alias range."""
        t = ecf.Task("t1")
        self.assertEqual(list(t.nodes), list(t.aliases))


class TestAlias(unittest.TestCase):
    """Tests for py::class_<Alias, py::bases<Submittable>, alias_ptr> exposed as ecf.Alias in ExportTask.cpp.

    Exposed API
    -----------
    Constructors
        (abstract -- no direct construction; created server-side only)

    Operators
        __eq__    -- value-based equality
        __ne__    -- implicit complement of __eq__
        __hash__  -- identity-based (boost.python C-extension type)
        __str__   -- serialised definition string
        __copy__  -- copy.copy() returns a value-equal, identity-distinct instance

    Note
    ----
    Alias objects can only be obtained from a live ecflow server (they are created when
    a task calls the 'alias' child command).  Unit tests are therefore limited to
    verifying construction constraints and class accessibility.
    """

    # ------------------------------------------------------------------
    # Constructor: abstract, no direct construction
    # ------------------------------------------------------------------

    def test_direct_construction_raises(self):
        """Alias cannot be instantiated directly from Python."""
        with self.assertRaises(RuntimeError):
            ecf.Alias("a1")

    def test_class_is_accessible(self):
        """ecf.Alias is accessible as a type."""
        self.assertTrue(hasattr(ecf, "Alias"))
        self.assertIsInstance(ecf.Alias, type)

    def test_alias_is_subtype_of_submittable(self):
        """Alias is registered as a subclass of Submittable in the class hierarchy."""
        self.assertTrue(issubclass(ecf.Alias, ecf.Submittable))

    def test_task_aliases_property_is_empty_without_server(self):
        """Without a server, a task's aliases list is always empty."""
        t = ecf.Task("t1")
        self.assertEqual(list(t.aliases), [])


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