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

# Tests for the "lsp" sub-command (tools/lsp.mjs), the RapydScript language
# server. These drive the pure analysis functions directly (the same functions
# the JSON-RPC layer calls) plus the parser's error-recovery mode.

lsp = require('./lsp.mjs')
path = require('path')
os = require('os')
url = require('url')

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

def uri_of(p):
    return url.pathToFileURL(p).href

def offset_of(text, needle, from_index):
    return text.indexOf(needle, from_index or 0)

def make_ctx(tmp):
    return lsp.create_server_context({
        'import_dirs': [tmp], 'libdir': libdir, 'workspace_roots': [tmp],
        'line_length': 80, 'preferred_quote': 'single',
    })

def write_workspace():
    tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rs-lsp-'))
    fs.writeFileSync(path.join(tmp, 'util.pyj'),
        'def helper(x):\n    return x + 1\n\nclass Widget:\n    def __init__(self):\n        self.n = 0\n')
    fs.writeFileSync(path.join(tmp, 'main.pyj'),
        'from util import helper\nimport util\n\ndef run():\n    w = util.Widget()\n    return helper(w)\n\nunused_var = 5\nmissing = undefined_thing()\nimport nonexistent_module\n')
    return tmp

def codes(diags):
    ans = {}
    for d in diags:
        ans[d.code] = (ans[d.code] or 0) + 1
    return ans

async def test_diagnostics():
    tmp = write_workspace()
    ctx = make_ctx(tmp)
    main_uri = uri_of(path.join(tmp, 'main.pyj'))
    text = fs.readFileSync(path.join(tmp, 'main.pyj'), 'utf-8')
    diags = await lsp.compute_diagnostics(ctx, main_uri, text)
    c = codes(diags)
    assrt.ok(c['undef'], 'expected an undef diagnostic for undefined_thing')
    assrt.ok(c['unused-import'], 'expected unused-import for nonexistent_module')
    assrt.ok(c['import-unresolved'], 'expected import-unresolved for nonexistent_module')
    fs.rmSync(tmp, {'recursive': True})

async def test_globals_directive_suppresses_undef():
    tmp = write_workspace()
    ctx = make_ctx(tmp)
    uri = uri_of(path.join(tmp, 'globals_test.pyj'))
    # ext_func is declared via # globals: so it must not produce an undef diagnostic.
    text = '# globals: ext_func\n\nresult = ext_func(42)\n'
    diags = await lsp.compute_diagnostics(ctx, uri, text)
    c = codes(diags)
    assrt.ok(not c['undef'], 'ext_func declared in # globals: must not be flagged as undef')
    # A name NOT in # globals: must still be flagged.
    text2 = '# globals: ext_func\n\nresult = ext_func(42)\nbad = undeclared_thing()\n'
    diags2 = await lsp.compute_diagnostics(ctx, uri, text2)
    c2 = codes(diags2)
    assrt.ok(c2['undef'], 'undeclared_thing not in # globals: must still be flagged as undef')
    fs.rmSync(tmp, {'recursive': True})

async def test_hover_and_definition():
    tmp = write_workspace()
    ctx = make_ctx(tmp)
    main_uri = uri_of(path.join(tmp, 'main.pyj'))
    util_uri = uri_of(path.join(tmp, 'util.pyj'))
    text = fs.readFileSync(path.join(tmp, 'main.pyj'), 'utf-8')
    off = offset_of(text, 'helper(w)')
    h = await lsp.hover(ctx, main_uri, text, off)
    assrt.ok(h and h.contents.value.indexOf('helper') >= 0, 'hover should mention helper')

    defs = await lsp.definition(ctx, main_uri, text, off)
    assrt.ok(Array.isArray(defs) and defs.length >= 1, 'definition should return locations')
    assrt.equal(defs[0].uri, util_uri, 'definition of helper should be in util.pyj')
    assrt.equal(defs[0].range.start.line, 0, 'helper is defined on line 0 of util.pyj')
    fs.rmSync(tmp, {'recursive': True})

async def test_cross_file_references():
    tmp = write_workspace()
    ctx = make_ctx(tmp)
    main_uri = uri_of(path.join(tmp, 'main.pyj'))
    util_uri = uri_of(path.join(tmp, 'util.pyj'))
    text = fs.readFileSync(path.join(tmp, 'main.pyj'), 'utf-8')
    off = offset_of(text, 'helper(w)')
    refs = await lsp.references(ctx, main_uri, text, off, True)
    uris = {}
    for r in refs:
        uris[r.uri] = True
    assrt.ok(uris[main_uri], 'references should include occurrences in main.pyj')
    assrt.ok(uris[util_uri], 'references should include the definition file util.pyj')
    assrt.ok(refs.length >= 3, 'expected at least 3 references to helper across files')
    fs.rmSync(tmp, {'recursive': True})

async def test_cross_file_rename():
    tmp = write_workspace()
    ctx = make_ctx(tmp)
    main_uri = uri_of(path.join(tmp, 'main.pyj'))
    util_uri = uri_of(path.join(tmp, 'util.pyj'))
    text = fs.readFileSync(path.join(tmp, 'main.pyj'), 'utf-8')
    off = offset_of(text, 'helper(w)')
    we = await lsp.rename(ctx, main_uri, text, off, 'compute')
    assrt.ok(we and we.changes, 'rename should return a workspace edit')
    assrt.ok(we.changes[main_uri] and we.changes[main_uri].length >= 2, 'main.pyj should have >= 2 edits')
    assrt.ok(we.changes[util_uri] and we.changes[util_uri].length >= 1, 'util.pyj should have the definition edit')
    for e in we.changes[util_uri]:
        assrt.equal(e.newText, 'compute', 'edit should insert the new name')
    fs.rmSync(tmp, {'recursive': True})

async def test_invalid_rename():
    tmp = write_workspace()
    ctx = make_ctx(tmp)
    main_uri = uri_of(path.join(tmp, 'main.pyj'))
    text = fs.readFileSync(path.join(tmp, 'main.pyj'), 'utf-8')
    off = offset_of(text, 'helper(w)')
    threw = False
    try:
        await lsp.rename(ctx, main_uri, text, off, 'not a name')
    except:
        threw = True
    assrt.ok(threw, 'renaming to an invalid identifier must be rejected')
    fs.rmSync(tmp, {'recursive': True})

async def test_local_rename():
    tmp = write_workspace()
    ctx = make_ctx(tmp)
    uri = uri_of(path.join(tmp, 'local.pyj'))
    text = 'def f():\n    total = 0\n    total = total + 1\n    return total\n'
    we = await lsp.rename(ctx, uri, text, offset_of(text, 'return total') + 'return '.length, 'sum')
    assrt.ok(we and we.changes and we.changes[uri], 'local rename should return edits')
    assrt.equal(we.changes[uri].length, 4, 'total is used 4 times (1 def + 3 refs)')
    fs.rmSync(tmp, {'recursive': True})

async def test_completion():
    tmp = write_workspace()
    ctx = make_ctx(tmp)
    main_uri = uri_of(path.join(tmp, 'main.pyj'))
    text = fs.readFileSync(path.join(tmp, 'main.pyj'), 'utf-8')
    # After "util." we should be offered the module's exports.
    dot_off = offset_of(text, 'util.Widget') + 'util.'.length
    items = await lsp.completions(ctx, main_uri, text, dot_off)
    labels = [it.label for it in items]
    assrt.ok(labels.indexOf('Widget') >= 0, 'member completion should include Widget')
    assrt.ok(labels.indexOf('helper') >= 0, 'member completion should include helper')

    # Bare completion should include in-scope symbols, builtins and keywords.
    bare = await lsp.completions(ctx, main_uri, text, offset_of(text, 'unused_var'))
    blabels = [it.label for it in bare]
    assrt.ok(blabels.indexOf('run') >= 0, 'completion should include the top-level function run')
    assrt.ok(blabels.indexOf('print') >= 0, 'completion should include builtins')
    fs.rmSync(tmp, {'recursive': True})

async def test_formatting():
    ctx = lsp.create_server_context({'line_length': 80, 'preferred_quote': 'single'})
    edits = lsp.format_document(ctx, 'x=1;y=2\n')
    assrt.equal(edits.length, 1, 'formatting a mis-spaced document should yield one edit')
    assrt.equal(edits[0].newText, 'x = 1; y = 2\n', 'formatter should normalize spacing')
    # An already-formatted document yields no edits.
    assrt.equal(lsp.format_document(ctx, 'x = 1\n').length, 0, 'well formatted input needs no edits')

async def test_error_recovery_still_analyzes():
    tmp = write_workspace()
    ctx = make_ctx(tmp)
    uri = uri_of(path.join(tmp, 'broke.pyj'))
    # alpha() has a syntax error but beta() must still be analyzable.
    text = 'def alpha():\n    x = \n    return x\n\ndef beta():\n    y = 1\n    return y + y\n'
    diags = await lsp.compute_diagnostics(ctx, uri, text)
    assrt.ok(codes(diags)['syntax-err'], 'a syntax error should be reported')
    off = offset_of(text, 'return y + y') + 'return '.length
    h = await lsp.hover(ctx, uri, text, off)
    assrt.ok(h and h.contents.value.indexOf('y') >= 0, 'hover must work in the healthy part of a broken file')
    fs.rmSync(tmp, {'recursive': True})

async def test_parser_recovery_flag():
    # The recover_errors parser option must never regress the normal path: a
    # broken file throws without it and yields recovered_errors with it.
    code = 'def good():\n    return 1\n\ndef bad(:\n    pass\n\nx = 2\n'
    threw = False
    try:
        await RapydScript.parse(code, {'filename': 'r.pyj', 'basedir': test_path, 'libdir': libdir, 'for_linting': True})
    except:
        threw = True
    assrt.ok(threw, 'normal parse must still throw on syntax errors (no regression)')

    top = await RapydScript.parse(code, {'filename': 'r.pyj', 'basedir': test_path, 'libdir': libdir, 'for_linting': True, 'recover_errors': True})
    assrt.ok(top, 'recover_errors parse should return a toplevel')
    assrt.ok(top.recovered_errors and top.recovered_errors.length >= 1, 'recovered_errors should be populated')

async def test_configuration_change():
    ctx = lsp.create_server_context({'line_length': 80, 'preferred_quote': 'single', 'import_dirs': []})

    # Basic updates.
    lsp.apply_configuration(ctx, {'lineLength': 120, 'preferredQuote': 'double', 'importPath': '/tmp/mylibs'})
    assrt.equal(ctx.line_length, 120, 'lineLength should update line_length')
    assrt.equal(ctx.preferred_quote, 'double', 'preferredQuote should update preferred_quote')
    assrt.ok(ctx.import_dirs.length > 0, 'string importPath should populate import_dirs')

    # Array-form importPath.
    lsp.apply_configuration(ctx, {'importPath': ['/tmp/lib1', '/tmp/lib2']})
    assrt.equal(ctx.import_dirs.length, 2, 'array importPath should yield 2 entries')

    # Invalid values must be silently ignored.
    lsp.apply_configuration(ctx, {'lineLength': 'bogus', 'preferredQuote': 'wrong'})
    assrt.equal(ctx.line_length, 120, 'bogus lineLength must be ignored')
    assrt.equal(ctx.preferred_quote, 'double', 'invalid preferredQuote must be ignored')

    # Empty/absent settings must be a no-op.
    lsp.apply_configuration(ctx, {})
    assrt.equal(ctx.line_length, 120, 'empty settings must not reset line_length')

    # Numeric lineLength (number type, not string).
    lsp.apply_configuration(ctx, {'lineLength': 100})
    assrt.equal(ctx.line_length, 100, 'numeric lineLength should be accepted')

    # Empty importPath string clears the dirs.
    lsp.apply_configuration(ctx, {'importPath': ''})
    assrt.equal(ctx.import_dirs.length, 0, 'empty importPath string should clear import_dirs')

async def test_organize_imports():
    tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rs-lsp-org-'))
    ctx = lsp.create_server_context({
        'import_dirs': [tmp], 'libdir': libdir, 'workspace_roots': [tmp],
        'line_length': 80, 'preferred_quote': 'single',
    })
    # Unorganized: stdlib after other -> edits are produced.
    unorganized = 'import my_module\nimport math\n\nx = 1\n'
    edits = lsp.organize_imports_document(ctx, unorganized)
    assrt.ok(Array.isArray(edits) and edits.length > 0,
             'organize_imports_document must return edits for unorganized imports')
    assrt.ok(edits[0].newText.indexOf('import math') < edits[0].newText.indexOf('import my_module'),
             'stdlib import (math) must come before other import in organized output')
    assrt.ok(edits[0].newText.indexOf('\n\n') >= 0,
             'organized output must have a blank line between stdlib and other groups')
    # from __python__ import ... goes first, before stdlib.
    with_python = 'import math\nfrom __python__ import os\n\nx = 1\n'
    edits_py = lsp.organize_imports_document(ctx, with_python)
    assrt.ok(Array.isArray(edits_py) and edits_py.length > 0,
             'organize_imports_document must return edits when __python__ import is out of place')
    assrt.ok(edits_py[0].newText.indexOf('from __python__') < edits_py[0].newText.indexOf('import math'),
             '__python__ group must come before stdlib group in organized output')
    # Already organized: no edits.
    organized = 'import math\n\nimport my_module\n\nx = 1\n'
    edits2 = lsp.organize_imports_document(ctx, organized)
    assrt.equal(edits2.length, 0,
                'organize_imports_document must return no edits for already-organized imports')
    # No imports: no edits.
    no_imports = 'x = 1\ny = 2\n'
    edits3 = lsp.organize_imports_document(ctx, no_imports)
    assrt.equal(edits3.length, 0,
                'organize_imports_document must return no edits when there are no imports')
    fs.rmSync(tmp, {'recursive': True})

async def run_tests():
    await test_diagnostics()
    await test_hover_and_definition()
    await test_cross_file_references()
    await test_cross_file_rename()
    await test_invalid_rename()
    await test_local_rename()
    await test_completion()
    await test_formatting()
    await test_error_recovery_still_analyzes()
    await test_parser_recovery_flag()
    await test_configuration_change()
    await test_organize_imports()

__test_async_done__ = run_tests()
