// jetbrains-parser.test.mjs — Zero-dep plugin.xml + MANIFEST.MF parsers.
//
// All inputs are inline strings — no filesystem fixtures required.

import { describe, it, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, writeFile, readdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import {
  parsePluginXml,
  parseManifestMf,
  parseIntelliJPlugin,
} from '../../scanners/lib/ide-extension-parser.mjs';
import { createZip } from '../helpers/zip-writer.mjs';

describe('parsePluginXml — happy path', () => {
  const xml = `<?xml version="1.0"?>
<idea-plugin>
  <id>org.example.myplugin</id>
  <name>My Plugin</name>
  <version>1.2.3</version>
  <vendor url="https://example.com">Example Inc</vendor>
  <idea-version since-build="232.0" until-build="242.*"/>
  <depends>com.intellij.modules.platform</depends>
  <depends optional="true" config-file="python.xml">com.intellij.modules.python</depends>
  <extensions defaultExtensionNs="com.intellij">
    <applicationService serviceImplementation="org.example.Foo"/>
    <postStartupActivity implementation="org.example.Startup"/>
    <themeProvider id="my-theme" path="/themes/my.theme.json"/>
  </extensions>
</idea-plugin>`;

  it('extracts pluginId, name, version, vendor', () => {
    const { manifest, warnings } = parsePluginXml(xml);
    assert.ok(manifest, `expected manifest, got null; warnings: ${warnings.join('; ')}`);
    assert.equal(manifest.pluginId, 'org.example.myplugin');
    assert.equal(manifest.name, 'My Plugin');
    assert.equal(manifest.version, '1.2.3');
    assert.equal(manifest.vendor, 'Example Inc');
    assert.equal(manifest.vendorUrl, 'https://example.com');
  });

  it('extracts idea-version build range', () => {
    const { manifest } = parsePluginXml(xml);
    assert.equal(manifest.sinceBuild, '232.0');
    assert.equal(manifest.untilBuild, '242.*');
  });

  it('extracts depends[] with optional + config-file', () => {
    const { manifest } = parsePluginXml(xml);
    assert.equal(manifest.depends.length, 2);
    assert.deepEqual(manifest.depends[0], {
      id: 'com.intellij.modules.platform', optional: false, configFile: null,
    });
    assert.deepEqual(manifest.depends[1], {
      id: 'com.intellij.modules.python', optional: true, configFile: 'python.xml',
    });
  });

  it('captures extension children with namespace', () => {
    const { manifest } = parsePluginXml(xml);
    const names = manifest.extensionDeclarations.map(e => e.name).sort();
    assert.deepEqual(names, ['applicationService', 'postStartupActivity', 'themeProvider']);
    assert.ok(manifest.extensionDeclarations.every(e => e.namespace === 'com.intellij'));
  });

  it('collects themeProviders[] with id + path', () => {
    const { manifest } = parsePluginXml(xml);
    assert.equal(manifest.themeProviders.length, 1);
    assert.equal(manifest.themeProviders[0].id, 'my-theme');
    assert.equal(manifest.themeProviders[0].path, '/themes/my.theme.json');
  });
});

describe('parsePluginXml — CDATA + entity handling', () => {
  it('preserves CDATA content verbatim', () => {
    const xml = `<idea-plugin>
      <id>x.y</id>
      <name><![CDATA[<b>hello & world</b>]]></name>
    </idea-plugin>`;
    const { manifest } = parsePluginXml(xml);
    assert.equal(manifest.name, '<b>hello & world</b>');
  });

  it('decodes named entity refs in non-CDATA text', () => {
    const xml = `<idea-plugin>
      <id>com.intellij.java&amp;extras</id>
      <name>n</name>
    </idea-plugin>`;
    const { manifest } = parsePluginXml(xml);
    assert.equal(manifest.pluginId, 'com.intellij.java&extras');
  });

  it('decodes numeric entity refs (decimal + hex)', () => {
    const xml = `<idea-plugin><id>A&#66;C&#x44;</id><name>n</name></idea-plugin>`;
    const { manifest } = parsePluginXml(xml);
    assert.equal(manifest.pluginId, 'ABCD');
  });
});

describe('parsePluginXml — robustness', () => {
  it('parses BOM-prefixed input identically', () => {
    const xmlA = `<idea-plugin><id>a</id><name>n</name></idea-plugin>`;
    const xmlB = '\uFEFF' + xmlA;
    assert.deepEqual(parsePluginXml(xmlA).manifest, parsePluginXml(xmlB).manifest);
  });

  it('parses CRLF identically to LF', () => {
    const xmlLF = `<idea-plugin>\n<id>a</id>\n<name>n</name>\n</idea-plugin>`;
    const xmlCRLF = xmlLF.replace(/\n/g, '\r\n');
    assert.deepEqual(parsePluginXml(xmlLF).manifest, parsePluginXml(xmlCRLF).manifest);
  });

  it('strips XML comments before regex match', () => {
    const xml = `<idea-plugin>
      <!-- <id>fake.id</id> -->
      <id>real.id</id>
      <name>n</name>
    </idea-plugin>`;
    const { manifest } = parsePluginXml(xml);
    assert.equal(manifest.pluginId, 'real.id');
  });

  it('non-string input returns null + warning (never throws)', () => {
    const { manifest, warnings } = parsePluginXml(null);
    assert.equal(manifest, null);
    assert.ok(warnings.length > 0);
  });

  it('truncated input returns null + warning (never throws)', () => {
    const xml = `<idea-plugin><id>a</id><name>n</name`;  // truncated
    const { manifest, warnings } = parsePluginXml(xml);
    assert.equal(manifest, null);
    assert.ok(warnings.length > 0);
  });

  it('unknown namespace on <extensions> is preserved', () => {
    const xml = `<idea-plugin>
      <id>a</id><name>n</name>
      <extensions defaultExtensionNs="org.custom">
        <myService key="x"/>
      </extensions>
    </idea-plugin>`;
    const { manifest } = parsePluginXml(xml);
    assert.equal(manifest.extensionDeclarations.length, 1);
    assert.equal(manifest.extensionDeclarations[0].namespace, 'org.custom');
    assert.equal(manifest.extensionDeclarations[0].name, 'myService');
  });

  it('captures legacy application-components', () => {
    const xml = `<idea-plugin>
      <id>a</id><name>n</name>
      <application-components>
        <component>
          <implementation-class>com.bad.Comp</implementation-class>
        </component>
      </application-components>
    </idea-plugin>`;
    const { manifest } = parsePluginXml(xml);
    assert.deepEqual(manifest.applicationComponents, ['com.bad.Comp']);
  });

  it('captures applicationListener topic + class', () => {
    const xml = `<idea-plugin>
      <id>a</id><name>n</name>
      <applicationListener topic="com.intellij.ide.AppLifecycleListener" class="org.x.Listener"/>
    </idea-plugin>`;
    const { manifest } = parsePluginXml(xml);
    assert.equal(manifest.listeners.length, 1);
    assert.equal(manifest.listeners[0].topic, 'com.intellij.ide.AppLifecycleListener');
    assert.equal(manifest.listeners[0].class, 'org.x.Listener');
  });
});

describe('parseManifestMf', () => {
  it('extracts Main-Class, Premain-Class, Implementation-Title/Version', () => {
    const mf = [
      'Manifest-Version: 1.0',
      'Main-Class: org.example.Main',
      'Premain-Class: org.bad.Agent',
      'Implementation-Title: my-plugin',
      'Implementation-Version: 1.0.0',
      '',
    ].join('\n');
    const out = parseManifestMf(mf);
    assert.equal(out.mainClass, 'org.example.Main');
    assert.equal(out.premainClass, 'org.bad.Agent');
    assert.equal(out.implTitle, 'my-plugin');
    assert.equal(out.implVersion, '1.0.0');
  });

  it('collects Premain-/Agent-/Can- attrs into premainAttrs', () => {
    const mf = [
      'Premain-Class: org.bad.Agent',
      'Can-Redefine-Classes: true',
      'Can-Retransform-Classes: true',
      'Agent-Class: org.bad.Agent',
      'Boot-Class-Path: boot.jar',
      '',
    ].join('\n');
    const out = parseManifestMf(mf);
    assert.equal(out.premainAttrs['Can-Redefine-Classes'], 'true');
    assert.equal(out.premainAttrs['Can-Retransform-Classes'], 'true');
    assert.equal(out.premainAttrs['Agent-Class'], 'org.bad.Agent');
    assert.equal(out.premainAttrs['Boot-Class-Path'], 'boot.jar');
  });

  it('handles 72-char continuation lines (space-prefixed)', () => {
    const mf = [
      'Premain-Class: org.example.VeryLongPackage',
      ' Name.ContinuedAgent',
      '',
    ].join('\n');
    const out = parseManifestMf(mf);
    assert.equal(out.premainClass, 'org.example.VeryLongPackageName.ContinuedAgent');
  });

  it('handles tab continuation (rare but legal)', () => {
    const mf = 'Main-Class: org.a\n\tTail\n';
    const out = parseManifestMf(mf);
    assert.equal(out.mainClass, 'org.aTail');
  });

  it('empty input returns all-null without throwing', () => {
    const out = parseManifestMf('');
    assert.equal(out.mainClass, null);
    assert.equal(out.premainClass, null);
    assert.deepEqual(out.premainAttrs, {});
  });

  it('non-string input returns all-null without throwing', () => {
    const out = parseManifestMf(null);
    assert.equal(out.mainClass, null);
  });

  it('garbage input returns all-null without throwing', () => {
    const out = parseManifestMf('lkajsdf qwertyui 12345\n!!!\n');
    assert.equal(out.mainClass, null);
    assert.equal(out.premainClass, null);
  });

  it('CRLF input parses identically to LF', () => {
    const lf = 'Main-Class: org.a\nPremain-Class: org.b\n';
    const crlf = lf.replace(/\n/g, '\r\n');
    assert.deepEqual(parseManifestMf(lf), parseManifestMf(crlf));
  });
});

// ---------------------------------------------------------------------------
// parseIntelliJPlugin — synthetic plugin dirs built in-test via zip-writer
// ---------------------------------------------------------------------------

const TEST_TMP_PREFIX = 'llmsec-jbparse-test-';
const createdRoots = [];

async function makePluginDir(jars) {
  const root = await mkdtemp(join(tmpdir(), TEST_TMP_PREFIX));
  createdRoots.push(root);
  await mkdir(join(root, 'lib'), { recursive: true });
  for (const { name, entries } of jars) {
    const buf = createZip(entries);
    await writeFile(join(root, 'lib', name), buf);
  }
  return root;
}

const BENIGN_PLUGIN_XML = `<?xml version="1.0"?>
<idea-plugin>
  <id>com.example.benign</id>
  <name>Benign</name>
  <version>1.0</version>
  <vendor>Example</vendor>
</idea-plugin>`;

describe('parseIntelliJPlugin — benign synthetic plugin', () => {
  it('extracts pluginId, depends, no native/premain/signature', async () => {
    const root = await makePluginDir([
      {
        name: 'main.jar',
        entries: [
          { name: 'META-INF/plugin.xml', data: BENIGN_PLUGIN_XML },
          { name: 'META-INF/MANIFEST.MF', data: 'Manifest-Version: 1.0\n' },
        ],
      },
    ]);
    const res = await parseIntelliJPlugin(root);
    assert.ok(res, 'expected non-null result');
    assert.ok(res.manifest, 'expected manifest');
    assert.equal(res.manifest.type, 'jetbrains');
    assert.equal(res.manifest.pluginId, 'com.example.benign');
    assert.equal(res.manifest.nativeBinaries.length, 0);
    assert.equal(res.manifest.hasPremainClass, false);
    assert.equal(res.manifest.hasSignature, false);
    assert.ok(Array.isArray(res.manifest.bundledJars));
    assert.equal(res.manifest.bundledJars.length, 1);
  });
});

describe('parseIntelliJPlugin — Premain-Class detection', () => {
  it('hasPremainClass === true when MANIFEST.MF sets it', async () => {
    const root = await makePluginDir([
      {
        name: 'main.jar',
        entries: [
          { name: 'META-INF/plugin.xml', data: BENIGN_PLUGIN_XML },
          {
            name: 'META-INF/MANIFEST.MF',
            data: 'Manifest-Version: 1.0\nPremain-Class: com.example.Agent\n',
          },
        ],
      },
    ]);
    const res = await parseIntelliJPlugin(root);
    assert.equal(res.manifest.hasPremainClass, true);
    assert.equal(res.manifest.premainClass, 'com.example.Agent');
  });
});

describe('parseIntelliJPlugin — native binary detection', () => {
  it('collects .so files with SHA-256 and size', async () => {
    const nativeBytes = Buffer.alloc(16, 0xAB);
    const expectedSha = createHash('sha256').update(nativeBytes).digest('hex');
    const root = await makePluginDir([
      {
        name: 'main.jar',
        entries: [
          { name: 'META-INF/plugin.xml', data: BENIGN_PLUGIN_XML },
          { name: 'native/dummy.so', data: nativeBytes },
        ],
      },
    ]);
    const res = await parseIntelliJPlugin(root);
    assert.equal(res.manifest.nativeBinaries.length, 1);
    assert.equal(res.manifest.nativeBinaries[0].size, 16);
    assert.equal(res.manifest.nativeBinaries[0].sha256, expectedSha);
  });
});

describe('parseIntelliJPlugin — failure modes', () => {
  it('missing lib/ returns null with IDE-JB-NO-LIB-DIR warning', async () => {
    const root = await mkdtemp(join(tmpdir(), TEST_TMP_PREFIX));
    createdRoots.push(root);
    const res = await parseIntelliJPlugin(root);
    assert.equal(res.manifest, null);
    assert.ok(res.warnings.some(w => w.startsWith('IDE-JB-NO-LIB-DIR')));
  });

  it('no plugin.xml in any jar returns null with IDE-JB-NO-PLUGIN-XML', async () => {
    const root = await makePluginDir([
      {
        name: 'empty.jar',
        entries: [
          { name: 'META-INF/MANIFEST.MF', data: 'Manifest-Version: 1.0\n' },
        ],
      },
    ]);
    const res = await parseIntelliJPlugin(root);
    assert.equal(res.manifest, null);
    assert.ok(res.warnings.some(w => w.includes('NO-PLUGIN-XML')));
  });
});

describe('parseIntelliJPlugin — temp dir cleanup', () => {
  after(async () => {
    // Cleanup test plugin roots
    for (const r of createdRoots) {
      await rm(r, { recursive: true, force: true }).catch(() => {});
    }
    // Assert no llmsec-jb-* temp dirs remain
    const entries = await readdir(tmpdir()).catch(() => []);
    const leaked = entries.filter(n => n.startsWith('llmsec-jb-'));
    assert.equal(leaked.length, 0, `leaked temp dirs: ${leaked.join(', ')}`);
  });

  it('cleanup runs (checked via after hook)', () => {
    assert.ok(true);
  });
});
