import { expect } from 'chai';
import LdapOrganizations from '../../../src/plugins/ldap/organizations';
import { DM } from '../../../src/bin';
import supertest from 'supertest';
import type { SearchResult } from 'ldapts';
import {
  skipIfMissingEnvVars,
  LDAP_ENV_VARS_WITH_ORG,
} from '../../helpers/env';

describe('LDAP Organizations Plugin', function () {
  before(function () {
    skipIfMissingEnvVars(this, [...LDAP_ENV_VARS_WITH_ORG]);
  });

  let server: DM;
  let plugin: LdapOrganizations;
  let request: any;
  let testOrgDn: string;
  let testSubOrgDn: string;
  let DM_LDAP_TOP_ORGANIZATION: string | undefined;
  let DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE: string | undefined;
  let DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE: string | undefined;

  before(async () => {
    // Initialize from env vars after they're set
    DM_LDAP_TOP_ORGANIZATION = process.env.DM_LDAP_TOP_ORGANIZATION;
    DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE =
      process.env.DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE;
    DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE =
      process.env.DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE;
    testOrgDn = `ou=testorg,${DM_LDAP_TOP_ORGANIZATION}`;
    testSubOrgDn = `ou=testsuborg,${DM_LDAP_TOP_ORGANIZATION}`;
    server = new DM();
    await server.ready;
    plugin = new LdapOrganizations(server);
    await server.registerPlugin('ldapOrganizations', plugin);
    plugin.api(server.app);
    request = supertest(server.app);
  });

  afterEach(async () => {
    // Clean up test organizations
    try {
      await plugin.server.ldap.delete(testOrgDn);
    } catch (e) {
      // ignore
    }
    try {
      await plugin.server.ldap.delete(testSubOrgDn);
    } catch (e) {
      // ignore
    }
  });

  describe('constructor', () => {
    it('should throw error when ldap_top_organization is missing', () => {
      const tempServer = new DM();
      const originalConfig = tempServer.config.ldap_top_organization;
      delete tempServer.config.ldap_top_organization;
      expect(() => new LdapOrganizations(tempServer)).to.throw(
        'Missing --ldap-top-organization'
      );
      tempServer.config.ldap_top_organization = originalConfig;
    });

    it('should set pathAttr and linkAttr from config', () => {
      expect(plugin.pathAttr).to.equal(
        server.config.ldap_organization_path_attribute
      );
      expect(plugin.linkAttr).to.equal(
        server.config.ldap_organization_link_attribute
      );
    });
  });

  describe('isOu', () => {
    it('should return true for organizational unit entries', () => {
      const entry = {
        objectClass: ['twakedepartment', 'organizationalunit', 'top'],
        ou: 'testorg',
      };
      expect(plugin.isOu(entry)).to.be.true;
    });

    it('should return false for non-organizational entries', () => {
      const entry = {
        objectClass: ['inetOrgPerson', 'top'],
        cn: 'Test User',
      };
      expect(plugin.isOu(entry)).to.be.false;
    });

    it('should ignore "top" objectClass', () => {
      const entry = {
        objectClass: ['top'],
        cn: 'Test',
      };
      expect(plugin.isOu(entry)).to.be.false;
    });
  });

  describe('getOrganisationTop', () => {
    it('should return top organization', async () => {
      const top = await plugin.getOrganisationTop();
      if (Array.isArray(top)) {
        expect(top.length).to.be.greaterThan(0);
        expect(top[0]).to.have.property('dn');
      } else {
        expect(top).to.have.property('dn');
        expect((top.dn as string).toLowerCase()).to.equal(
          DM_LDAP_TOP_ORGANIZATION?.toLowerCase()
        );
      }
    });

    it('should throw error when top organization not configured', async () => {
      const tempServer = new DM();
      delete tempServer.config.ldap_top_organization;
      try {
        const tempPlugin = new LdapOrganizations(tempServer);
        await tempPlugin.getOrganisationTop();
        expect.fail('Should have thrown error');
      } catch (e) {
        expect((e as Error).message).to.match(
          /Missing --ldap-top-organization|No top organization configured/
        );
      }
    });
  });

  describe('getOrganisationByDn', () => {
    beforeEach(async () => {
      // Create test organization
      await plugin.server.ldap.add(testOrgDn, {
        objectClass: ['organizationalUnit', 'top'],
        ou: 'testorg',
      });
    });

    it('should return organization by DN', async () => {
      const org = await plugin.getOrganisationByDn(testOrgDn);
      expect(org).to.have.property('dn', testOrgDn);
      expect(org).to.have.property('ou');
    });

    it('should throw error for non-existent organization', async () => {
      try {
        await plugin.getOrganisationByDn(
          `ou=nonexistent,${DM_LDAP_TOP_ORGANIZATION}`
        );
        expect.fail('Should have thrown error');
      } catch (e) {
        expect((e as Error).message).to.match(/not found|Code: 0x20/);
      }
    });
  });

  describe('getOrganisationSubnodes', () => {
    it('should return empty array when no subnodes exist', async function () {
      // Create test organization
      await plugin.server.ldap.add(testOrgDn, {
        objectClass: ['organizationalUnit', 'top'],
        ou: 'testorg',
      });

      const subnodes = await plugin.getOrganisationSubnodes(testOrgDn);
      expect(subnodes).to.be.an('array').that.is.empty;
    });

    it('should return empty array when no entries link to organization', async () => {
      // Create parent organization
      await plugin.server.ldap.add(testOrgDn, {
        objectClass: ['organizationalUnit', 'top'],
        ou: 'testorg',
      });

      // Since twakeDepartmentLink may not exist in schema,
      // verify that getOrganisationSubnodes returns empty array
      // when no entries have this attribute pointing to the org
      const subnodes = await plugin.getOrganisationSubnodes(testOrgDn);
      expect(subnodes).to.be.an('array').that.is.empty;
    });
  });

  describe('checkDeptLink', () => {
    beforeEach(async () => {
      // Create test organization
      await plugin.server.ldap.add(testOrgDn, {
        objectClass: ['organizationalUnit', 'top'],
        ou: 'testorg',
      });
    });

    it('should accept valid organization link', async () => {
      const linkAttr = server.config.ldap_organization_link_attribute as string;
      const entry = {
        [linkAttr]: [testOrgDn],
      };
      await plugin.checkDeptLink(entry);
    });

    it('should reject non-existent organization link', async function () {
      const linkAttr = server.config.ldap_organization_link_attribute as string;
      const entry = {
        [linkAttr]: [`ou=nonexistent,${DM_LDAP_TOP_ORGANIZATION}`],
      };
      try {
        await plugin.checkDeptLink(entry);
        expect.fail('Should have thrown error');
      } catch (e) {
        // Accept both "does not exist" and LDAP error codes for non-existent entries
        expect((e as Error).message).to.match(/does not exist|Code: 0x20/);
      }
    });

    it('should allow entry without organization link', async () => {
      const entry = { cn: 'test' };
      await plugin.checkDeptLink(entry);
    });
  });

  describe('checkDeptPath', () => {
    it('should allow entry without organization path', async () => {
      const entry = { cn: 'test' };
      await plugin.checkDeptPath(entry);
    });
  });

  describe('isEmptyOrganization', () => {
    beforeEach(async () => {
      await plugin.server.ldap.add(testOrgDn, {
        objectClass: ['organizationalUnit', 'top'],
        ou: 'testorg',
      });
    });

    it('should not throw for empty organization', async () => {
      await plugin.isEmptyOrganization(testOrgDn);
    });

    it('should not throw when organization has no linked entries', async () => {
      // Since twakeDepartmentLink attribute may not exist in schema,
      // this test verifies that isEmptyOrganization correctly identifies
      // an organization as empty when no entries link to it
      await plugin.isEmptyOrganization(testOrgDn);
      // If we get here without exception, the organization is correctly identified as empty
    });
  });

  describe('hooks', () => {
    describe('ldapaddrequest', () => {
      it('should validate organization link on add', async () => {
        const linkAttr = server.config
          .ldap_organization_link_attribute as string;
        const entry = {
          [linkAttr]: [`ou=nonexistent,${DM_LDAP_TOP_ORGANIZATION}`],
        };
        const dn = `uid=testuser3,${process.env.DM_LDAP_BASE}`;

        try {
          await plugin.hooks.ldapaddrequest?.([dn, entry]);
          expect.fail('Should have thrown error');
        } catch (e) {
          // Accept both "does not exist" and LDAP error codes
          expect((e as Error).message).to.match(/does not exist|Code: 0x20/);
        }
      });

      it('should allow add without organization attributes', async () => {
        const entry = { cn: 'test' };
        const dn = `cn=test,${process.env.DM_LDAP_BASE}`;

        const result = await plugin.hooks.ldapaddrequest?.([dn, entry]);
        expect(result).to.deep.equal([dn, entry]);
      });
    });

    describe('ldapmodifyrequest', () => {
      it('should prevent deletion of organization link attribute for users/groups', async () => {
        // Create a test user first
        const userDn = `uid=testuser,${process.env.DM_LDAP_BASE}`;
        await plugin.server.ldap.add(userDn, {
          objectClass: ['inetOrgPerson', 'person', 'top'],
          uid: 'testuser',
          cn: 'Test User',
          sn: 'User',
        });

        const linkAttr = server.config
          .ldap_organization_link_attribute as string;
        const changes = {
          delete: [linkAttr],
        };

        try {
          await plugin.hooks.ldapmodifyrequest?.([userDn, changes, 0]);
          expect.fail('Should have thrown error');
        } catch (e) {
          expect((e as Error).message).to.match(
            /organization link cannot be deleted/
          );
        }

        // Clean up
        await plugin.server.ldap.delete(userDn);
      });

      it('should prevent deletion of organization path attribute', async () => {
        // Create organization first
        await plugin.server.ldap.add(testOrgDn, {
          objectClass: ['organizationalUnit', 'top'],
          ou: 'testorg',
        });

        const pathAttr = server.config
          .ldap_organization_path_attribute as string;
        const dn = testOrgDn;
        const changes = {
          delete: [pathAttr],
        };

        try {
          await plugin.hooks.ldapmodifyrequest?.([dn, changes, 0]);
          expect.fail('Should have thrown error');
        } catch (e) {
          expect((e as Error).message).to.match(
            /organization path cannot be deleted/
          );
        }

        // Clean up
        await plugin.server.ldap.delete(testOrgDn);
      });

      it('should allow modification without organization attributes', async () => {
        const dn = testOrgDn;
        const changes = { replace: { description: 'test' } };

        const result = await plugin.hooks.ldapmodifyrequest?.([dn, changes, 0]);
        expect(result).to.deep.equal([dn, changes, 0]);
      });
    });

    describe('ldapdeleterequest', () => {
      it('should allow deletion when no entries link to organization', async () => {
        // Create organization
        await plugin.server.ldap.add(testOrgDn, {
          objectClass: ['organizationalUnit', 'top'],
          ou: 'testorg',
        });

        // Since twakeDepartmentLink may not exist in schema,
        // verify that hook allows deletion when no entries link to org
        const result = await plugin.hooks.ldapdeleterequest?.([testOrgDn]);
        expect(result).to.deep.equal([testOrgDn, undefined]);
      });

      it('should allow deletion of empty organization', async () => {
        await plugin.server.ldap.add(testOrgDn, {
          objectClass: ['organizationalUnit', 'top'],
          ou: 'testorg',
        });

        const result = await plugin.hooks.ldapdeleterequest?.([testOrgDn]);
        expect(result).to.deep.equal([testOrgDn, undefined]);
      });

      it('should allow deletion of non-organization entries', async () => {
        const dn = `cn=test,${process.env.DM_LDAP_BASE}`;
        const result = await plugin.hooks.ldapdeleterequest?.([dn]);
        expect(result).to.deep.equal([dn, undefined]);
      });
    });

    describe('ldaprenamerequest', () => {
      it('should pass through rename requests', () => {
        const dn = testOrgDn;
        const newdn = testSubOrgDn;
        const result = plugin.hooks.ldaprenamerequest?.([dn, newdn]);
        expect(result).to.deep.equal([dn, newdn]);
      });
    });
  });

  describe('moveOrganization', () => {
    let parentOrgDn: string;
    let childOrgDn: string;
    let targetOrgDn: string;

    beforeEach(async () => {
      parentOrgDn = `ou=parent,${DM_LDAP_TOP_ORGANIZATION}`;
      childOrgDn = `ou=child,${parentOrgDn}`;
      targetOrgDn = `ou=target,${DM_LDAP_TOP_ORGANIZATION}`;

      // Create parent organization
      await plugin.server.ldap.add(parentOrgDn, {
        objectClass: ['organizationalUnit', 'twakeDepartment', 'top'],
        ou: 'parent',
      });

      // Create child organization under parent
      await plugin.server.ldap.add(childOrgDn, {
        objectClass: ['organizationalUnit', 'twakeDepartment', 'top'],
        ou: 'child',
      });

      // Create target organization
      await plugin.server.ldap.add(targetOrgDn, {
        objectClass: ['organizationalUnit', 'twakeDepartment', 'top'],
        ou: 'target',
      });
    });

    afterEach(async () => {
      // Clean up in reverse order (child first)
      const newChildDn = `ou=child,${targetOrgDn}`;
      try {
        await plugin.server.ldap.delete(newChildDn);
      } catch (e) {
        // ignore
      }
      try {
        await plugin.server.ldap.delete(childOrgDn);
      } catch (e) {
        // ignore
      }
      try {
        await plugin.server.ldap.delete(parentOrgDn);
      } catch (e) {
        // ignore
      }
      try {
        await plugin.server.ldap.delete(targetOrgDn);
      } catch (e) {
        // ignore
      }
    });

    it('should move organization to different parent', async () => {
      const result = await plugin.moveOrganization(childOrgDn, targetOrgDn);

      expect(result).to.have.property('newDn');
      expect(result.newDn).to.equal(`ou=child,${targetOrgDn}`);

      // Verify organization was moved
      const movedOrg = await plugin.getOrganisationByDn(result.newDn);
      expect(movedOrg).to.have.property('ou', 'child');

      // Verify old DN no longer exists
      try {
        await plugin.getOrganisationByDn(childOrgDn);
        expect.fail('Old DN should not exist');
      } catch (e) {
        expect((e as Error).message).to.match(/not found|Code: 0x20/);
      }
    });

    it('should reject move to non-existent target', async () => {
      const invalidTarget = `ou=nonexistent,${DM_LDAP_TOP_ORGANIZATION}`;

      try {
        await plugin.moveOrganization(childOrgDn, invalidTarget);
        expect.fail('Should have thrown error');
      } catch (e) {
        expect((e as Error).message).to.match(
          /Target organization .* not found/
        );
      }
    });

    it('should reject move of non-existent organization', async () => {
      const invalidSource = `ou=nonexistent,${parentOrgDn}`;

      try {
        await plugin.moveOrganization(invalidSource, targetOrgDn);
        expect.fail('Should have thrown error');
      } catch (e) {
        expect((e as Error).message).to.match(/Source organization not found/);
      }
    });

    it('should reject move to same location', async () => {
      try {
        await plugin.moveOrganization(childOrgDn, parentOrgDn);
        expect.fail('Should have thrown error');
      } catch (e) {
        expect((e as Error).message).to.match(
          /Cannot move organization to its current location/
        );
      }
    });

    it('should reject circular move (move into itself)', async () => {
      try {
        await plugin.moveOrganization(parentOrgDn, childOrgDn);
        expect.fail('Should have thrown error');
      } catch (e) {
        expect((e as Error).message).to.match(
          /Cannot move organization into itself or its descendant/
        );
      }
    });

    it('should reject move into itself', async () => {
      try {
        await plugin.moveOrganization(parentOrgDn, parentOrgDn);
        expect.fail('Should have thrown error');
      } catch (e) {
        expect((e as Error).message).to.match(
          /Cannot move organization into itself or its descendant/
        );
      }
    });

    it('should reject move to non-organizational target', async () => {
      // Create a non-organizational entry
      const userDn = `uid=testuser,${process.env.DM_LDAP_BASE}`;
      await plugin.server.ldap.add(userDn, {
        objectClass: ['inetOrgPerson', 'person', 'top'],
        uid: 'testuser',
        cn: 'Test User',
        sn: 'User',
      });

      try {
        await plugin.moveOrganization(childOrgDn, userDn);
        expect.fail('Should have thrown error');
      } catch (e) {
        expect((e as Error).message).to.match(/is not an organizational unit/);
      }

      // Clean up
      await plugin.server.ldap.delete(userDn);
    });
  });

  describe('API', () => {
    describe('GET /api/v1/ldap/organizations/top', () => {
      it('should return top organization', async () => {
        const res = await request
          .get('/api/v1/ldap/organizations/top')
          .set('Accept', 'application/json');

        expect(res.status).to.equal(200);
        expect(res.body).to.have.property('dn');
        expect(res.body.dn.toLowerCase()).to.equal(
          DM_LDAP_TOP_ORGANIZATION?.toLowerCase()
        );
      });
    });

    describe('GET /api/v1/ldap/organizations/:dn', () => {
      beforeEach(async () => {
        await plugin.server.ldap.add(testOrgDn, {
          objectClass: ['organizationalUnit', 'top'],
          ou: 'testorg',
        });
      });

      it('should return organization by DN', async () => {
        const res = await request
          .get(`/api/v1/ldap/organizations/${encodeURIComponent(testOrgDn)}`)
          .set('Accept', 'application/json');

        expect(res.status).to.equal(200);
        expect(res.body).to.have.property('dn', testOrgDn);
        expect(res.body).to.have.property('ou');
      });

      it('should return error for non-existent organization', async () => {
        const res = await request
          .get(
            `/api/v1/ldap/organizations/${encodeURIComponent(`ou=nonexistent,${DM_LDAP_TOP_ORGANIZATION}`)}`
          )
          .set('Accept', 'application/json');

        expect(res.status).to.equal(500);
      });
    });

    describe('GET /api/v1/ldap/organizations/:dn/subnodes', () => {
      beforeEach(async () => {
        await plugin.server.ldap.add(testOrgDn, {
          objectClass: ['organizationalUnit', 'top'],
          ou: 'testorg',
        });
      });

      it('should return empty array when organization has no linked entries', async () => {
        const res = await request
          .get(
            `/api/v1/ldap/organizations/${encodeURIComponent(testOrgDn)}/subnodes`
          )
          .set('Accept', 'application/json');

        expect(res.status).to.equal(200);
        expect(res.body).to.be.an('array').that.is.empty;
      });
    });

    describe('POST /api/v1/ldap/organizations', () => {
      it('should create a new organization', async () => {
        const res = await request
          .post('/api/v1/ldap/organizations')
          .type('json')
          .send({
            ou: 'testorg',
          });

        expect(res.status).to.equal(200);
        expect(res.body).to.deep.equal({ success: true });

        // Verify organization was created
        const org = await plugin.getOrganisationByDn(testOrgDn);
        expect(org).to.have.property('ou', 'testorg');
      });

      it('should return error when ou is missing', async () => {
        const res = await request
          .post('/api/v1/ldap/organizations')
          .type('json')
          .send({});

        expect(res.status).to.equal(400);
      });

      it('should create a sub-organization with parentDn', async () => {
        // First create parent organization
        await plugin.server.ldap.add(testOrgDn, {
          objectClass: ['organizationalUnit', 'top'],
          ou: 'testorg',
        });

        const subOrgRes = await request
          .post('/api/v1/ldap/organizations')
          .type('json')
          .send({
            ou: 'testsuborg',
            parentDn: testOrgDn,
          });

        expect(subOrgRes.status).to.equal(200);
        expect(subOrgRes.body).to.deep.equal({ success: true });

        // Verify sub-organization was created under parent
        const subOrgDn = `ou=testsuborg,${testOrgDn}`;
        const subOrg = await plugin.getOrganisationByDn(subOrgDn);
        expect(subOrg).to.have.property('ou', 'testsuborg');

        // Clean up sub-org
        await plugin.server.ldap.delete(subOrgDn);
      });
    });

    describe('PUT /api/v1/ldap/organizations/:dn', () => {
      beforeEach(async () => {
        await plugin.server.ldap.add(testOrgDn, {
          objectClass: ['organizationalUnit', 'top'],
          ou: 'testorg',
        });
      });

      it('should modify an organization', async () => {
        const res = await request
          .put(`/api/v1/ldap/organizations/${encodeURIComponent(testOrgDn)}`)
          .type('json')
          .send({
            replace: { description: 'Test organization description' },
          });

        expect(res.status).to.equal(200);
        expect(res.body).to.deep.equal({ success: true });

        // Verify modification
        const org = await plugin.getOrganisationByDn(testOrgDn);
        expect(org).to.have.property(
          'description',
          'Test organization description'
        );
      });

      it('should return error for invalid dn', async () => {
        const res = await request
          .put(
            `/api/v1/ldap/organizations/${encodeURIComponent(`ou=nonexistent,${DM_LDAP_TOP_ORGANIZATION}`)}`
          )
          .type('json')
          .send({
            replace: { description: 'Test' },
          });

        expect(res.status).to.equal(500);
      });
    });

    describe('DELETE /api/v1/ldap/organizations/:dn', () => {
      beforeEach(async () => {
        await plugin.server.ldap.add(testOrgDn, {
          objectClass: ['organizationalUnit', 'top'],
          ou: 'testorg',
        });
      });

      it('should delete an empty organization', async () => {
        const res = await request
          .delete(`/api/v1/ldap/organizations/${encodeURIComponent(testOrgDn)}`)
          .set('Accept', 'application/json');

        expect(res.status).to.equal(200);
        expect(res.body).to.deep.equal({ success: true });

        // Verify organization was deleted
        try {
          await plugin.getOrganisationByDn(testOrgDn);
          expect.fail('Organization should have been deleted');
        } catch (e) {
          expect((e as Error).message).to.match(/not found|Code: 0x20/);
        }
      });

      it('should return error when deleting non-existent organization', async () => {
        const res = await request
          .delete(
            `/api/v1/ldap/organizations/${encodeURIComponent(`ou=nonexistent,${DM_LDAP_TOP_ORGANIZATION}`)}`
          )
          .set('Accept', 'application/json');

        expect(res.status).to.equal(500);
      });
    });

    describe('POST /api/v1/ldap/organizations/:dn/move', () => {
      let parentOrgDn: string;
      let childOrgDn: string;
      let targetOrgDn: string;

      beforeEach(async () => {
        parentOrgDn = `ou=parent,${DM_LDAP_TOP_ORGANIZATION}`;
        childOrgDn = `ou=child,${parentOrgDn}`;
        targetOrgDn = `ou=target,${DM_LDAP_TOP_ORGANIZATION}`;

        // Create parent organization
        await plugin.server.ldap.add(parentOrgDn, {
          objectClass: ['organizationalUnit', 'twakeDepartment', 'top'],
          ou: 'parent',
        });

        // Create child organization under parent
        await plugin.server.ldap.add(childOrgDn, {
          objectClass: ['organizationalUnit', 'twakeDepartment', 'top'],
          ou: 'child',
        });

        // Create target organization
        await plugin.server.ldap.add(targetOrgDn, {
          objectClass: ['organizationalUnit', 'twakeDepartment', 'top'],
          ou: 'target',
        });
      });

      afterEach(async () => {
        // Clean up in reverse order (child first)
        const newChildDn = `ou=child,${targetOrgDn}`;
        try {
          await plugin.server.ldap.delete(newChildDn);
        } catch (e) {
          // ignore
        }
        try {
          await plugin.server.ldap.delete(childOrgDn);
        } catch (e) {
          // ignore
        }
        try {
          await plugin.server.ldap.delete(parentOrgDn);
        } catch (e) {
          // ignore
        }
        try {
          await plugin.server.ldap.delete(targetOrgDn);
        } catch (e) {
          // ignore
        }
      });

      it('should move organization via API', async () => {
        const res = await request
          .post(
            `/api/v1/ldap/organizations/${encodeURIComponent(childOrgDn)}/move`
          )
          .type('json')
          .send({
            targetOrgDn,
          });

        expect(res.status).to.equal(200);
        expect(res.body).to.have.property('newDn', `ou=child,${targetOrgDn}`);

        // Verify organization was moved
        const movedOrg = await plugin.getOrganisationByDn(res.body.newDn);
        expect(movedOrg).to.have.property('ou', 'child');
      });

      it('should return error when targetOrgDn is missing', async () => {
        const res = await request
          .post(
            `/api/v1/ldap/organizations/${encodeURIComponent(childOrgDn)}/move`
          )
          .type('json')
          .send({});

        expect(res.status).to.equal(400);
        expect(res.text).to.match(/Bad content/);
      });

      it('should return error when targetOrgDn is invalid', async () => {
        const res = await request
          .post(
            `/api/v1/ldap/organizations/${encodeURIComponent(childOrgDn)}/move`
          )
          .type('json')
          .send({
            targetOrgDn: 123, // Invalid type
          });

        expect(res.status).to.equal(400);
        expect(res.text).to.match(/Missing or invalid targetOrgDn/);
      });

      it('should return 404 when target does not exist', async () => {
        const res = await request
          .post(
            `/api/v1/ldap/organizations/${encodeURIComponent(childOrgDn)}/move`
          )
          .type('json')
          .send({
            targetOrgDn: `ou=nonexistent,${DM_LDAP_TOP_ORGANIZATION}`,
          });

        expect(res.status).to.equal(404);
        expect(res.body.error).to.match(/Target organization .* not found/);
      });

      it('should return 404 when source does not exist', async () => {
        const res = await request
          .post(
            `/api/v1/ldap/organizations/${encodeURIComponent(`ou=nonexistent,${parentOrgDn}`)}/move`
          )
          .type('json')
          .send({
            targetOrgDn,
          });

        expect(res.status).to.equal(404);
        expect(res.body.error).to.match(/Source organization/);
      });

      it('should return 400 for circular move', async () => {
        const res = await request
          .post(
            `/api/v1/ldap/organizations/${encodeURIComponent(parentOrgDn)}/move`
          )
          .type('json')
          .send({
            targetOrgDn: childOrgDn,
          });

        expect(res.status).to.equal(400);
        expect(res.body.error).to.match(/itself or its descendant/);
      });

      it('should return 400 when moving to same location', async () => {
        const res = await request
          .post(
            `/api/v1/ldap/organizations/${encodeURIComponent(childOrgDn)}/move`
          )
          .type('json')
          .send({
            targetOrgDn: parentOrgDn,
          });

        expect(res.status).to.equal(400);
        expect(res.body.error).to.match(/current location/);
      });

      it('should reject move into self when DNs differ only in case', async () => {
        // Same DN but different attribute-name casing — must still be
        // recognised as the same node, not as a child of itself.
        const upperCased = childOrgDn.replace(/^ou=/, 'OU=');
        const res = await request
          .post(
            `/api/v1/ldap/organizations/${encodeURIComponent(childOrgDn)}/move`
          )
          .type('json')
          .send({ targetOrgDn: upperCased });

        expect(res.status).to.equal(400);
        expect(res.body.error).to.match(
          /itself or its descendant|current location/
        );
      });
    });

    describe('GET /api/v1/ldap/organizations/:dn/subnodes (LDAP injection)', () => {
      beforeEach(async () => {
        await plugin.server.ldap.add(testOrgDn, {
          objectClass: ['organizationalUnit', 'top'],
          ou: 'testorg',
        });
      });

      it('should escape LDAP filter metacharacters in objectClass query', async () => {
        // Without escaping, "*)(uid=*" would close the link-attribute
        // clause and inject `(uid=*)` — leaking unrelated entries.
        // With escaping, the value is treated as a literal objectClass
        // and the search returns an empty result set without errors.
        const res = await request
          .get(
            `/api/v1/ldap/organizations/${encodeURIComponent(testOrgDn)}/subnodes`
          )
          .query({ objectClass: '*)(uid=*' })
          .set('Accept', 'application/json');

        expect(res.status).to.equal(200);
        expect(res.body).to.be.an('array').that.is.empty;
      });
    });
  });
});
