import { describe, expect, it } from 'vitest';

import type { Parameter } from '#types';

import { createCodeNodeScaffold } from './codeNodeScaffold';
import { getAvailableNodes } from './nodePalette';
import {
  analyzeCodeNodeOutputs,
  reconcileInferredCodeNodeOutputs,
  syncCodeNodeInputParameters,
  syncCodeNodeOutputDefinitions,
} from './codeNodeSync';

describe('code node scaffold', () => {
  it('is attached to newly created code nodes', () => {
    const codeNode = getAvailableNodes().find(
      (node) => node.type === 'codeNode',
    );

    expect(codeNode?.extra).toMatchObject({
      code: createCodeNodeScaffold('js'),
      codeScaffoldManaged: true,
      codeScaffoldVersion: 2,
      engine: 'js',
      mainArgsMode: 'named',
    });
  });

  it('creates a javascript main scaffold without an explicit call', () => {
    expect(
      createCodeNodeScaffold('js', [{ name: 'data' }], [{ name: 'result' }]),
    ).toBe(
      [
        'function main(data) {',
        '  return {',
        '    "result": null,',
        '  };',
        '}',
      ].join('\n'),
    );
  });

  it('creates a python main scaffold without an explicit call', () => {
    expect(
      createCodeNodeScaffold(
        'python',
        [{ name: 'data' }],
        [{ name: 'result' }],
      ),
    ).toBe(
      [
        'def main(data):',
        '    return {',
        '        "result": None,',
        '    }',
      ].join('\n'),
    );
  });

  it('does not generate invalid or duplicate function parameters', () => {
    const code = createCodeNodeScaffold('python', [
      { name: 'data' },
      { name: 'class' },
      { name: 'data' },
    ]);

    expect(code).toContain('def main(data):');
    expect(code).not.toContain('_result');
  });
});

describe.each([
  {
    engine: 'js',
    emptyCode: createCodeNodeScaffold('js'),
    mainWithData: 'function main(data)',
    mainWithQuery: 'function main(query)',
    mainWithDefault: 'function main(data = null)',
    legacyCallWithData: '_result = main(data);',
    legacyCallWithQuery: '_result = main(query);',
  },
  {
    engine: 'python',
    emptyCode: createCodeNodeScaffold('python'),
    mainWithData: 'def main(data):',
    mainWithQuery: 'def main(query):',
    mainWithDefault: 'def main(data=None):',
    legacyCallWithData: '_result = main(data)',
    legacyCallWithQuery: '_result = main(query)',
  },
])('code node input sync for $engine', (fixture) => {
  it('syncs a newly configured input name to main', () => {
    const blankParameter: Parameter = {
      id: 'input-1',
      name: '',
      dataType: 'String',
    };
    const blankResult = syncCodeNodeInputParameters(
      fixture.emptyCode,
      fixture.engine,
      [],
      [blankParameter],
    );

    expect(blankResult.synced).toBe(false);
    expect(blankResult.code).toBe(fixture.emptyCode);

    const namedResult = syncCodeNodeInputParameters(
      blankResult.code,
      fixture.engine,
      blankResult.parameters,
      [{ ...blankResult.parameters[0], name: 'data' }],
    );

    expect(namedResult.synced).toBe(true);
    expect(namedResult.code).toContain(fixture.mainWithData);
    expect(namedResult.code).not.toContain('_result');
    expect(namedResult.parameters[0].codeSyncName).toBe('data');
  });

  it('keeps the previous binding while the user clears and renames it', () => {
    const initial = syncCodeNodeInputParameters(
      fixture.emptyCode,
      fixture.engine,
      [],
      [{ id: 'input-1', name: 'data', dataType: 'String' }],
    );
    const cleared = syncCodeNodeInputParameters(
      initial.code,
      fixture.engine,
      initial.parameters,
      [{ ...initial.parameters[0], name: '' }],
    );
    const renamed = syncCodeNodeInputParameters(
      cleared.code,
      fixture.engine,
      cleared.parameters,
      [{ ...cleared.parameters[0], name: 'query' }],
    );

    expect(cleared.synced).toBe(false);
    expect(cleared.parameters[0].codeSyncName).toBe('data');
    expect(renamed.synced).toBe(true);
    expect(renamed.code).toContain(fixture.mainWithQuery);
    expect(renamed.code).not.toContain('_result');
  });

  it('does not overwrite a user-managed main signature', () => {
    const initial = syncCodeNodeInputParameters(
      fixture.emptyCode,
      fixture.engine,
      [],
      [{ id: 'input-1', name: 'data' }],
    );
    const manualCode = initial.code.replace(
      fixture.mainWithData,
      fixture.engine === 'python'
        ? 'def main(custom):'
        : 'function main(custom)',
    );
    const result = syncCodeNodeInputParameters(
      manualCode,
      fixture.engine,
      initial.parameters,
      [{ ...initial.parameters[0], name: 'query' }],
    );

    expect(result.synced).toBe(false);
    expect(result.code).toBe(manualCode);
  });

  it('does not overwrite a main signature with default values', () => {
    const initial = syncCodeNodeInputParameters(
      fixture.emptyCode,
      fixture.engine,
      [],
      [{ id: 'input-1', name: 'data' }],
    );
    const manualCode = initial.code.replace(
      fixture.mainWithData,
      fixture.mainWithDefault,
    );
    const result = syncCodeNodeInputParameters(
      manualCode,
      fixture.engine,
      initial.parameters,
      [{ ...initial.parameters[0], name: 'query' }],
    );

    expect(result.synced).toBe(false);
    expect(result.code).toBe(manualCode);
  });

  it('keeps a compatible legacy explicit main call synchronized', () => {
    const initial = syncCodeNodeInputParameters(
      fixture.emptyCode,
      fixture.engine,
      [],
      [{ id: 'input-1', name: 'data' }],
    );
    const legacyCode = [initial.code, fixture.legacyCallWithData].join('\n');
    const result = syncCodeNodeInputParameters(
      legacyCode,
      fixture.engine,
      initial.parameters,
      [{ ...initial.parameters[0], name: 'query' }],
    );

    expect(result.synced).toBe(true);
    expect(result.code).toContain(fixture.mainWithQuery);
    expect(result.code).toContain(fixture.legacyCallWithQuery);
  });

  it('does not partially update multiple explicit main calls', () => {
    const initial = syncCodeNodeInputParameters(
      fixture.emptyCode,
      fixture.engine,
      [],
      [{ id: 'input-1', name: 'data' }],
    );
    const duplicatedCall = [
      initial.code,
      fixture.legacyCallWithData,
      fixture.legacyCallWithData,
    ].join('\n');
    const result = syncCodeNodeInputParameters(
      duplicatedCall,
      fixture.engine,
      initial.parameters,
      [{ ...initial.parameters[0], name: 'query' }],
    );

    expect(result.synced).toBe(false);
    expect(result.code).toBe(duplicatedCall);
  });

  it('syncs input ordering and deletion', () => {
    const initial = syncCodeNodeInputParameters(
      fixture.emptyCode,
      fixture.engine,
      [],
      [
        { id: 'input-1', name: 'data' },
        { id: 'input-2', name: 'question' },
      ],
    );
    const reordered = syncCodeNodeInputParameters(
      initial.code,
      fixture.engine,
      initial.parameters,
      [initial.parameters[1], initial.parameters[0]],
    );
    const deleted = syncCodeNodeInputParameters(
      reordered.code,
      fixture.engine,
      reordered.parameters,
      [reordered.parameters[0]],
    );

    expect(reordered.code).toContain('main(question, data)');
    expect(deleted.code).toContain('main(question)');
    expect(deleted.code).not.toContain('main(question, data)');
  });
});

describe.each([
  {
    engine: 'js',
    emptyCode: createCodeNodeScaffold('js'),
    expectedEntry: '"answer": null',
  },
  {
    engine: 'python',
    emptyCode: createCodeNodeScaffold('python'),
    expectedEntry: '"answer": None',
  },
])('code node output sync for $engine', (fixture) => {
  it('adds a configured output name to the return object', () => {
    const blankOutput: Parameter = {
      id: 'output-1',
      name: '',
      dataType: 'String',
    };
    const blankResult = syncCodeNodeOutputDefinitions(
      fixture.emptyCode,
      fixture.engine,
      [],
      [blankOutput],
    );
    const namedResult = syncCodeNodeOutputDefinitions(
      blankResult.code,
      fixture.engine,
      blankResult.outputDefs,
      [{ ...blankResult.outputDefs[0], name: 'answer' }],
    );

    expect(namedResult.synced).toBe(true);
    expect(namedResult.code).toContain(fixture.expectedEntry);
    expect(namedResult.outputDefs[0].codeSyncName).toBe('answer');
  });

  it('renames the matching return key', () => {
    const initial = syncCodeNodeOutputDefinitions(
      fixture.emptyCode,
      fixture.engine,
      [],
      [{ id: 'output-1', name: 'answer', dataType: 'String' }],
    );
    const renamed = syncCodeNodeOutputDefinitions(
      initial.code,
      fixture.engine,
      initial.outputDefs,
      [{ ...initial.outputDefs[0], name: 'message' }],
    );

    expect(renamed.synced).toBe(true);
    expect(renamed.code).toContain(
      fixture.engine === 'python' ? '"message": None' : '"message": null',
    );
    expect(renamed.code).not.toContain(fixture.expectedEntry);
  });

  it('adds the renamed key when the previous return key is absent', () => {
    const renamed = syncCodeNodeOutputDefinitions(
      fixture.emptyCode,
      fixture.engine,
      [
        {
          id: 'output-1',
          name: 'answer',
          dataType: 'String',
          codeSyncName: 'answer',
        },
      ],
      [
        {
          id: 'output-1',
          name: 'message',
          dataType: 'String',
          codeSyncName: 'answer',
        },
      ],
    );

    expect(renamed.code).toContain(
      fixture.engine === 'python' ? '"message": None' : '"message": null',
    );
  });
});

describe('code node output inference', () => {
  it.each([
    {
      engine: 'js',
      code: [
        'function main(data) {',
        '  const answer = data;',
        '  return { answer };',
        '}',
        '_result = main(data);',
      ].join('\n'),
    },
    {
      engine: 'python',
      code: [
        'def main(data):',
        '    answer = data',
        '    return {"answer": answer}',
        '_result = main(data)',
      ].join('\n'),
    },
  ])(
    'preserves the return expression when renaming for $engine',
    ({ code, engine }) => {
      const renamed = syncCodeNodeOutputDefinitions(
        code,
        engine,
        [
          {
            id: 'output-1',
            name: 'answer',
            dataType: 'String',
            codeSyncName: 'answer',
          },
        ],
        [
          {
            id: 'output-1',
            name: 'message',
            dataType: 'String',
            codeSyncName: 'answer',
          },
        ],
      );

      expect(renamed.code).toContain('"message": answer');
    },
  );

  it('infers the python return fields used by the executable example', () => {
    const code = [
      'import json',
      'def main(data):',
      '    r1 = data + "111"',
      '    r2 = "222"',
      '    return {',
      '        "r1": r1,',
      '        "r2": r2,',
      '    }',
      '_result = main(data)',
    ].join('\n');

    expect(
      analyzeCodeNodeOutputs(code, 'python', [
        { name: 'data', dataType: 'String' },
      ]),
    ).toEqual([
      { name: 'r1', dataType: 'String' },
      { name: 'r2', dataType: 'String' },
    ]);
  });

  it('infers supported python primitive and container types', () => {
    const code = [
      'def main(data, count, file):',
      '    text = f"{data}-ok"',
      '    total = count + 1',
      '    return {',
      '        "text": text,',
      '        "total": total,',
      '        "ok": True,',
      '        "file": file,',
      '        "objectValue": {},',
      '        "arrayValue": [],',
      '    }',
      '_result = main(data, count, file)',
    ].join('\n');

    expect(
      analyzeCodeNodeOutputs(code, 'python', [
        { name: 'data', dataType: 'String' },
        { name: 'count', dataType: 'Number' },
        { name: 'file', dataType: 'File' },
      ]),
    ).toEqual([
      { name: 'text', dataType: 'String' },
      { name: 'total', dataType: 'Number' },
      { name: 'ok', dataType: 'Boolean' },
      { name: 'file', dataType: 'File' },
      { name: 'objectValue', dataType: 'Object' },
      { name: 'arrayValue', dataType: 'Array' },
    ]);
  });

  it('infers supported javascript primitive and container types', () => {
    const code = [
      'function main(data, count, file) {',
      '  const text = data + "x";',
      '  const total = count + 1;',
      '  return {',
      '    text,',
      '    total,',
      '    ok: true,',
      '    file,',
      '    objectValue: {},',
      '    arrayValue: [],',
      '  };',
      '}',
      '_result = main(data, count, file);',
    ].join('\n');

    expect(
      analyzeCodeNodeOutputs(code, 'js', [
        { name: 'data', dataType: 'String' },
        { name: 'count', dataType: 'Number' },
        { name: 'file', dataType: 'File' },
      ]),
    ).toEqual([
      { name: 'text', dataType: 'String' },
      { name: 'total', dataType: 'Number' },
      { name: 'ok', dataType: 'Boolean' },
      { name: 'file', dataType: 'File' },
      { name: 'objectValue', dataType: 'Object' },
      { name: 'arrayValue', dataType: 'Array' },
    ]);
  });

  it('falls back to String for a static key with a dynamic value', () => {
    const code = [
      'function main(data) {',
      '  return { answer: externalCall(data) };',
      '}',
      '_result = main(data);',
    ].join('\n');

    expect(analyzeCodeNodeOutputs(code, 'js', [])).toEqual([
      { name: 'answer', dataType: 'String' },
    ]);
  });

  it('does not infer assignment types across complex control flow', () => {
    const code = [
      'function main(data) {',
      '  let value = 1;',
      '  if (data) {',
      '    value = true;',
      '  }',
      '  return { value };',
      '}',
      '_result = main(data);',
    ].join('\n');

    expect(analyzeCodeNodeOutputs(code, 'js', [])).toEqual([
      { name: 'value', dataType: 'String' },
    ]);
  });

  it.each([
    {
      engine: 'js',
      code: [
        'function main(data) {',
        '  return { fixed: 1, ...data };',
        '}',
        '_result = main(data);',
      ].join('\n'),
    },
    {
      engine: 'python',
      code: [
        'def main(data):',
        '    return {"fixed": 1, **data}',
        '_result = main(data)',
      ].join('\n'),
    },
  ])(
    'does not infer fields from a spread return object for $engine',
    ({ code, engine }) => {
      expect(analyzeCodeNodeOutputs(code, engine, [])).toBeNull();
    },
  );

  it('preserves manual output types and removes stale inferred fields', () => {
    const outputs = reconcileInferredCodeNodeOutputs(
      [
        {
          id: 'manualized',
          name: 'answer',
          dataType: 'Number',
          codeInferred: true,
          codeInferredDataType: 'String',
        },
        {
          id: 'stale',
          name: 'oldField',
          dataType: 'String',
          codeInferred: true,
          codeInferredDataType: 'String',
        },
      ],
      [
        { name: 'answer', dataType: 'String' },
        { name: 'newField', dataType: 'Boolean' },
      ],
      () => 'generated',
    );

    expect(outputs).toEqual([
      {
        id: 'manualized',
        name: 'answer',
        dataType: 'Number',
      },
      {
        id: 'generated',
        name: 'newField',
        dataType: 'Boolean',
        codeInferred: true,
        codeInferredDataType: 'Boolean',
        codeSyncName: 'newField',
      },
    ]);
  });
});
