# globals: assrt, fs, require, test_path, compiler_dir, console, RapydScript

# Tests for the "fmt" sub-command (tools/fmt.mjs), the PEP8 style formatter for
# RapydScript source code.

fmt = require('./fmt.mjs')
path = require('path')
os = require('os')

base_path = path.resolve(compiler_dir, '..')
libdir = path.join(base_path, 'src', 'lib')

async def parses(code):
    ok = True
    try:
        await RapydScript.parse(code, {
            'filename': 'fmt_test.pyj', 'basedir': test_path,
            'libdir': libdir, 'for_linting': True
        })
    except:
        ok = False
        console.log('Formatted output failed to parse:\n' + code)
    return ok

nchecks = {'n': 0}

async def check(name, src, expected, opts):
    opts = opts or {}
    out = fmt.format_string(src, opts)
    assrt.equal(out, expected, 'format output mismatch for: ' + name)
    # The formatter must be idempotent.
    assrt.equal(fmt.format_string(out, opts), out, 'formatter not idempotent for: ' + name)
    # The formatted output must remain valid RapydScript.
    ok = await parses(out)
    assrt.ok(ok, 'formatted output does not parse for: ' + name)
    nchecks.n += 1


async def run_tests():

    # --- Whitespace / operator normalization -------------------------------
    await check('binary-ops', 'x=1+2*3\n', 'x = 1 + 2 * 3\n')
    await check('collapse-spaces', 'y  =   f( a ,b )\n', 'y = f(a, b)\n')
    await check('unary-minus', 'x = -1\ny = a - 1\n', 'x = -1\ny = a - 1\n')
    await check('unary-not', 'z = not  x\n', 'z = not x\n')
    await check('word-ops', 'q=a and b or c\n', 'q = a and b or c\n')
    await check('is-not', 'r = a is not b\n', 'r = a is not b\n')
    await check('power', 'p = a**b\n', 'p = a ** b\n')
    await check('attribute', 'v = a . b . c\n', 'v = a.b.c\n')

    # --- kwargs / defaults vs assignment -----------------------------------
    await check('kwargs', 'f( a=1, b = 2 )\n', 'f(a=1, b=2)\n')
    await check('defaults', 'def g(a, b=3, c = 4):\n    return a\n',
                'def g(a, b=3, c=4):\n    return a\n')
    await check('assignment-spaces', 'x=1\n', 'x = 1\n')
    await check('augmented', 'x+=1\n', 'x += 1\n')

    # --- splat / unpacking --------------------------------------------------
    await check('splat', 'f( *args, **kwargs )\n', 'f(*args, **kwargs)\n')

    # --- slices / index / dict / list --------------------------------------
    await check('slice', 'a = b[ 1 : 2 ]\n', 'a = b[1:2]\n')
    await check('slice-step', 'a = b[::2]\n', 'a = b[::2]\n')
    await check('index', 'c = d[ x ]\n', 'c = d[x]\n')
    await check('negative-index', 'c = d[-1]\n', 'c = d[-1]\n')
    await check('dict', 'e = {"a":1,"b":2}\n', "e = {'a': 1, 'b': 2}\n")
    await check('list', 'l = [ 1,2,3 ]\n', 'l = [1, 2, 3]\n')

    # --- comprehensions -----------------------------------------------------
    await check('list-comp', 'a=[i*i for i in range(1,20) if i*i%3==0]\n',
                'a = [i * i for i in range(1, 20) if i * i % 3 == 0]\n')
    await check('dict-comp', 'd={x:x+1 for x in range(20) if x>2}\n',
                'd = {x: x + 1 for x in range(20) if x > 2}\n')

    # --- quote normalization -----------------------------------------------
    await check('quote-to-single', 'a = "hi"\n', "a = 'hi'\n")
    await check('quote-to-double', "a = 'hi'\n", 'a = "hi"\n', {'preferred_quote': 'double'})
    # Do not re-quote when it would add escapes.
    await check('quote-keep-apostrophe', 'a = "it\'s"\n', 'a = "it\'s"\n')
    await check('quote-keep-embedded-double', 'a = \'say "hi"\'\n', 'a = \'say "hi"\'\n')
    # Raw / f / verbatim strings are never re-quoted.
    await check('quote-raw-untouched', 'a = r"\\d+"\n', 'a = r"\\d+"\n')
    await check('quote-fstring-untouched', 'a = f"x{y}"\n', 'a = f"x{y}"\n')

    # --- indentation normalization (tabs and 2-space -> 4-space) -----------
    await check('tab-indent', 'if True:\n\tx = 1\n', 'if True:\n    x = 1\n')
    await check('two-space-indent', 'if True:\n  x = 1\n  if x:\n    y = 2\n',
                'if True:\n    x = 1\n    if x:\n        y = 2\n')

    # --- blank line policy --------------------------------------------------
    await check('cap-blank-lines', 'a = 1\n\n\n\n\nb = 2\n', 'a = 1\n\n\nb = 2\n')
    await check('two-blanks-between-defs',
                'def f():\n    pass\ndef g():\n    pass\n',
                'def f():\n    pass\n\n\ndef g():\n    pass\n')
    await check('one-blank-between-methods',
                'class A:\n    def f(self):\n        pass\n    def g(self):\n        pass\n',
                'class A:\n    def f(self):\n        pass\n\n    def g(self):\n        pass\n')
    await check('collapse-def-blanks',
                'def f():\n    pass\n\n\n\n\ndef g():\n    pass\n',
                'def f():\n    pass\n\n\ndef g():\n    pass\n')

    # --- comments -----------------------------------------------------------
    await check('comment-space', '#hello\nx = 1\n', '# hello\nx = 1\n')
    await check('trailing-comment', 'x = 1 #note\n', 'x = 1  # note\n')
    await check('comment-before-def',
                '# a helper\ndef f():\n    pass\n',
                '# a helper\ndef f():\n    pass\n')

    # --- decorators ---------------------------------------------------------
    await check('decorators',
                '@makebold\n@makeitalic\ndef hello():\n    return "hi"\n',
                "@makebold\n@makeitalic\ndef hello():\n    return 'hi'\n")

    # --- line wrapping ------------------------------------------------------
    await check('wrap-call',
                'result = fn(aaaaaaaa, bbbbbbbb, cccccccc, dddddddd, eeeeeeee)\n',
                'result = fn(\n    aaaaaaaa,\n    bbbbbbbb,\n    cccccccc,\n    dddddddd,\n    eeeeeeee\n)\n',
                {'line_length': 30})
    await check('wrap-list-trailing-comma',
                'data = [11111111, 22222222, 33333333, 44444444]\n',
                'data = [\n    11111111,\n    22222222,\n    33333333,\n    44444444,\n]\n',
                {'line_length': 20})
    # Short lines are left alone.
    await check('no-wrap-short', 'x = f(a, b)\n', 'x = f(a, b)\n', {'line_length': 80})
    # Comprehensions must never be wrapped on their (tuple-unpacking) commas.
    await check('no-wrap-comprehension',
                'result = [transform(it) for it, index in enumerate(collection) if it.is_valid]\n',
                'result = [transform(it) for it, index in enumerate(collection) if it.is_valid]\n',
                {'line_length': 40})

    # --- reflow of simple multi-line statements ----------------------------
    # By default, source line breaks are preserved (no joining).
    await check('no-join-default',
                'x = fn(\n    a,\n    b,\n    c\n)\n',
                'x = fn(\n    a,\n    b,\n    c\n)\n')
    # With join_lines=True the statement is collapsed onto one line.
    await check('reflow-simple-call',
                'x = fn(\n    a,\n    b,\n    c\n)\n',
                'x = fn(a, b, c)\n',
                {'join_lines': True})
    # A multi-line statement that is too long even in source form gets wrapped
    # regardless of join_lines.
    await check('no-join-wrap-long',
                'x = very_long_function_name(\n    argument_one,\n    argument_two,\n    argument_three\n)\n',
                'x = very_long_function_name(\n    argument_one,\n    argument_two,\n    argument_three\n)\n',
                {'line_length': 40})

    # --- RapydScript specific constructs are preserved ----------------------
    await check('preserve-anon-function',
                "params = {\n    'onclick': def (event):\n        alert('hi')\n    ,\n    'x': 5\n}\n",
                "params = {\n    'onclick': def (event):\n        alert('hi')\n    ,\n    'x': 5\n}\n")
    # Assignments inside an anonymous def passed as an argument must keep spaces
    # around '='; they are not kwargs even though they are inside the outer call.
    await check('anon-def-arg-assignment',
                "window.addEventListener('beforeunload', def (event):\n    self.disable=True\n    self.ws.close()\n)\n",
                "window.addEventListener('beforeunload', def (event):\n    self.disable = True\n    self.ws.close()\n)\n")
    # Inline anonymous def inside a call: kwargs that follow the def must not
    # gain spaces (they are not assignments in the def body).
    await check('inline-anon-def-arg-kwargs',
                "xhr = ajax('url', def ():pass;, method='POST', flag=False)\n",
                "xhr = ajax('url', def (): pass;, method='POST', flag=False)\n")
    await check('preserve-chain',
                "$(element)\n.css('background-color', 'red')\n.show()\n",
                "$(element)\n.css('background-color', 'red')\n.show()\n")
    await check('preserve-verbatim-js', "a = v'var x = {foo: 1};'\n", "a = v'var x = {foo: 1};'\n")
    await check('preserve-regex', 'b = /a(b)/g\n', 'b = /a(b)/g\n')
    await check('preserve-verbose-regex',
                "c = re.match(///\n  a  # comment\n  b\n///, 'ab')\n",
                "c = re.match(///\n  a  # comment\n  b\n///, 'ab')\n")
    await check('preserve-fstring', "msg = f'hello {name} world'\n", "msg = f'hello {name} world'\n")
    await check('preserve-triple-string',
                "text = '''\nline one\n    line two\n'''\n",
                "text = '''\nline one\n    line two\n'''\n")
    await check('preserve-existential',
                'ans = a?.b?[1]?()\nv = b ? c\n',
                'ans = a?.b?[1]?()\nv = b ? c\n')

    # --- backslash continuation is preserved --------------------------------
    await check('preserve-backslash',
                'x = a + \\\n    b + \\\n    c\n',
                'x = a + \\\n    b + \\\n    c\n')

    # --- inline anonymous function on a single line -------------------------
    await check('inline-anon', 'add = def(a,b): return a+b\n',
                'add = def (a, b): return a + b\n')
    # --- anonymous function assignment: space always inserted between def and ( --
    await check('anon-def-assignment',
                "func = def(event):\n    pass\n",
                "func = def (event):\n    pass\n")
    await check('anon-def-assignment-already-spaced',
                "func = def (event):\n    pass\n",
                "func = def (event):\n    pass\n")

    # --- shebang preserved --------------------------------------------------
    await check('shebang', '#!/usr/bin/env rapydscript\nx=1\n',
                '#!/usr/bin/env rapydscript\nx = 1\n')

    # --- empty / whitespace only input --------------------------------------
    assrt.equal(fmt.format_string('', {}), '', 'empty input -> empty output')
    assrt.equal(fmt.format_string('\n\n\n', {}), '', 'blank input -> empty output')

    # --- check_report -------------------------------------------------------
    errs = fmt.check_report('a.pyj', 'x=1\n', fmt.format_string('x=1\n', {}), {})
    assrt.ok(errs.length >= 1, 'check_report must flag files that would be reformatted')
    assrt.ok(errs[0].indexOf('would be reformatted') >= 0, 'check_report message content')

    errs2 = fmt.check_report('a.pyj', 'x = 1\n', 'x = 1\n', {})
    assrt.equal(errs2.length, 0, 'check_report must be silent for already-formatted, short files')

    longline = 'x = ' + "'" + Array(90).join('a') + "'" + '\n'
    errs3 = fmt.check_report('a.pyj', longline, longline, {'line_length': 80})
    assrt.ok(errs3.length >= 1, 'check_report must flag over-length lines')
    assrt.ok(errs3.join('\n').indexOf('exceeds') >= 0, 'check_report over-length message content')

    # --- import organization ------------------------------------------------
    # __python__ group is first; stdlib group is second; other group is third.
    # Blank line separates each non-empty group from the next.
    await check('import-organize-three-groups',
                'import my_module\nimport math\nfrom __python__ import os\n',
                'from __python__ import os\n\nimport math\n\nimport my_module\n')
    # stdlib (math, re, ...) in group 2; other imports in group 3, blank line between.
    await check('import-organize-two-groups',
                'import my_module\nimport math\n',
                'import math\n\nimport my_module\n')
    # from-imports in stdlib group follow bare imports (isort convention).
    await check('import-organize-from-after-bare',
                'from re import match\nimport math\nimport my_module\n',
                'import math\nfrom re import match\n\nimport my_module\n')
    # Sorting within a group: alphabetical by module then by names.
    await check('import-organize-sort-stdlib',
                'import re\nimport math\n',
                'import math\nimport re\n')
    await check('import-organize-sort-other',
                'import zebra\nimport apple\n',
                'import apple\nimport zebra\n')
    # Only stdlib: no blank-line separator.
    await check('import-organize-stdlib-only',
                'import re\nimport math\n',
                'import math\nimport re\n')
    # Only other: no blank-line separator.
    await check('import-organize-other-only',
                'import zebra\nimport apple\n',
                'import apple\nimport zebra\n')
    # Only __python__: no blank-line separator.
    await check('import-organize-python-only',
                'from __python__ import sys\nfrom __python__ import os\n',
                'from __python__ import os\nfrom __python__ import sys\n')
    # __python__ with stdlib only: blank line between them.
    await check('import-organize-python-and-stdlib',
                'import math\nfrom __python__ import os\n',
                'from __python__ import os\n\nimport math\n')
    # __python__ with other only: blank line between them.
    await check('import-organize-python-and-other',
                'import my_module\nfrom __python__ import os\n',
                'from __python__ import os\n\nimport my_module\n')
    # Already organized: idempotent (checked automatically by check()).
    await check('import-organize-already-organized',
                'import math\n\nimport my_module\n',
                'import math\n\nimport my_module\n')
    await check('import-organize-python-already-organized',
                'from __python__ import os\n\nimport math\n\nimport my_module\n',
                'from __python__ import os\n\nimport math\n\nimport my_module\n')
    # Code after imports is untouched; blank line between imports and code is preserved.
    await check('import-organize-with-code',
                'import my_module\nimport math\n\nx = 1\n',
                'import math\n\nimport my_module\n\nx = 1\n')
    # from-imports within other group are sorted by module then names.
    await check('import-organize-from-other',
                'from zebra import z\nfrom apple import a\n',
                'from apple import a\nfrom zebra import z\n')
    # Mixed bare and from-imports across groups.
    await check('import-organize-mixed',
                'from my_mod import func\nimport re\nimport math\n',
                'import math\nimport re\n\nfrom my_mod import func\n')

    # --- collect_pyj_files (directory recursion) ----------------------------
    tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rs-fmt-'))
    fs.mkdirSync(path.join(tmp, 'sub'))
    fs.writeFileSync(path.join(tmp, 'a.pyj'), 'x=1\n')
    fs.writeFileSync(path.join(tmp, 'sub', 'b.pyj'), 'y=2\n')
    fs.writeFileSync(path.join(tmp, 'c.txt'), 'not rapydscript\n')
    collected = await fmt.collect_pyj_files([tmp])
    assrt.equal(collected.length, 2, 'collect_pyj_files must recurse and find 2 .pyj files')
    for f in collected:
        assrt.ok(f.indexOf('.txt') < 0, 'collect_pyj_files must skip non .pyj files in directories')
    # An explicitly named non .pyj file is still included.
    collected2 = await fmt.collect_pyj_files([path.join(tmp, 'c.txt')])
    assrt.equal(collected2.length, 1, 'explicitly named files are always included')
    fs.rmSync(tmp, {'recursive': True})

__test_async_done__ = run_tests()
