# -*- coding: utf-8 -*-
#
# Picard, the next-generation MusicBrainz tagger
#
# Copyright (C) 2007 Lukáš Lalinský
# Copyright (C) 2010, 2014, 2018-2020 Philipp Wolfer
# Copyright (C) 2012 Chad Wilson
# Copyright (C) 2013 Michael Wiencek
# Copyright (C) 2013, 2017-2020 Laurent Monin
# Copyright (C) 2014, 2017 Sophist-UK
# Copyright (C) 2016-2017 Sambhav Kothari
# Copyright (C) 2017 Antonio Larrosa
# Copyright (C) 2017-2018 Wieland Hoffmann
# Copyright (C) 2018 virusMac
# Copyright (C) 2020 Bob Swift
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.


import copy
import datetime
import re
import unittest
from unittest.mock import MagicMock

from test.picardtestcase import PicardTestCase

from picard.cluster import Cluster
from picard.const import DEFAULT_FILE_NAMING_FORMAT
from picard.metadata import (
    MULTI_VALUED_JOINER,
    Metadata,
)
from picard.plugin import ExtensionPoint
from picard.script import (
    MultiValue,
    ScriptEndOfFile,
    ScriptError,
    ScriptExpression,
    ScriptFunction,
    ScriptFunctionDocError,
    ScriptParser,
    ScriptRuntimeError,
    ScriptSyntaxError,
    ScriptUnknownFunction,
    register_script_function,
    script_function,
    script_function_documentation,
    script_function_documentation_all,
)
from picard.script.functions import FunctionRegistryItem


try:
    from markdown import markdown
except ImportError:
    markdown = None


class _TestTimezone(datetime.tzinfo):
    def utcoffset(self, dt):
        # Set to GMT+2
        return datetime.timedelta(hours=2) + self.dst(dt)

    def dst(self, dt):
        d = datetime.datetime(dt.year, 4, 1)
        self.dston = d - datetime.timedelta(days=d.weekday() + 1)
        d = datetime.datetime(dt.year, 11, 1)
        self.dstoff = d - datetime.timedelta(days=d.weekday() + 1)
        if self.dston <= dt.replace(tzinfo=None) < self.dstoff:
            return datetime.timedelta(hours=1)
        else:
            return datetime.timedelta(0)

    def tzname(self, dt):
        return "TZ Test"


class _DateTime(datetime.datetime):
    @classmethod
    def now(cls, tz=None):
        return datetime.datetime(2020, 1, 2, hour=12, minute=34, second=56, microsecond=789, tzinfo=tz)

    def astimezone(self, tz=None):
        # Ignore tz passed to the method and force use of test timezone.
        tz = _TestTimezone()
        utc = (self - self.utcoffset()).replace(tzinfo=tz)
        # Convert from UTC to tz's local time.
        return tz.fromutc(utc)


class ScriptParserTest(PicardTestCase):

    def setUp(self):
        super().setUp()
        self.set_config_values({
            'enabled_plugins': '',
        })

        self.parser = ScriptParser()

        # ensure we start on clean registry
        ScriptParser._cache = {}
        self._backup_registry = copy.deepcopy(ScriptParser._function_registry)

    def tearDown(self):
        ScriptParser._function_registry = copy.deepcopy(self._backup_registry)

    def assertScriptResultEquals(self, script, expected, context=None, file=None):
        """Asserts that evaluating `script` returns `expected`.


        Args:
            script: The tagger script
            expected: The expected result
            context: A Metadata object with pre-set tags or None
        """
        actual = self.parser.eval(script, context=context, file=file)
        self.assertEqual(actual,
                         expected,
                         "'%s' evaluated to '%s', expected '%s'"
                         % (script, actual, expected))

    def test_function_registry_item(self):
        def somefunc():
            return 'x'
        item = FunctionRegistryItem(somefunc, 'x', 'y', 'doc')
        self.assertEqual(item.function, somefunc)
        self.assertEqual(item.eval_args, 'x')
        self.assertEqual(item.argcount, 'y')
        self.assertEqual(item.documentation, 'doc')

        regex = r'^'\
                + re.escape(r'FunctionRegistryItem(<function ')\
                + r'[^ ]+'\
                + re.escape(r'.somefunc at ')\
                + r'[^>]+'\
                + re.escape(r'>, x, y, """doc""")')\
                + r'$'

        self.assertRegex(repr(item), regex)

    def test_script_function_decorator_default(self):
        # test default decorator and default prefix
        @script_function()
        def func_somefunc(parser):
            return "x"
        self.assertScriptResultEquals("$somefunc()", "x")

    def test_script_function_decorator_no_prefix(self):
        # function without prefix
        @script_function()
        def somefunc(parser):
            return "x"
        self.assertScriptResultEquals("$somefunc()", "x")

    def test_script_function_decorator_arg(self):
        # function with argument
        @script_function()
        def somefunc(parser, arg):
            return arg
        self.assertScriptResultEquals("$somefunc($title(x))", "X")
        areg = r"^\d+:\d+:\$somefunc: Wrong number of arguments for \$somefunc: Expected exactly 1"
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$somefunc()")

    def test_script_function_decorator_argcount(self):
        # ignore argument count
        @script_function(check_argcount=False)
        def somefunc(parser, *arg):
            return str(len(arg))
        self.assertScriptResultEquals("$somefunc(a,b,c)", "3")

    def test_script_function_decorator_altname(self):
        # alternative name
        @script_function(name="otherfunc")
        def somefunc4(parser):
            return "x"
        self.assertScriptResultEquals("$otherfunc()", "x")
        areg = r"^\d+:\d+:\$somefunc: Unknown function '\$somefunc'"
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$somefunc()")

    def test_script_function_decorator_altprefix(self):
        # alternative prefix
        @script_function(prefix='theprefix_')
        def theprefix_somefunc(parser):
            return "x"
        self.assertScriptResultEquals("$somefunc()", "x")

    def test_script_function_decorator_eval_args(self):
        # disable argument evaluation
        @script_function(eval_args=False)
        def somefunc(parser, arg):
            return arg.eval(parser)
        self.assertScriptResultEquals("$somefunc($title(x))", "X")

    @staticmethod
    def assertStartswith(text, expect):
        if not text.startswith(expect):
            raise AssertionError("do not start with %r but with %r" % (expect, text[:len(expect)]))

    @staticmethod
    def assertEndswith(text, expect):
        if not text.endswith(expect):
            raise AssertionError("do not end with %r but with %r" % (expect, text[-len(expect):]))

    @staticmethod
    def reset_registry():
        # use a clean registry, to ensure we have only one registered function
        ScriptParser._function_registry = ExtensionPoint()

    def test_script_function_documentation_nodoc(self):
        """test script_function_documentation() with a function without documentation"""
        self.reset_registry()

        @script_function()
        def func_nodocfunc(parser):
            return ""

        doc = script_function_documentation('nodocfunc', 'markdown')
        self.assertEqual(doc, '')
        doc = script_function_documentation('nodocfunc', 'html')
        self.assertEqual(doc, '')

    def test_script_function_documentation(self):
        """test script_function_documentation() with a function with documentation"""
        self.reset_registry()

        # the documentation used to test includes backquotes
        testdoc = '`$somefunc()`'

        @script_function(documentation=testdoc)
        def func_somefunc(parser):
            return "x"

        doc = script_function_documentation('somefunc', 'markdown')
        self.assertEqual(doc, testdoc)
        areg = r"^no such documentation format: unknownformat"
        with self.assertRaisesRegex(ScriptFunctionDocError, areg):
            script_function_documentation('somefunc', 'unknownformat')

    @unittest.skipUnless(markdown, "markdown module missing")
    def test_script_function_documentation_html(self):
        """test script_function_documentation() with a function with documentation"""
        self.reset_registry()

        # get html code as generated by markdown
        pre, post = markdown('`XXX`').split('XXX')

        # the documentation used to test includes backquotes
        testdoc = '`$somefunc()`'

        @script_function(documentation=testdoc)
        def func_somefunc(parser):
            return "x"

        doc = script_function_documentation('somefunc', 'html')
        self.assertEqual(doc, pre + '$somefunc()' + post)

    def test_script_function_documentation_unknown_function(self):
        """test script_function_documentation() with an unknown function"""
        self.reset_registry()

        areg = r"^no such function: unknownfunc"
        with self.assertRaisesRegex(ScriptFunctionDocError, areg):
            script_function_documentation('unknownfunc', 'html')

    def test_script_function_documentation_all(self):
        """test script_function_documentation_all() with markdown format"""
        self.reset_registry()

        @script_function(documentation='somedoc2')
        def func_somefunc2(parser):
            return "x"

        @script_function(documentation='somedoc1')
        def func_somefunc1(parser):
            return "x"

        docall = script_function_documentation_all()
        self.assertEqual(docall, 'somedoc1\nsomedoc2')

    @unittest.skipUnless(markdown, "markdown module missing")
    def test_script_function_documentation_all_html(self):
        """test script_function_documentation_all() with html format"""
        self.reset_registry()

        # get html code as generated by markdown
        pre, post = markdown('XXX').split('XXX')

        @script_function(documentation='somedoc')
        def func_somefunc(parser):
            return "x"

        def postprocessor(data, function):
            return 'w' + data + function.name + 'y'

        docall = script_function_documentation_all(
            fmt='html',
            pre='<div id="test">',
            post="</div>\n",
            postprocessor=postprocessor,
        )

        self.assertStartswith(docall, '<div id="test">w' + pre)
        self.assertEndswith(docall, post + 'somefuncy</div>\n')

    def test_unknown_function(self):
        areg = r"^\d+:\d+:\$unknownfunction: Unknown function '\$unknownfunction'"
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$unknownfunction()")

    def test_noname_function(self):
        areg = r"^\d+:\d+:\$: Unknown function '\$'"
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$()")

    def test_unexpected_end_of_script(self):
        areg = r"^\d+:\d+: Unexpected end of script"
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$noop(")
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$")

    def test_unexpected_character(self):
        areg = r"^\d+:\d+: Unexpected character '\^'"
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$^noop()")

    def test_scriptfunction_unknown(self):
        parser = ScriptParser()
        parser.parse('')
        areg = r"^\d+:\d+:\$x: Unknown function '\$x'"
        with self.assertRaisesRegex(ScriptError, areg):
            ScriptFunction('x', '', parser)
        areg = r"^\d+:\d+:\$noop: Unknown function '\$noop'"
        with self.assertRaisesRegex(ScriptError, areg):
            f = ScriptFunction('noop', '', parser)
            del parser.functions['noop']
            f.eval(parser)

    def test_cmd_noop(self):
        self.assertScriptResultEquals("$noop()", "")
        self.assertScriptResultEquals("$noop(abcdefg)", "")

    def test_cmd_if(self):
        self.assertScriptResultEquals("$if(1,a,b)", "a")
        self.assertScriptResultEquals("$if(,a,b)", "b")

    def test_cmd_if2(self):
        self.assertScriptResultEquals("$if2(,a,b)", "a")
        self.assertScriptResultEquals("$if2($noop(),b)", "b")
        self.assertScriptResultEquals("$if2()", "")
        self.assertScriptResultEquals("$if2(,)", "")

    def test_cmd_left(self):
        self.assertScriptResultEquals("$left(abcd,2)", "ab")
        self.assertScriptResultEquals("$left(abcd,x)", "")

    def test_cmd_right(self):
        self.assertScriptResultEquals("$right(abcd,2)", "cd")
        self.assertScriptResultEquals("$right(abcd,x)", "")

    def test_cmd_set(self):
        context = Metadata()
        self.assertScriptResultEquals("$set(test,aaa)%test%", "aaa", context)
        self.assertEqual(context['test'], 'aaa')

    def test_cmd_set_empty(self):
        self.assertScriptResultEquals("$set(test,)%test%", "")

    def test_cmd_set_multi_valued(self):
        context = Metadata()
        context["source"] = ["multi", "valued"]
        self.parser.eval("$set(test,%source%)", context)
        self.assertEqual(context.getall("test"), ["multi; valued"])  # list has only a single value

    def test_cmd_setmulti_multi_valued(self):
        context = Metadata()
        context["source"] = ["multi", "valued"]
        self.assertEqual("", self.parser.eval("$setmulti(test,%source%)", context))  # no return value
        self.assertEqual(context.getall("source"), context.getall("test"))

    def test_cmd_setmulti_multi_valued_wth_spaces(self):
        context = Metadata()
        context["source"] = ["multi, multi", "valued, multi"]
        self.assertEqual("", self.parser.eval("$setmulti(test,%source%)", context))  # no return value
        self.assertEqual(context.getall("source"), context.getall("test"))

    def test_cmd_setmulti_not_multi_valued(self):
        context = Metadata()
        context["source"] = "multi, multi"
        self.assertEqual("", self.parser.eval("$setmulti(test,%source%)", context))  # no return value
        self.assertEqual(context.getall("source"), context.getall("test"))

    def test_cmd_setmulti_will_remove_empty_items(self):
        context = Metadata()
        context["source"] = ["", "multi", ""]
        self.assertEqual("", self.parser.eval("$setmulti(test,%source%)", context))  # no return value
        self.assertEqual(["multi"], context.getall("test"))

    def test_cmd_setmulti_custom_splitter_string(self):
        context = Metadata()
        self.assertEqual("", self.parser.eval("$setmulti(test,multi##valued##test##,##)", context))  # no return value
        self.assertEqual(["multi", "valued", "test"], context.getall("test"))

    def test_cmd_setmulti_empty_splitter_does_nothing(self):
        context = Metadata()
        self.assertEqual("", self.parser.eval("$setmulti(test,multi; valued,)", context))  # no return value
        self.assertEqual(["multi; valued"], context.getall("test"))

    def test_cmd_get(self):
        context = Metadata()
        context["test"] = "aaa"
        self.assertScriptResultEquals("$get(test)", "aaa", context)
        context["test2"] = ["multi", "valued"]
        self.assertScriptResultEquals("$get(test2)", "multi; valued", context)

    def test_cmd_num(self):
        self.assertScriptResultEquals("$num(3,3)", "003")
        self.assertScriptResultEquals("$num(03,3)", "003")
        self.assertScriptResultEquals("$num(123,2)", "123")
        self.assertScriptResultEquals("$num(123,a)", "")
        self.assertScriptResultEquals("$num(a,2)", "00")
        self.assertScriptResultEquals("$num(123,-1)", "123")
        self.assertScriptResultEquals("$num(123,35)", "00000000000000000123")

    def test_cmd_or(self):
        self.assertScriptResultEquals("$or(,)", "")
        self.assertScriptResultEquals("$or(,,)", "")
        self.assertScriptResultEquals("$or(,q)", "1")
        self.assertScriptResultEquals("$or(q,)", "1")
        self.assertScriptResultEquals("$or(q,q)", "1")
        self.assertScriptResultEquals("$or(q,,)", "1")

    def test_cmd_and(self):
        self.assertScriptResultEquals("$and(,)", "")
        self.assertScriptResultEquals("$and(,q)", "")
        self.assertScriptResultEquals("$and(q,)", "")
        self.assertScriptResultEquals("$and(q,q,)", "")
        self.assertScriptResultEquals("$and(q,q)", "1")
        self.assertScriptResultEquals("$and(q,q,q)", "1")

    def test_cmd_not(self):
        self.assertScriptResultEquals("$not($noop())", "1")
        self.assertScriptResultEquals("$not(q)", "")

    def test_cmd_trim(self):
        self.assertScriptResultEquals("$trim( \\t test \\n )", "test")
        self.assertScriptResultEquals("$trim(test,t)", "es")
        self.assertScriptResultEquals("$trim(test,ts)", "e")

    def test_cmd_add(self):
        self.assertScriptResultEquals("$add(1,2)", "3")
        self.assertScriptResultEquals("$add(1,2,3)", "6")
        self.assertScriptResultEquals("$add(a,2)", "")
        self.assertScriptResultEquals("$add(2,a)", "")

    def test_cmd_sub(self):
        self.assertScriptResultEquals("$sub(1,2)", "-1")
        self.assertScriptResultEquals("$sub(2,1)", "1")
        self.assertScriptResultEquals("$sub(4,2,1)", "1")
        self.assertScriptResultEquals("$sub(a,2)", "")
        self.assertScriptResultEquals("$sub(2,a)", "")

    def test_cmd_div(self):
        self.assertScriptResultEquals("$div(9,3)", "3")
        self.assertScriptResultEquals("$div(10,3)", "3")
        self.assertScriptResultEquals("$div(30,3,3)", "3")
        self.assertScriptResultEquals("$div(30,0)", "")
        self.assertScriptResultEquals("$div(30,a)", "")

    def test_cmd_mod(self):
        self.assertScriptResultEquals("$mod(9,3)", "0")
        self.assertScriptResultEquals("$mod(10,3)", "1")
        self.assertScriptResultEquals("$mod(10,6,3)", "1")
        self.assertScriptResultEquals("$mod(a,3)", "")
        self.assertScriptResultEquals("$mod(10,a)", "")
        self.assertScriptResultEquals("$mod(10,0)", "")

    def test_cmd_mul(self):
        self.assertScriptResultEquals("$mul(9,3)", "27")
        self.assertScriptResultEquals("$mul(10,3)", "30")
        self.assertScriptResultEquals("$mul(2,5,3)", "30")
        self.assertScriptResultEquals("$mul(2,a)", "")
        self.assertScriptResultEquals("$mul(2,5,a)", "")

    def test_cmd_eq(self):
        self.assertScriptResultEquals("$eq(,)", "1")
        self.assertScriptResultEquals("$eq(,$noop())", "1")
        self.assertScriptResultEquals("$eq(,q)", "")
        self.assertScriptResultEquals("$eq(q,q)", "1")
        self.assertScriptResultEquals("$eq(q,)", "")

    def test_cmd_ne(self):
        self.assertScriptResultEquals("$ne(,)", "")
        self.assertScriptResultEquals("$ne(,$noop())", "")
        self.assertScriptResultEquals("$ne(,q)", "1")
        self.assertScriptResultEquals("$ne(q,q)", "")
        self.assertScriptResultEquals("$ne(q,)", "1")

    def test_cmd_lower(self):
        self.assertScriptResultEquals("$lower(AbeCeDA)", "abeceda")

    def test_cmd_upper(self):
        self.assertScriptResultEquals("$upper(AbeCeDA)", "ABECEDA")

    def test_cmd_pad(self):
        self.assertScriptResultEquals("$pad(abc de,10,-)", "----abc de")
        self.assertScriptResultEquals("$pad(abc de,e,-)", "")
        self.assertScriptResultEquals("$pad(abc de,6,-)", "abc de")
        self.assertScriptResultEquals("$pad(abc de,3,-)", "abc de")
        self.assertScriptResultEquals("$pad(abc de,0,-)", "abc de")
        self.assertScriptResultEquals("$pad(abc de,-3,-)", "abc de")

    def test_cmd_replace(self):
        self.assertScriptResultEquals("$replace(abc ab abd a,ab,test)", "testc test testd a")

    def test_cmd_strip(self):
        self.assertScriptResultEquals("$strip(  \t abc  de \n f  )", "abc de f")

    def test_cmd_rreplace(self):
        self.assertScriptResultEquals(r'''$rreplace(test \(disc 1\),\\s\\\(disc \\d+\\\),)''', "test")
        self.assertScriptResultEquals(r'''$rreplace(test,[t,)''', "test")

    def test_cmd_rsearch(self):
        self.assertScriptResultEquals(r"$rsearch(test \(disc 1\),\\\(disc \(\\d+\)\\\))", "1")
        self.assertScriptResultEquals(r"$rsearch(test \(disc 1\),\\\(disc \\d+\\\))", "(disc 1)")
        self.assertScriptResultEquals(r"$rsearch(test,x)", "")
        self.assertScriptResultEquals(r'''$rsearch(test,[t)''', "")

    def test_arguments(self):
        self.assertTrue(
            self.parser.eval(
                r"$set(bleh,$rsearch(test \(disc 1\),\\\(disc \(\\d+\)\\\)))) $set(wer,1)"))

    def test_cmd_gt(self):
        self.assertScriptResultEquals("$gt(10,4)", "1")
        self.assertScriptResultEquals("$gt(6,4)", "1")
        self.assertScriptResultEquals("$gt(a,b)", "")

    def test_cmd_gte(self):
        self.assertScriptResultEquals("$gte(10,10)", "1")
        self.assertScriptResultEquals("$gte(10,4)", "1")
        self.assertScriptResultEquals("$gte(6,4)", "1")
        self.assertScriptResultEquals("$gte(a,b)", "")

    def test_cmd_lt(self):
        self.assertScriptResultEquals("$lt(4,10)", "1")
        self.assertScriptResultEquals("$lt(4,6)", "1")
        self.assertScriptResultEquals("$lt(a,b)", "")

    def test_cmd_lte(self):
        self.assertScriptResultEquals("$lte(10,10)", "1")
        self.assertScriptResultEquals("$lte(4,10)", "1")
        self.assertScriptResultEquals("$lte(4,6)", "1")
        self.assertScriptResultEquals("$lte(a,b)", "")

    def test_cmd_len(self):
        self.assertScriptResultEquals("$len(abcdefg)", "7")
        self.assertScriptResultEquals("$len(0)", "1")
        self.assertScriptResultEquals("$len()", "0")

    def test_cmd_firstalphachar(self):
        self.assertScriptResultEquals("$firstalphachar(abc)", "A")
        self.assertScriptResultEquals("$firstalphachar(Abc)", "A")
        self.assertScriptResultEquals("$firstalphachar(1abc)", "#")
        self.assertScriptResultEquals("$firstalphachar(...abc)", "#")
        self.assertScriptResultEquals("$firstalphachar(1abc,_)", "_")
        self.assertScriptResultEquals("$firstalphachar(...abc,_)", "_")
        self.assertScriptResultEquals("$firstalphachar()", "#")
        self.assertScriptResultEquals("$firstalphachar(,_)", "_")
        self.assertScriptResultEquals("$firstalphachar( abc)", "#")

    def test_cmd_initials(self):
        self.assertScriptResultEquals("$initials(Abc def Ghi)", "AdG")
        self.assertScriptResultEquals("$initials(Abc #def Ghi)", "AG")
        self.assertScriptResultEquals("$initials(Abc 1def Ghi)", "AG")
        self.assertScriptResultEquals("$initials(Abc)", "A")
        self.assertScriptResultEquals("$initials()", "")

    def test_cmd_firstwords(self):
        self.assertScriptResultEquals("$firstwords(Abc Def Ghi,11)", "Abc Def Ghi")
        self.assertScriptResultEquals("$firstwords(Abc Def Ghi,12)", "Abc Def Ghi")
        self.assertScriptResultEquals("$firstwords(Abc Def Ghi,7)", "Abc Def")
        self.assertScriptResultEquals("$firstwords(Abc Def Ghi,8)", "Abc Def")
        self.assertScriptResultEquals("$firstwords(Abc Def Ghi,6)", "Abc")
        self.assertScriptResultEquals("$firstwords(Abc Def Ghi,0)", "")
        self.assertScriptResultEquals("$firstwords(Abc Def Ghi,NaN)", "")
        self.assertScriptResultEquals("$firstwords(Abc Def Ghi,)", "")
        self.assertScriptResultEquals("$firstwords(Abc Def Ghi,-2)", "Abc Def")
        self.assertScriptResultEquals("$firstwords(Abc Def Ghi,-50)", "")

    def test_cmd_startswith(self):
        self.assertScriptResultEquals("$startswith(abc,a)", "1")
        self.assertScriptResultEquals("$startswith(abc,abc)", "1")
        self.assertScriptResultEquals("$startswith(abc,)", "1")
        self.assertScriptResultEquals("$startswith(abc,b)", "")
        self.assertScriptResultEquals("$startswith(abc,Ab)", "")

    def test_cmd_endswith(self):
        self.assertScriptResultEquals("$endswith(abc,c)", "1")
        self.assertScriptResultEquals("$endswith(abc,abc)", "1")
        self.assertScriptResultEquals("$endswith(abc,)", "1")
        self.assertScriptResultEquals("$endswith(abc,b)", "")
        self.assertScriptResultEquals("$endswith(abc,bC)", "")

    def test_cmd_truncate(self):
        self.assertScriptResultEquals("$truncate(abcdefg,0)", "")
        self.assertScriptResultEquals("$truncate(abcdefg,7)", "abcdefg")
        self.assertScriptResultEquals("$truncate(abcdefg,3)", "abc")
        self.assertScriptResultEquals("$truncate(abcdefg,10)", "abcdefg")
        self.assertScriptResultEquals("$truncate(abcdefg,)", "abcdefg")
        self.assertScriptResultEquals("$truncate(abcdefg,NaN)", "abcdefg")

    def test_cmd_copy(self):
        context = Metadata()
        tagsToCopy = ["tag1", "tag2"]
        context["source"] = tagsToCopy
        context["target"] = ["will", "be", "overwritten"]
        self.parser.eval("$copy(target,source)", context)
        self.assertEqual(self.parser.context.getall("target"), tagsToCopy)

    def _eval_and_check_copymerge(self, context, expected):
        self.parser.eval("$copymerge(target,source)", context)
        self.assertEqual(self.parser.context.getall("target"), expected)

    def test_cmd_copymerge_notarget(self):
        context = Metadata()
        tagsToCopy = ["tag1", "tag2"]
        context["source"] = tagsToCopy
        self._eval_and_check_copymerge(context, tagsToCopy)

    def test_cmd_copymerge_nosource(self):
        context = Metadata()
        target = ["tag1", "tag2"]
        context["target"] = target
        self._eval_and_check_copymerge(context, target)

    def test_cmd_copymerge_removedupes(self):
        context = Metadata()
        context["target"] = ["tag1", "tag2", "tag1"]
        context["source"] = ["tag2", "tag3", "tag2"]
        self._eval_and_check_copymerge(context, ["tag1", "tag2", "tag3"])

    def test_cmd_copymerge_nonlist(self):
        context = Metadata()
        context["target"] = "targetval"
        context["source"] = "sourceval"
        self._eval_and_check_copymerge(context, ["targetval", "sourceval"])

    def test_cmd_eq_any(self):
        self.assertScriptResultEquals("$eq_any(abc,def,ghi,jkl)", "")
        self.assertScriptResultEquals("$eq_any(abc,def,ghi,jkl,abc)", "1")

    def test_cmd_ne_all(self):
        self.assertScriptResultEquals("$ne_all(abc,def,ghi,jkl)", "1")
        self.assertScriptResultEquals("$ne_all(abc,def,ghi,jkl,abc)", "")

    def test_cmd_eq_all(self):
        self.assertScriptResultEquals("$eq_all(abc,abc,abc,abc)", "1")
        self.assertScriptResultEquals("$eq_all(abc,abc,def,ghi)", "")

    def test_cmd_ne_any(self):
        self.assertScriptResultEquals("$ne_any(abc,abc,abc,abc)", "")
        self.assertScriptResultEquals("$ne_any(abc,abc,def,ghi)", "1")

    def test_cmd_title(self):
        self.assertScriptResultEquals("$title(abc Def g)", "Abc Def G")
        self.assertScriptResultEquals("$title(Abc Def G)", "Abc Def G")
        self.assertScriptResultEquals("$title(abc def g)", "Abc Def G")
        self.assertScriptResultEquals("$title(#1abc 4def - g)", "#1abc 4def - G")
        self.assertScriptResultEquals(r"$title(abcd \(efg hi jkl mno\))", "Abcd (Efg Hi Jkl Mno)")
        self.assertScriptResultEquals("$title(...abcd)", "...Abcd")
        self.assertScriptResultEquals("$title(a)", "A")
        self.assertScriptResultEquals("$title(’a)", "’a")
        self.assertScriptResultEquals("$title('a)", "'a")
        self.assertScriptResultEquals("$title(l'a)", "L'a")
        self.assertScriptResultEquals("$title(2'a)", "2'A")
        self.assertScriptResultEquals(r"$title(%empty%)", "")
        # Tests wrong number of arguments
        areg = r"^\d+:\d+:\$title: Wrong number of arguments for \$title: Expected exactly 1, "
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$title()")

    def test_cmd_swapprefix(self):
        self.assertScriptResultEquals("$swapprefix(A stitch in time)", "stitch in time, A")
        self.assertScriptResultEquals("$swapprefix(The quick brown fox)", "quick brown fox, The")
        self.assertScriptResultEquals("$swapprefix(How now brown cow)", "How now brown cow")
        self.assertScriptResultEquals("$swapprefix(When the red red robin)", "When the red red robin")
        self.assertScriptResultEquals("$swapprefix(A stitch in time,How,When,Who)", "A stitch in time")
        self.assertScriptResultEquals("$swapprefix(The quick brown fox,How,When,Who)", "The quick brown fox")
        self.assertScriptResultEquals("$swapprefix(How now brown cow,How,When,Who)", "now brown cow, How")
        self.assertScriptResultEquals("$swapprefix(When the red red robin,How,When,Who)", "the red red robin, When")

    def test_cmd_delprefix(self):
        self.assertScriptResultEquals("$delprefix(A stitch in time)", "stitch in time")
        self.assertScriptResultEquals("$delprefix(The quick brown fox)", "quick brown fox")
        self.assertScriptResultEquals("$delprefix(How now brown cow)", "How now brown cow")
        self.assertScriptResultEquals("$delprefix(When the red red robin)", "When the red red robin")
        self.assertScriptResultEquals("$delprefix(A stitch in time,How,When,Who)", "A stitch in time")
        self.assertScriptResultEquals("$delprefix(The quick brown fox,How,When,Who)", "The quick brown fox")
        self.assertScriptResultEquals("$delprefix(How now brown cow,How,When,Who)", "now brown cow")
        self.assertScriptResultEquals("$delprefix(When the red red robin,How,When,Who)", "the red red robin")

    def test_default_filenaming(self):
        context = Metadata()
        context['albumartist'] = 'albumartist'
        context['artist'] = 'artist'
        context['album'] = 'album'
        context['totaldiscs'] = 2
        context['discnumber'] = 1
        context['tracknumber'] = 8
        context['title'] = 'title'
        result = self.parser.eval(DEFAULT_FILE_NAMING_FORMAT, context)
        self.assertEqual(result, 'albumartist/\nalbum/\n1-08 title')
        context['~multiartist'] = '1'
        result = self.parser.eval(DEFAULT_FILE_NAMING_FORMAT, context)
        self.assertEqual(result, 'albumartist/\nalbum/\n1-08 artist - title')

    def test_default_NAT_filenaming(self):
        context = Metadata()
        context['artist'] = 'artist'
        context['album'] = '[non-album tracks]'
        context['title'] = 'title'
        result = self.parser.eval(DEFAULT_FILE_NAMING_FORMAT, context)
        self.assertEqual(result, 'artist/\n\ntitle')

    def test_cmd_with_not_arguments(self):
        def func_noargstest(parser):
            return ""

        register_script_function(func_noargstest, "noargstest")
        self.parser.eval("$noargstest()")

    def test_cmd_with_wrong_argcount_or(self):
        # $or() requires at least 2 arguments
        areg = r"^\d+:\d+:\$or: Wrong number of arguments for \$or: Expected at least 2, "
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval('$or(0)')

    def test_cmd_with_wrong_argcount_eq(self):
        # $eq() requires exactly 2 arguments
        areg = r"^\d+:\d+:\$eq: Wrong number of arguments for \$eq: Expected exactly 2, "
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval('$eq(0)')
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval('$eq(0,0,0)')

    def test_cmd_with_wrong_argcount_if(self):
        areg = r"^\d+:\d+:\$if: Wrong number of arguments for \$if: Expected between 2 and 3, "
        # $if() requires 2 or 3 arguments
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval('$if(1)')
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval('$if(1,a,b,c)')

    def test_cmd_unset_simple(self):
        context = Metadata()
        context['title'] = 'Foo'
        context['album'] = 'Foo'
        context['artist'] = 'Foo'
        self.parser.eval("$unset(album)", context)
        self.assertNotIn('album', context)

    def test_cmd_unset_prefix(self):
        context = Metadata()
        context['title'] = 'Foo'
        context['~rating'] = '4'
        self.parser.eval("$unset(_rating)", context)
        self.assertNotIn('~rating', context)

    def test_cmd_unset_multi(self):
        context = Metadata()
        context['performer:foo'] = 'Foo'
        context['performer:bar'] = 'Foo'
        self.parser.eval("$unset(performer:*)", context)
        self.assertNotIn('performer:bar', context)
        self.assertNotIn('performer:foo', context)

    def test_cmd_unset(self):
        context = Metadata()
        context['title'] = 'Foo'
        self.parser.eval("$unset(title)", context)
        self.assertNotIn('title', context)
        self.assertNotIn('title', context.deleted_tags)

    def test_cmd_delete(self):
        context = Metadata()
        context['title'] = 'Foo'
        self.parser.eval("$delete(title)", context)
        self.assertNotIn('title', context)
        self.assertIn('title', context.deleted_tags)

    def test_cmd_inmulti(self):
        context = Metadata()

        # Test with single-value string
        context["foo"] = "First:A; Second:B; Third:C"
        # Tests with $in for comparison purposes
        self.assertScriptResultEquals("$in(%foo%,Second:B)", "1", context)
        self.assertScriptResultEquals("$in(%foo%,irst:A; Second:B; Thi)", "1", context)
        self.assertScriptResultEquals("$in(%foo%,First:A; Second:B; Third:C)", "1", context)
        # Base $inmulti tests
        self.assertScriptResultEquals("$inmulti(%foo%,Second:B)", "", context)
        self.assertScriptResultEquals("$inmulti(%foo%,irst:A; Second:B; Thi)", "", context)
        self.assertScriptResultEquals("$inmulti(%foo%,First:A; Second:B; Third:C)", "1", context)
        # Test separator override but with existing separator - results should be same as base
        self.assertScriptResultEquals("$inmulti(%foo%,Second:B,; )", "", context)
        self.assertScriptResultEquals("$inmulti(%foo%,irst:A; Second:B; Thi,; )", "", context)
        self.assertScriptResultEquals("$inmulti(%foo%,First:A; Second:B; Third:C,; )", "1", context)
        # Test separator override
        self.assertScriptResultEquals("$inmulti(%foo%,First:A,:)", "", context)
        self.assertScriptResultEquals("$inmulti(%foo%,Second:B,:)", "", context)
        self.assertScriptResultEquals("$inmulti(%foo%,Third:C,:)", "", context)
        self.assertScriptResultEquals("$inmulti(%foo%,First,:)", "1", context)
        self.assertScriptResultEquals("$inmulti(%foo%,A; Second,:)", "1", context)
        self.assertScriptResultEquals("$inmulti(%foo%,B; Third,:)", "1", context)
        self.assertScriptResultEquals("$inmulti(%foo%,C,:)", "1", context)
        # Test no separator
        self.assertScriptResultEquals("$inmulti(%foo%,First:A; Second:B; Third:C,)", "1", context)

        # Test with multi-values
        context["foo"] = ["First:A", "Second:B", "Third:C"]
        # Tests with $in for comparison purposes
        self.assertScriptResultEquals("$in(%foo%,Second:B)", "1", context)
        self.assertScriptResultEquals("$in(%foo%,irst:A; Second:B; Thi)", "1", context)
        self.assertScriptResultEquals("$in(%foo%,First:A; Second:B; Third:C)", "1", context)
        # Base $inmulti tests
        self.assertScriptResultEquals("$inmulti(%foo%,Second:B)", "1", context)
        self.assertScriptResultEquals("$inmulti(%foo%,irst:A; Second:B; Thi)", "", context)
        self.assertScriptResultEquals("$inmulti(%foo%,First:A; Second:B; Third:C)", "", context)
        # Test separator override but with existing separator - results should be same as base
        self.assertScriptResultEquals("$inmulti(%foo%,Second:B,; )", "1", context)
        self.assertScriptResultEquals("$inmulti(%foo%,irst:A; Second:B; Thi,; )", "", context)
        self.assertScriptResultEquals("$inmulti(%foo%,First:A; Second:B; Third:C,; )", "", context)
        # Test separator override
        self.assertScriptResultEquals("$inmulti(%foo%,First:A,:)", "", context)
        self.assertScriptResultEquals("$inmulti(%foo%,Second:B,:)", "", context)
        self.assertScriptResultEquals("$inmulti(%foo%,Third:C,:)", "", context)
        self.assertScriptResultEquals("$inmulti(%foo%,First,:)", "1", context)
        self.assertScriptResultEquals("$inmulti(%foo%,A; Second,:)", "1", context)
        self.assertScriptResultEquals("$inmulti(%foo%,B; Third,:)", "1", context)
        self.assertScriptResultEquals("$inmulti(%foo%,C,:)", "1", context)
        # Test no separator
        self.assertScriptResultEquals("$inmulti(%foo%,First:A; Second:B; Third:C,)", "1", context)

    def test_cmd_lenmulti(self):
        context = Metadata()
        context["foo"] = "First:A; Second:B; Third:C"
        context["bar"] = ["First:A", "Second:B", "Third:C"]
        # Tests with $len for comparison purposes
        self.assertScriptResultEquals("$len(%foo%)", "26", context)
        self.assertScriptResultEquals("$len(%bar%)", "26", context)
        # Base $lenmulti tests
        self.assertScriptResultEquals("$lenmulti(%foo%)", "1", context)
        self.assertScriptResultEquals("$lenmulti(%bar%)", "3", context)
        self.assertScriptResultEquals("$lenmulti(%foo%.)", "3", context)
        # Test separator override but with existing separator - results should be same as base
        self.assertScriptResultEquals("$lenmulti(%foo%,; )", "1", context)
        self.assertScriptResultEquals("$lenmulti(%bar%,; )", "3", context)
        self.assertScriptResultEquals("$lenmulti(%foo%.,; )", "3", context)
        # Test separator override
        self.assertScriptResultEquals("$lenmulti(%foo%,:)", "4", context)
        self.assertScriptResultEquals("$lenmulti(%bar%,:)", "4", context)
        self.assertScriptResultEquals("$lenmulti(%foo%.,:)", "4", context)
        # Test no separator
        self.assertScriptResultEquals("$lenmulti(%foo%,)", "1", context)
        self.assertScriptResultEquals("$lenmulti(%bar%,)", "1", context)
        # Test blank name
        context["baz"] = ""
        self.assertScriptResultEquals("$lenmulti(%baz%)", "0", context)
        self.assertScriptResultEquals("$lenmulti(%baz%,:)", "0", context)
        # Test empty multi-value
        context["baz"] = []
        self.assertScriptResultEquals("$lenmulti(%baz%)", "0", context)
        self.assertScriptResultEquals("$lenmulti(%baz%,:)", "0", context)
        # Test missing name
        self.assertScriptResultEquals("$lenmulti(,)", "0", context)
        self.assertScriptResultEquals("$lenmulti(,:)", "0", context)

    def test_cmd_performer(self):
        context = Metadata()
        context['performer:guitar'] = 'Foo1'
        context['performer:rhythm-guitar'] = 'Foo2'
        context['performer:drums'] = 'Drummer'
        result = self.parser.eval("$performer(guitar)", context=context)
        performers = result.split(', ')
        self.assertIn('Foo1', performers)
        self.assertIn('Foo2', performers)
        self.assertEqual(2, len(performers))

    def test_cmd_performer_custom_join(self):
        context = Metadata()
        context['performer:guitar'] = 'Foo1'
        context['performer:rhythm-guitar'] = 'Foo2'
        context['performer:drums'] = 'Drummer'
        result = self.parser.eval("$performer(guitar, / )", context=context)
        performers = result.split(' / ')
        self.assertIn('Foo1', performers)
        self.assertIn('Foo2', performers)
        self.assertEqual(2, len(performers))

    def test_cmd_matchedtracks(self):
        file = MagicMock()
        file.parent.album.get_num_matched_tracks.return_value = 42
        self.assertScriptResultEquals("$matchedtracks()", "42", file=file)
        self.assertScriptResultEquals("$matchedtracks()", "0")
        # The following only is possible for backward compatibility, arg is unused
        self.assertScriptResultEquals("$matchedtracks(arg)", "0")

    def test_cmd_matchedtracks_with_cluster(self):
        file = MagicMock()
        cluster = Cluster(name="Test")
        cluster.files.append(file)
        file.parent = cluster
        self.assertScriptResultEquals("$matchedtracks()", "0", file=file)

    def test_cmd_is_complete(self):
        file = MagicMock()
        file.parent.album.is_complete.return_value = True
        self.assertScriptResultEquals("$is_complete()", "1", file=file)
        file.parent.album.is_complete.return_value = False
        self.assertScriptResultEquals("$is_complete()", "", file=file)
        self.assertScriptResultEquals("$is_complete()", "")

    def test_cmd_is_complete_with_cluster(self):
        file = MagicMock()
        cluster = Cluster(name="Test")
        cluster.files.append(file)
        file.parent = cluster
        self.assertScriptResultEquals("$is_complete()", "", file=file)

    def test_cmd_is_video(self):
        context = Metadata({'~video': '1'})
        self.assertScriptResultEquals("$is_video()", "1", context=context)
        context = Metadata({'~video': '0'})
        self.assertScriptResultEquals("$is_video()", "", context=context)
        self.assertScriptResultEquals("$is_video()", "")

    def test_cmd_is_audio(self):
        context = Metadata({'~video': '1'})
        self.assertScriptResultEquals("$is_audio()", "", context=context)
        context = Metadata({'~video': '0'})
        self.assertScriptResultEquals("$is_audio()", "1", context=context)
        self.assertScriptResultEquals("$is_audio()", "1")

    def test_required_kwonly_parameters(self):
        def func(a, *, required_kwarg):
            pass

        with self.assertRaises(TypeError,
                               msg="Functions with required keyword-only parameters are not supported"):
            register_script_function(func)

    @staticmethod
    def test_optional_kwonly_parameters():
        def func(a, *, optional_kwarg=1):
            pass
        register_script_function(func)

    def test_char_escape(self):
        self.assertScriptResultEquals(r"\n\t\$\%\(\)\,\\", "\n\t$%(),\\")

    def test_raise_unknown_function(self):
        self.assertRaises(ScriptUnknownFunction, self.parser.eval, '$unknownfn()')

    def test_raise_end_of_file(self):
        self.assertRaises(ScriptEndOfFile, self.parser.eval, '$noop(')
        self.assertRaises(ScriptEndOfFile, self.parser.eval, '%var')

    def test_raise_syntax_error(self):
        self.assertRaises(ScriptSyntaxError, self.parser.eval, '%var()%')
        self.assertRaises(ScriptSyntaxError, self.parser.eval, '$noop(()')
        self.assertRaises(ScriptSyntaxError, self.parser.eval, r'\x')

    def test_cmd_find(self):
        context = Metadata()
        context["foo"] = "First:A; Second:B; Third:C"
        context["bar"] = ["First:A", "Second:B", "Third:C"]
        context["baz"] = "Second"
        context["err"] = "Forth"
        # Tests with context
        self.assertScriptResultEquals("$find(%foo%,%baz%)", "9", context)
        self.assertScriptResultEquals("$find(%bar%,%baz%)", "9", context)
        self.assertScriptResultEquals("$find(%foo%,%bar%)", "0", context)
        self.assertScriptResultEquals("$find(%bar%,%foo%)", "0", context)
        self.assertScriptResultEquals("$find(%foo%,%err%)", "", context)
        # Tests with static input
        self.assertScriptResultEquals("$find(abcdef,c)", "2", context)
        self.assertScriptResultEquals("$find(abcdef,cd)", "2", context)
        self.assertScriptResultEquals("$find(abcdef,g)", "", context)
        # Tests ends of string
        self.assertScriptResultEquals("$find(abcdef,a)", "0", context)
        self.assertScriptResultEquals("$find(abcdef,ab)", "0", context)
        self.assertScriptResultEquals("$find(abcdef,f)", "5", context)
        self.assertScriptResultEquals("$find(abcdef,ef)", "4", context)
        # Tests case sensitivity
        self.assertScriptResultEquals("$find(abcdef,C)", "", context)
        # Tests no characters processed as wildcards
        self.assertScriptResultEquals("$find(abcdef,.f)", "", context)
        self.assertScriptResultEquals("$find(abcdef,?f)", "", context)
        self.assertScriptResultEquals("$find(abcdef,*f)", "", context)
        self.assertScriptResultEquals("$find(abc.ef,cde)", "", context)
        self.assertScriptResultEquals("$find(abc?ef,cde)", "", context)
        self.assertScriptResultEquals("$find(abc*ef,cde)", "", context)
        # Tests missing inputs
        self.assertScriptResultEquals("$find(,c)", "", context)
        self.assertScriptResultEquals("$find(abcdef,)", "0", context)
        # Tests wrong number of arguments
        areg = r"^\d+:\d+:\$find: Wrong number of arguments for \$find: Expected exactly 2, "
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$find(abcdef)")

    def test_cmd_reverse(self):
        context = Metadata()
        context["foo"] = "One; Two; Three"
        context["bar"] = ["One", "Two", "Three"]
        # Tests with context
        self.assertScriptResultEquals("$reverse(%foo%)", "eerhT ;owT ;enO", context)
        self.assertScriptResultEquals("$reverse(%bar%)", "eerhT ;owT ;enO", context)
        # Tests with static input
        self.assertScriptResultEquals("$reverse(One; Two; Three)", "eerhT ;owT ;enO", context)
        # Tests with missing input
        areg = r"^\d+:\d+:\$reverse: Wrong number of arguments for \$reverse: Expected exactly 1, "
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$reverse()")

    def test_cmd_substr(self):
        context = Metadata()
        context["foo"] = "One; Two; Three"
        context["bar"] = ["One", "Two", "Three"]
        context["start"] = '5'
        context["end"] = '9'
        # Tests with context
        self.assertScriptResultEquals("$substr(%foo%,%start%,%end%)", "Two;", context)
        self.assertScriptResultEquals("$substr(%bar%,%start%,%end%)", "Two;", context)
        # Tests with static input
        self.assertScriptResultEquals("$substr(One; Two; Three,5,9)", "Two;", context)
        # Tests ends of string
        self.assertScriptResultEquals("$substr(One; Two; Three,0,4)", "One;", context)
        self.assertScriptResultEquals("$substr(One; Two; Three,10,15)", "Three", context)
        # Tests negative index inputs
        self.assertScriptResultEquals("$substr(One; Two; Three,-15,4)", "One;", context)
        self.assertScriptResultEquals("$substr(One; Two; Three,-5,15)", "Three", context)
        self.assertScriptResultEquals("$substr(One; Two; Three,-10,8)", "Two", context)
        self.assertScriptResultEquals("$substr(One; Two; Three,5,-7)", "Two", context)
        # Tests overrun ends of string
        self.assertScriptResultEquals("$substr(One; Two; Three,10,25)", "Three", context)
        self.assertScriptResultEquals("$substr(One; Two; Three,-25,4)", "One;", context)
        self.assertScriptResultEquals("$substr(One; Two; Three,-5,25)", "Three", context)
        # Tests invalid ranges (end < start, end = start)
        self.assertScriptResultEquals("$substr(One; Two; Three,10,9)", "", context)
        self.assertScriptResultEquals("$substr(One; Two; Three,10,10)", "", context)
        # Tests invalid index inputs
        self.assertScriptResultEquals("$substr(One; Two; Three,10-1,4)", "One;", context)
        self.assertScriptResultEquals("$substr(One; Two; Three,10,4+2)", "Three", context)
        self.assertScriptResultEquals("$substr(One; Two; Three,10-1,4+2)", "One; Two; Three", context)
        self.assertScriptResultEquals("$substr(One; Two; Three,a,4)", "One;", context)
        self.assertScriptResultEquals("$substr(One; Two; Three,0,b)", "One; Two; Three", context)
        self.assertScriptResultEquals("$substr(One; Two; Three,a,b)", "One; Two; Three", context)
        # # Tests with missing input
        self.assertScriptResultEquals("$substr(One; Two; Three,,4)", "One;", context)
        self.assertScriptResultEquals("$substr(One; Two; Three,10,)", "Three", context)
        self.assertScriptResultEquals("$substr(One; Two; Three,,)", "One; Two; Three", context)
        # Tests with missing input
        areg = r"^\d+:\d+:\$substr: Wrong number of arguments for \$substr: Expected exactly 3, "
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$substr()")
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$substr(abc)")
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$substr(abc,0)")

    def test_cmd_getmulti(self):
        context = Metadata()
        context["foo"] = ["First:A", "Second:B", "Third:C"]
        context["index"] = "1"
        # Tests with context
        self.assertScriptResultEquals("$getmulti(%foo%,%index%)", "Second:B", context)
        # Tests with static inputs
        self.assertScriptResultEquals("$getmulti(First:A; Second:B; Third:C,0)", "First:A", context)
        self.assertScriptResultEquals("$getmulti(First:A; Second:B; Third:C,1)", "Second:B", context)
        # Tests separator override
        self.assertScriptResultEquals("$getmulti(%foo%,1,:)", "A; Second", context)
        self.assertScriptResultEquals("$getmulti(First:A; Second:B; Third:C,1,:)", "A; Second", context)
        # Tests negative index values
        self.assertScriptResultEquals("$getmulti(First:A; Second:B; Third:C,-1)", "Third:C", context)
        self.assertScriptResultEquals("$getmulti(First:A; Second:B; Third:C,-2)", "Second:B", context)
        self.assertScriptResultEquals("$getmulti(First:A; Second:B; Third:C,-3)", "First:A", context)
        # Tests out of range index values
        self.assertScriptResultEquals("$getmulti(First:A; Second:B; Third:C,10)", "", context)
        self.assertScriptResultEquals("$getmulti(First:A; Second:B; Third:C,-4)", "", context)
        # Tests invalid index values
        self.assertScriptResultEquals("$getmulti(,0)", "", context)
        self.assertScriptResultEquals("$getmulti(First:A; Second:B; Third:C,)", "", context)
        self.assertScriptResultEquals("$getmulti(First:A; Second:B; Third:C,10+1)", "", context)
        self.assertScriptResultEquals("$getmulti(First:A; Second:B; Third:C,a)", "", context)
        # Tests with missing inputs
        self.assertScriptResultEquals("$getmulti(,0)", "", context)
        self.assertScriptResultEquals("$getmulti(First:A; Second:B; Third:C,)", "", context)
        # Tests with invalid number of arguments
        areg = r"^\d+:\d+:\$getmulti: Wrong number of arguments for \$getmulti: Expected between 2 and 3, "
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$getmulti()")
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$getmulti(abc)")
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$getmulti(abc,0,; ,extra)")

    def test_cmd_foreach(self):
        context = Metadata()
        context["foo"] = "First:A; Second:B; Third:C"
        context["bar"] = ["First:A", "Second:B", "Third:C"]
        foo_output = "Output: 1=First:A; Second:B; Third:C"
        loop_output = "Output: 1=First:A 2=Second:B 3=Third:C"
        alternate_output = "Output: 1=First 2=A; Second 3=B; Third 4=C"
        # Tests with context
        context["output"] = "Output:"
        self.assertScriptResultEquals("$foreach(%foo%,$set(output,%output% %_loop_count%=%_loop_value%))%output%", foo_output, context)
        context["output"] = "Output:"
        self.assertScriptResultEquals("$foreach(%bar%,$set(output,%output% %_loop_count%=%_loop_value%))%output%", loop_output, context)
        # Tests with static inputs
        context["output"] = "Output:"
        self.assertScriptResultEquals("$foreach(First:A; Second:B; Third:C,$set(output,%output% %_loop_count%=%_loop_value%))%output%", loop_output, context)
        # Tests with missing inputs
        context["output"] = "Output:"
        self.assertScriptResultEquals("$foreach(,$set(output,%output% %_loop_count%=%_loop_value%))%output%", "Output:", context)
        context["output"] = "Output:"
        self.assertScriptResultEquals("$foreach(First:A; Second:B; Third:C,)%output%", "Output:", context)
        # Tests with separator override
        context["output"] = "Output:"
        self.assertScriptResultEquals("$foreach(First:A; Second:B; Third:C,$set(output,%output% %_loop_count%=%_loop_value%),:)%output%", alternate_output, context)
        # Tests with invalid number of arguments
        areg = r"^\d+:\d+:\$foreach: Wrong number of arguments for \$foreach: Expected between 2 and 3, "
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$foreach()")
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$foreach(abc;def)")
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$foreach(abc:def,$noop(),:,extra)")

    def test_cmd_while(self):
        context = Metadata()
        context["max_value"] = '5'
        loop_output = "Output: 1 2 3 4 5"
        # Tests with context
        context["output"] = "Output:"
        self.assertScriptResultEquals("$set(_loop_count,1)$while($lt(%_loop_count%,%max_value%),$set(output,%output% %_loop_count%))%output%", loop_output, context)
        # Tests with static inputs
        context["output"] = "Output:"
        self.assertScriptResultEquals("$set(_loop_count,1)$while($lt(%_loop_count%,5),$set(output,%output% %_loop_count%))%output%", loop_output, context)
        # Tests with invalid conditional input
        context["output"] = "Output:"
        self.assertScriptResultEquals("$while($lt(%_loop_count%,5),$set(output,%output% %_loop_count%))%output%", "Output:", context)
        # Tests with forced conditional (runaway condition)
        context["output"] = "Output:"
        self.assertScriptResultEquals("$while(1,$set(output,%output% %_loop_count%))$right(%output%,4)", "1000", context)
        context["output"] = "Output:"
        self.assertScriptResultEquals("$while(0,$set(output,%output% %_loop_count%))$right(%output%,4)", "1000", context)
        # Tests with missing inputs
        context["output"] = "Output:"
        self.assertScriptResultEquals("$while($lt(%_loop_count%,5),)%output%", "Output:", context)
        context["output"] = "Output:"
        self.assertScriptResultEquals("$while(,$set(output,%output% %_loop_count%))%output%", "Output:", context)
        # Tests with invalid number of arguments
        areg = r"^\d+:\d+:\$while: Wrong number of arguments for \$while: Expected exactly 2, "
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$while()")
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$while(a)")
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$while(a,$noop(),extra)")

    def test_cmd_map(self):
        context = Metadata()
        context["foo"] = "First:A; Second:B; Third:C"
        context["bar"] = ["First:A", "Second:B", "Third:C"]
        foo_output = "1=FIRST:A; SECOND:B; THIRD:C"
        loop_output = "1=FIRST:A; 2=SECOND:B; 3=THIRD:C"
        alternate_output = "1=FIRST:2=A; SECOND:3=B; THIRD:4=C"
        # Tests with context
        self.assertScriptResultEquals("$map(%foo%,$upper(%_loop_count%=%_loop_value%))", foo_output, context)
        self.assertScriptResultEquals("$map(%bar%,$upper(%_loop_count%=%_loop_value%))", loop_output, context)
        # Tests with static inputs
        self.assertScriptResultEquals("$map(First:A; Second:B; Third:C,$upper(%_loop_count%=%_loop_value%))", loop_output, context)
        # Tests with missing inputs
        self.assertScriptResultEquals("$map(,$upper(%_loop_count%=%_loop_value%))", "", context)
        self.assertScriptResultEquals("$map(First:A; Second:B; Third:C,)", "; ; ", context)
        # Tests with separator override
        self.assertScriptResultEquals("$map(First:A; Second:B; Third:C,$upper(%_loop_count%=%_loop_value%),:)", alternate_output, context)
        # Tests with invalid number of arguments
        areg = r"^\d+:\d+:\$map: Wrong number of arguments for \$map: Expected between 2 and 3, "
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$map()")
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$map(abc; def)")
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$map(abc:def,$noop(),:,extra)")

    def test_cmd_joinmulti(self):
        context = Metadata()
        context["foo"] = "First:A; Second:B; Third:C"
        context["bar"] = ["First:A", "Second:B", "Third:C"]
        context["joiner"] = " ==> "
        foo_output = "First:A; Second:B; Third:C"
        bar_output = "First:A ==> Second:B ==> Third:C"
        alternate_output = "First ==> A; Second ==> B; Third ==> C"
        # Tests with context
        self.assertScriptResultEquals("$join(%foo%,%joiner%)", foo_output, context)
        self.assertScriptResultEquals("$join(%bar%,%joiner%)", bar_output, context)
        # Tests with static inputs
        self.assertScriptResultEquals("$join(First:A; Second:B; Third:C, ==> )", bar_output, context)
        # Tests with missing inputs
        self.assertScriptResultEquals("$join(, ==> )", "", context)
        self.assertScriptResultEquals("$join(First:A; Second:B; Third:C,)", "First:ASecond:BThird:C", context)
        # Tests with separator override
        self.assertScriptResultEquals("$join(First:A; Second:B; Third:C, ==> ,:)", alternate_output, context)
        # Tests with invalid number of arguments
        areg = r"^\d+:\d+:\$join: Wrong number of arguments for \$join: Expected between 2 and 3, "
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$join()")
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$join(abc; def)")
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$join(abc:def, ==> ,:,extra)")

    def test_cmd_slice(self):
        context = Metadata()
        context["foo"] = "First:A; Second:B; Third:C"
        context["bar"] = ["First:A", "Second:B", "Third:C"]
        context["zero"] = '0'
        context["one"] = '1'
        context["two"] = '2'
        context["three"] = '3'
        output_0_1 = "First:A"
        output_0_2 = "First:A; Second:B"
        output_0_3 = "First:A; Second:B; Third:C"
        output_1_2 = "Second:B"
        output_1_3 = "Second:B; Third:C"
        output_2_3 = "Third:C"
        alternate_output = "A; Second:B; Third"
        # Tests with context
        self.assertScriptResultEquals("$slice(%foo%,%zero%,%one%)", output_0_3, context)
        self.assertScriptResultEquals("$slice(%bar%,%zero%,%one%)", output_0_1, context)
        self.assertScriptResultEquals("$slice(%bar%,%zero%,%two%)", output_0_2, context)
        self.assertScriptResultEquals("$slice(%bar%,%zero%,%three%)", output_0_3, context)
        # Tests with static inputs
        self.assertScriptResultEquals("$slice(First:A; Second:B; Third:C,0,1)", output_0_1, context)
        self.assertScriptResultEquals("$slice(First:A; Second:B; Third:C,0,2)", output_0_2, context)
        self.assertScriptResultEquals("$slice(First:A; Second:B; Third:C,0,3)", output_0_3, context)
        self.assertScriptResultEquals("$slice(First:A; Second:B; Third:C,1,2)", output_1_2, context)
        self.assertScriptResultEquals("$slice(First:A; Second:B; Third:C,1,3)", output_1_3, context)
        self.assertScriptResultEquals("$slice(First:A; Second:B; Third:C,2,3)", output_2_3, context)
        # Tests with negative inputs
        self.assertScriptResultEquals("$slice(First:A; Second:B; Third:C,-3,1)", output_0_1, context)
        self.assertScriptResultEquals("$slice(First:A; Second:B; Third:C,0,-1)", output_0_2, context)
        self.assertScriptResultEquals("$slice(First:A; Second:B; Third:C,-2,2)", output_1_2, context)
        # Tests with missing inputs
        self.assertScriptResultEquals("$slice(First:A; Second:B; Third:C,,1)", output_0_1, context)
        self.assertScriptResultEquals("$slice(First:A; Second:B; Third:C,1,)", output_1_3, context)
        self.assertScriptResultEquals("$slice(First:A; Second:B; Third:C,,)", output_0_3, context)
        # Tests with invalid inputs (end < start)
        self.assertScriptResultEquals("$slice(First:A; Second:B; Third:C,1,0)", "", context)
        # Tests with invalid inputs (non-numeric end and/or start)
        self.assertScriptResultEquals("$slice(First:A; Second:B; Third:C,a,1)", output_0_1, context)
        self.assertScriptResultEquals("$slice(First:A; Second:B; Third:C,1,b)", output_1_3, context)
        self.assertScriptResultEquals("$slice(First:A; Second:B; Third:C,a,b)", output_0_3, context)
        # Tests with separator override
        self.assertScriptResultEquals("$slice(First:A; Second:B; Third:C,1,3,:)", alternate_output, context)
        # Tests with invalid number of arguments
        areg = r"^\d+:\d+:\$slice: Wrong number of arguments for \$slice: Expected between 3 and 4, "
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$slice()")
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$slice(abc; def)")
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$slice(abc; def,0)")
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$slice(abc; def),0,1,:,extra")

    def test_cmd_datetime(self):
        # Save original datetime object and substitute one returning
        # a fixed now() value for testing.
        original_datetime = datetime.datetime
        datetime.datetime = _DateTime

        try:
            context = Metadata()
            context["foo"] = "%Y%m%d%H%M%S.%f"
            # Tests with context
            self.assertScriptResultEquals("$datetime(%foo%)", "20200102123456.000789", context)
            self.assertScriptResultEquals("$datetime($initials())", "2020-01-02 12:34:56", context)
            # Tests with static input
            self.assertScriptResultEquals(r"$datetime(\%Y\%m\%d\%H\%M\%S.\%f)", "20200102123456.000789", context)
            # Tests with timezones
            self.assertScriptResultEquals(r"$datetime(\%H\%M\%S \%z \%Z)", "123456 +0200 TZ Test", context)
            # Tests with missing input
            self.assertScriptResultEquals("$datetime()", "2020-01-02 12:34:56", context)
            # Tests with invalid format
            self.assertScriptResultEquals("$datetime(xxx)", "xxx", context)
            # Tests with invalid number of arguments
            areg = r"^\d+:\d+:\$datetime: Wrong number of arguments for \$datetime: Expected between 0 and 1, "
            with self.assertRaisesRegex(ScriptError, areg):
                self.parser.eval("$datetime(abc,def)")
        finally:
            # Restore original datetime object
            datetime.datetime = original_datetime

    def test_cmd_datetime_platform_dependent(self):
        # Platform dependent testing because different platforms (both os and Python version)
        # support some format arguments differently.
        possible_tests = (
            '%',    # Hanging % at end of format
            '%-d',  # Non zero-padded day
            '%-m',  # Non zero-padded month
            '%3Y',  # Length specifier shorter than string
        )
        tests_to_run = []
        # Get list of tests for unsupported format codes
        for test_case in possible_tests:
            try:
                datetime.datetime.now().strftime(test_case)
            except ValueError:
                tests_to_run.append(test_case)
        if not tests_to_run:
            self.skipTest('datetime module supports all test cases')
        # Save original datetime object and substitute one returning
        # a fixed now() value for testing.
        original_datetime = datetime.datetime
        datetime.datetime = _DateTime

        try:
            areg = r"^\d+:\d+:\$datetime: Unsupported format code"
            # Tests with invalid format code (platform dependent tests)
            for test_case in tests_to_run:
                with self.assertRaisesRegex(ScriptRuntimeError, areg):
                    self.parser.eval(r'$datetime(\{0})'.format(test_case))
        finally:
            # Restore original datetime object
            datetime.datetime = original_datetime

    def test_scriptruntimeerror(self):
        # Platform dependent testing because different platforms (both os and Python version)
        # support some format arguments differently.  Use $datetime function to generate exceptions.
        possible_tests = (
            '%',    # Hanging % at end of format
            '%-d',  # Non zero-padded day
            '%-m',  # Non zero-padded month
            '%3Y',  # Length specifier shorter than string
        )
        test_to_run = ''
        # Get list of tests for unsupported format codes
        for test_case in possible_tests:
            try:
                datetime.datetime.now().strftime(test_case)
            except ValueError:
                test_to_run = test_case
                break
        if not test_to_run:
            self.skipTest('no test found to generate ScriptRuntimeError')
        # Save original datetime object and substitute one returning
        # a fixed now() value for testing.
        original_datetime = datetime.datetime
        datetime.datetime = _DateTime

        try:
            # Test that the correct position number is passed
            areg = r"^\d+:7:\$datetime: Unsupported format code"
            with self.assertRaisesRegex(ScriptRuntimeError, areg):
                self.parser.eval(r'$noop()$datetime(\{0})'.format(test_to_run))
            # Test that the function stack is returning the correct name (nested functions)
            areg = r"^\d+:\d+:\$datetime: Unsupported format code"
            with self.assertRaisesRegex(ScriptRuntimeError, areg):
                self.parser.eval(r'$set(foo,$datetime($if(,,\{0})))'.format(test_to_run))
            # Test that the correct line number is passed
            areg = r"^2:\d+:\$datetime: Unsupported format code"
            with self.assertRaisesRegex(ScriptRuntimeError, areg):
                self.parser.eval('$noop(\n)$datetime($if(,,\\{0})))'.format(test_to_run))
        finally:
            # Restore original datetime object
            datetime.datetime = original_datetime

    def test_multivalue_1(self):
        self.parser.context = Metadata({'foo': ':', 'bar': 'x:yz', 'empty': ''})
        expr = self.parser.parse('a:bc; d:ef')
        self.assertIsInstance(expr, ScriptExpression)

        mv = MultiValue(self.parser, expr, MULTI_VALUED_JOINER)
        self.assertEqual(mv._multi, ['a:bc', 'd:ef'])
        self.assertEqual(mv.separator, MULTI_VALUED_JOINER)
        self.assertEqual(len(mv), 2)
        del mv[0]
        self.assertEqual(len(mv), 1)
        mv.insert(0, 'x')
        self.assertEqual(mv[0], 'x')
        mv[0] = 'y'
        self.assertEqual(mv[0], 'y')
        self.assertEqual(str(mv), 'y; d:ef')
        self.assertTrue(repr(mv).startswith('MultiValue('))

        mv = MultiValue(self.parser, expr, ':')
        self.assertEqual(mv._multi, ['a', 'bc; d', 'ef'])
        self.assertEqual(mv.separator, ':')

        expr = self.parser.parse('a:bc; d:ef')
        self.assertIsInstance(expr, ScriptExpression)
        sep = self.parser.parse('%foo%')
        self.assertIsInstance(sep, ScriptExpression)
        mv = MultiValue(self.parser, expr, sep)
        self.assertEqual(mv._multi, ['a', 'bc; d', 'ef'])

        expr = self.parser.parse('%bar%; d:ef')
        self.assertIsInstance(expr, ScriptExpression)
        sep = self.parser.parse('%foo%')
        self.assertIsInstance(sep, ScriptExpression)
        mv = MultiValue(self.parser, expr, sep)
        self.assertEqual(mv._multi, ['x', 'yz; d', 'ef'])

        expr = self.parser.parse('%bar%')
        self.assertIsInstance(expr, ScriptExpression)
        mv = MultiValue(self.parser, expr, MULTI_VALUED_JOINER)
        self.assertEqual(mv._multi, ['x:yz'])

        expr = self.parser.parse('%bar%; d:ef')
        self.assertIsInstance(expr, ScriptExpression)
        sep = self.parser.parse('%empty%')
        self.assertIsInstance(sep, ScriptExpression)
        mv = MultiValue(self.parser, expr, sep)
        self.assertEqual(mv._multi, ['x:yz; d:ef'])

        expr = self.parser.parse('')
        self.assertIsInstance(expr, ScriptExpression)
        mv = MultiValue(self.parser, expr, MULTI_VALUED_JOINER)
        self.assertEqual(mv._multi, [])

    def test_cmd_sortmulti(self):
        context = Metadata()
        context["foo"] = ['B', 'D', 'E', 'A', 'C']
        context["bar"] = ['B:AB', 'D:C', 'E:D', 'A:A', 'C:X']
        context['baz'] = "B; D; E; A; C"
        # Tests with context
        self.assertScriptResultEquals("$sortmulti(%foo%)", "A; B; C; D; E", context)
        self.assertScriptResultEquals("$sortmulti(%bar%)", "A:A; B:AB; C:X; D:C; E:D", context)
        self.assertScriptResultEquals("$sortmulti(%baz%)", "B; D; E; A; C", context)
        # Tests with static inputs
        self.assertScriptResultEquals("$sortmulti(B; D; E; A; C)", "A; B; C; D; E", context)
        self.assertScriptResultEquals("$sortmulti(B:AB; D:C; E:D; A:A; C:X)", "A:A; B:AB; C:X; D:C; E:D", context)
        # Tests with separator override
        self.assertScriptResultEquals("$sortmulti(B:AB; D:C; E:D; A:A; C:X,:)", "A; C:AB; D:B:C; E:D; A:X", context)
        # Tests with missing inputs
        self.assertScriptResultEquals("$sortmulti(,)", "", context)
        self.assertScriptResultEquals("$sortmulti(,:)", "", context)
        # Tests with invalid number of arguments
        areg = r"^\d+:\d+:\$sortmulti: Wrong number of arguments for \$sortmulti: Expected between 1 and 2, "
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$sortmulti()")
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$sortmulti(B:AB; D:C; E:D; A:A; C:X,:,extra)")

    def test_cmd_reversemulti(self):
        context = Metadata()
        context["foo"] = ['B', 'D', 'E', 'A', 'C']
        context["bar"] = ['B:AB', 'D:C', 'E:D', 'A:A', 'C:X']
        context['baz'] = "B; D; E; A; C"
        # Tests with context
        self.assertScriptResultEquals("$reversemulti(%foo%)", "C; A; E; D; B", context)
        self.assertScriptResultEquals("$reversemulti(%bar%)", "C:X; A:A; E:D; D:C; B:AB", context)
        self.assertScriptResultEquals("$reversemulti(%baz%)", "B; D; E; A; C", context)
        # Tests with static inputs
        self.assertScriptResultEquals("$reversemulti(B; D; E; A; C)", "C; A; E; D; B", context)
        self.assertScriptResultEquals("$reversemulti(B:AB; D:C; E:D; A:A; C:X)", "C:X; A:A; E:D; D:C; B:AB", context)
        # Tests with separator override
        self.assertScriptResultEquals("$reversemulti(B:AB; D:C; E:D; A:A; C:X,:)", "X:A; C:D; A:C; E:AB; D:B", context)
        # Tests with missing inputs
        self.assertScriptResultEquals("$reversemulti(,)", "", context)
        self.assertScriptResultEquals("$reversemulti(,:)", "", context)
        # Tests with invalid number of arguments
        areg = r"^\d+:\d+:\$reversemulti: Wrong number of arguments for \$reversemulti: Expected between 1 and 2, "
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$reversemulti()")
        with self.assertRaisesRegex(ScriptError, areg):
            self.parser.eval("$reversemulti(B:AB; D:C; E:D; A:A; C:X,:,extra)")
