/**
 * @module abstract/ldapFlat
 * @author Xavier Guimard <xguimard@linagora.com>
 *
 * Abstract class to manage LDAP entries in a flat branch
 * - add/delete entries
 * - modify entries
 * - validate with schema
 */
import fs from 'fs';

import type { Express, Request, Response } from 'express';

import type { DM } from '../bin';
import type ldapActions from '../lib/ldapActions';
import type {
  AttributesList,
  AttributeValue,
  LdapList,
  ModifyRequest,
  SearchResult,
} from '../lib/ldapActions';
import {
  created,
  jsonBody,
  tryMethod,
  wantJson,
} from '../lib/expressFormatedResponses';
import {
  asyncHandler,
  escapeDnValue,
  escapeLdapFilter,
  escapeRegex,
  getCompiledRegex,
  getParentDn,
  launchHooks,
  launchHooksChained,
  transformSchemas,
  validateDnValue,
} from '../lib/utils';
import type { Schema } from '../config/schema';
import { BadRequestError, NotFoundError, ConflictError } from '../lib/errors';

import DmPlugin from './plugin';

export interface LdapFlatConfig {
  /**
   * LDAP branch where entries are stored
   */
  base: string;

  /**
   * Main attribute used as entry identifier (e.g., 'uid', 'cn')
   */
  mainAttribute: string;

  /**
   * ObjectClass(es) for new entries
   */
  objectClass: string[];

  /**
   * Default attributes to add to new entries
   */
  defaultAttributes?: AttributesList;

  /**
   * Optional schema file path for validation
   */
  schemaPath?: string;

  /**
   * Singular name for API routes (e.g., 'user', 'position')
   */
  singularName: string;

  /**
   * Plural name for API routes (e.g., 'users', 'positions')
   */
  pluralName: string;

  /**
   * Hook name prefix (e.g., 'ldapuser', 'ldapposition')
   */
  hookPrefix: string;
}

/**
 * Generic OpenAPI schemas for the flat LDAP-entry CRUD surface.
 * Every concrete plugin that extends LdapFlat (users, mailgroups, …)
 * exposes these shapes, with `mainAttribute` and concrete attribute
 * names varying per instance.
 *
 * @openapi-component
 * FlatEntry:
 *   type: object
 *   description: |
 *     A single LDAP entry from a flat branch. The `dn` field is always
 *     present; all other fields depend on the concrete plugin's schema
 *     (e.g. `uid`, `cn`, `mail`, `sn` for users).
 *   required: [dn]
 *   properties:
 *     dn:
 *       type: string
 *       description: Fully-qualified distinguished name of the entry.
 *       example: uid=alice,ou=users,dc=example,dc=com
 *   additionalProperties:
 *     oneOf:
 *       - type: string
 *       - type: array
 *         items: { type: string }
 *   example:
 *     dn: uid=alice,ou=users,dc=example,dc=com
 *     uid: alice
 *     cn: Alice Smith
 *     sn: Smith
 *     mail: alice@example.com
 * FlatList:
 *   type: object
 *   description: |
 *     A map of entries returned by the LIST endpoint, keyed by the
 *     entry's `mainAttribute` value (e.g. the `uid` for users). This
 *     is the `LdapList` shape (`Record<string, FlatEntry>`).
 *   additionalProperties:
 *     $ref: '#/components/schemas/FlatEntry'
 *   example:
 *     alice:
 *       dn: uid=alice,ou=users,dc=example,dc=com
 *       uid: alice
 *       cn: Alice Smith
 *       mail: alice@example.com
 *     bob:
 *       dn: uid=bob,ou=users,dc=example,dc=com
 *       uid: bob
 *       cn: Bob Jones
 *       mail: bob@example.com
 * FlatCreate:
 *   type: object
 *   description: |
 *     Body for creating a new entry. The plugin's `mainAttribute` field
 *     (e.g. `uid`) is required and used to build the DN. All other
 *     attributes are plugin-specific; unknown attributes are accepted
 *     unless the plugin's schema is in strict mode.
 *   additionalProperties: true
 *   example:
 *     uid: carol
 *     cn: Carol White
 *     sn: White
 *     mail: carol@example.com
 * FlatModify:
 *   type: object
 *   description: |
 *     Partial-update body. Supports `add`, `replace`, and `delete`
 *     maps matching the LDAP modify operation. The `mainAttribute`
 *     (e.g. `uid`) cannot be changed through this endpoint — use the
 *     rename API instead.
 *   properties:
 *     add:
 *       type: object
 *       description: Attributes whose values are appended.
 *       additionalProperties: true
 *     replace:
 *       type: object
 *       description: Attributes whose values are replaced wholesale.
 *       additionalProperties: true
 *     delete:
 *       description: |
 *         Attributes (or specific values) to remove. Either an array
 *         of attribute names or a map of attribute → value(s).
 *       oneOf:
 *         - type: array
 *           items: { type: string }
 *         - type: object
 *           additionalProperties: true
 *   example:
 *     replace:
 *       mail: carol.white@example.com
 *       cn: Carol White-Smith
 * FlatMoveRequest:
 *   type: object
 *   description: |
 *     Body for moving an entry to a different organisational unit.
 *     Requires `ldap_organization_link_attribute` and
 *     `ldap_organization_path_attribute` to be defined in the plugin's
 *     schema.
 *   required: [targetOrgDn]
 *   properties:
 *     targetOrgDn:
 *       type: string
 *       description: DN of the destination organisational unit.
 *       example: ou=engineering,ou=departments,dc=example,dc=com
 *   example:
 *     targetOrgDn: ou=engineering,ou=departments,dc=example,dc=com
 */
export default abstract class LdapFlat extends DmPlugin {
  base: string;
  ldap: ldapActions;
  mainAttribute: string;
  objectClass: string[];
  defaultAttributes: AttributesList;
  schema?: Schema;
  singularName: string;
  pluralName: string;
  hookPrefix: string;

  constructor(server: DM, config: LdapFlatConfig) {
    super(server);
    this.ldap = server.ldap;
    this.base = config.base;
    this.mainAttribute = config.mainAttribute;
    this.objectClass = config.objectClass;
    this.defaultAttributes = config.defaultAttributes || {};
    this.singularName = config.singularName;
    this.pluralName = config.pluralName;
    this.hookPrefix = config.hookPrefix;

    if (!this.base) {
      throw new Error(`LDAP base is not defined for ${this.singularName}`);
    }

    if (config.schemaPath) {
      try {
        const data = fs.readFileSync(config.schemaPath, 'utf8');
        this.schema = JSON.parse(transformSchemas(data, this.config)) as Schema;
        this.logger.info(
          `${this.singularName} schema loaded from ${config.schemaPath}`
        );
      } catch (err) {
        this.logger.error(
          // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
          `Failed to load ${this.singularName} schema from ${config.schemaPath}: ${err}`
        );
      }
    }
  }

  /**
   * API routes
   */
  api(app: Express): void {
    /**
     * @openapi
     * summary: List entries
     * description: |
     *   Returns all entries in the flat LDAP branch, keyed by their
     *   `mainAttribute` value (e.g. `uid` for users). The optional
     *   `match` and `attribute` query parameters filter results using
     *   a substring LDAP search (`attribute=*match*`). The `attributes`
     *   parameter limits which LDAP attributes are returned.
     * tags:
     *   - Entities
     * parameters:
     *   - in: path
     *     name: resource
     *     required: true
     *     schema: { type: string }
     *     description: |
     *       Plural name of the flat resource (e.g. `users`, `mailgroups`).
     *       Each concrete plugin sets its own value.
     *     example: users
     *   - in: query
     *     name: match
     *     required: false
     *     schema: { type: string }
     *     description: |
     *       Substring to match. Must be used together with `attribute`.
     *       The resulting LDAP filter is `(attribute=*match*)`.
     *     example: alice
     *   - in: query
     *     name: attribute
     *     required: false
     *     schema: { type: string }
     *     description: |
     *       LDAP attribute name to match against (used with `match`).
     *     example: cn
     *   - in: query
     *     name: attributes
     *     required: false
     *     schema: { type: string }
     *     description: |
     *       Comma-separated list of LDAP attributes to include in each
     *       returned entry. Omit to return all attributes.
     *     example: uid,cn,mail
     * responses:
     *   '200':
     *     description: Map of entries keyed by mainAttribute value.
     *     content:
     *       application/json:
     *         schema: { $ref: '#/components/schemas/FlatList' }
     *         example:
     *           alice:
     *             dn: uid=alice,ou=users,dc=example,dc=com
     *             uid: alice
     *             cn: Alice Smith
     *             mail: alice@example.com
     *           bob:
     *             dn: uid=bob,ou=users,dc=example,dc=com
     *             uid: bob
     *             cn: Bob Jones
     *             mail: bob@example.com
     *   '400':
     *     description: Invalid LDAP attribute name in `attribute` parameter.
     *     content:
     *       application/json:
     *         schema: { $ref: '#/components/schemas/Error' }
     */
    // List entries
    app.get(
      `${this.config.api_prefix}/v1/ldap/${this.pluralName}`,
      asyncHandler(async (req, res) => {
        if (!wantJson(req, res)) return;
        const args: { filter?: string; attributes?: string[] } = {};
        if (
          req.query.match &&
          typeof req.query.match === 'string' &&
          req.query.attribute &&
          typeof req.query.attribute === 'string'
        ) {
          // Validate LDAP attribute name (alphanumeric + hyphen, starting with letter)
          const attributePattern = /^[a-zA-Z][a-zA-Z0-9-]*$/;
          if (!attributePattern.test(req.query.attribute)) {
            throw new BadRequestError('Invalid LDAP attribute name');
          }
          // Escape filter value to prevent LDAP injection
          const escapedMatch = escapeLdapFilter(req.query.match);
          args.filter = `(${req.query.attribute}=*${escapedMatch}*)`;
        }
        if (req.query.attributes && typeof req.query.attributes === 'string') {
          args.attributes = req.query.attributes.split(',');
        }
        const list = await this.listEntries(args);
        res.json(list);
      })
    );

    /**
     * @openapi
     * summary: Get entry by ID or DN
     * description: |
     *   Returns a single entry identified by its `mainAttribute` value
     *   (e.g. a `uid`) or by its full DN. If the value starts with
     *   `mainAttribute=` it is treated as a DN; otherwise it is treated
     *   as a raw RDN value.
     * tags:
     *   - Entities
     * parameters:
     *   - in: path
     *     name: resource
     *     required: true
     *     schema: { type: string }
     *     description: |
     *       Plural name of the flat resource (e.g. `users`, `mailgroups`).
     *       Each concrete plugin sets its own value.
     *     example: users
     * responses:
     *   '200':
     *     description: The requested entry.
     *     content:
     *       application/json:
     *         schema: { $ref: '#/components/schemas/FlatEntry' }
     *         example:
     *           dn: uid=alice,ou=users,dc=example,dc=com
     *           uid: alice
     *           cn: Alice Smith
     *           sn: Smith
     *           mail: alice@example.com
     *   '400':
     *     description: The supplied DN is not a direct child of the configured base.
     *     content:
     *       application/json:
     *         schema: { $ref: '#/components/schemas/Error' }
     *   '404':
     *     description: Entry not found.
     *     content:
     *       application/json:
     *         schema: { $ref: '#/components/schemas/Error' }
     *         example:
     *           error: user not found
     *           code: 404
     */
    // Get entry by id or DN
    app.get(
      `${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id`,
      asyncHandler(async (req, res) => this.apiGet(req, res))
    );

    /**
     * @openapi
     * summary: Create entry
     * description: |
     *   Creates a new entry in the flat LDAP branch. The `mainAttribute`
     *   field (e.g. `uid`) is required and used to construct the DN.
     *   Returns the newly created entry on success (HTTP 201).
     * tags:
     *   - Entities
     * parameters:
     *   - in: path
     *     name: resource
     *     required: true
     *     schema: { type: string }
     *     description: |
     *       Plural name of the flat resource (e.g. `users`, `mailgroups`).
     *       Each concrete plugin sets its own value.
     *     example: users
     * requestBody:
     *   required: true
     *   content:
     *     application/json:
     *       schema: { $ref: '#/components/schemas/FlatCreate' }
     *       example:
     *         uid: carol
     *         cn: Carol White
     *         sn: White
     *         mail: carol@example.com
     * responses:
     *   '201':
     *     description: Entry created; returns the new entry.
     *     content:
     *       application/json:
     *         schema: { $ref: '#/components/schemas/FlatEntry' }
     *         example:
     *           dn: uid=carol,ou=users,dc=example,dc=com
     *           uid: carol
     *           cn: Carol White
     *           sn: White
     *           mail: carol@example.com
     *   '400':
     *     description: Missing required field or validation error.
     *     content:
     *       application/json:
     *         schema: { $ref: '#/components/schemas/Error' }
     *   '409':
     *     description: Entry already exists.
     *     content:
     *       application/json:
     *         schema: { $ref: '#/components/schemas/Error' }
     */
    // Add entry
    app.post(
      `${this.config.api_prefix}/v1/ldap/${this.pluralName}`,
      asyncHandler(async (req, res) => this.apiAdd(req, res))
    );

    /**
     * @openapi
     * summary: Delete entry
     * description: |
     *   Permanently removes the entry identified by `id` from the LDAP
     *   branch. The `id` may be either a `mainAttribute` value or the
     *   entry's full DN.
     * tags:
     *   - Entities
     * parameters:
     *   - in: path
     *     name: resource
     *     required: true
     *     schema: { type: string }
     *     description: |
     *       Plural name of the flat resource (e.g. `users`, `mailgroups`).
     *       Each concrete plugin sets its own value.
     *     example: users
     * responses:
     *   '200':
     *     description: Entry deleted.
     *     content:
     *       application/json:
     *         example: { success: true }
     *   '404':
     *     description: Entry not found.
     *     content:
     *       application/json:
     *         schema: { $ref: '#/components/schemas/Error' }
     */
    // Delete entry
    app.delete(
      `${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id`,
      asyncHandler(async (req, res) => this.apiDelete(req, res))
    );

    /**
     * @openapi
     * summary: Modify entry
     * description: |
     *   Applies a partial update to the entry identified by `id`. The
     *   body follows the LDAP modify structure: `add`, `replace`, and/or
     *   `delete` maps. The `mainAttribute` (e.g. `uid`) cannot be
     *   changed through this endpoint.
     * tags:
     *   - Entities
     * parameters:
     *   - in: path
     *     name: resource
     *     required: true
     *     schema: { type: string }
     *     description: |
     *       Plural name of the flat resource (e.g. `users`, `mailgroups`).
     *       Each concrete plugin sets its own value.
     *     example: users
     * requestBody:
     *   required: true
     *   content:
     *     application/json:
     *       schema: { $ref: '#/components/schemas/FlatModify' }
     *       example:
     *         replace:
     *           mail: alice.smith@example.com
     *           cn: Alice M. Smith
     * responses:
     *   '200':
     *     description: Entry updated.
     *     content:
     *       application/json:
     *         example: { success: true }
     *   '400':
     *     description: Validation error or attempt to modify a fixed attribute.
     *     content:
     *       application/json:
     *         schema: { $ref: '#/components/schemas/Error' }
     *   '404':
     *     description: Entry not found.
     *     content:
     *       application/json:
     *         schema: { $ref: '#/components/schemas/Error' }
     */
    // Modify entry
    app.put(
      `${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id`,
      asyncHandler(async (req, res) => this.apiModify(req, res))
    );

    /**
     * @openapi
     * summary: Move entry to another organisation
     * description: |
     *   Moves the entry to a different organisational unit by updating
     *   the configured department-link and department-path attributes
     *   (`ldap_organization_link_attribute` and
     *   `ldap_organization_path_attribute`). The plugin's schema must
     *   declare both attributes; otherwise the request is rejected with
     *   400.
     * tags:
     *   - Entities
     * parameters:
     *   - in: path
     *     name: resource
     *     required: true
     *     schema: { type: string }
     *     description: |
     *       Plural name of the flat resource (e.g. `users`, `mailgroups`).
     *       Each concrete plugin sets its own value.
     *     example: users
     * requestBody:
     *   required: true
     *   content:
     *     application/json:
     *       schema: { $ref: '#/components/schemas/FlatMoveRequest' }
     *       example:
     *         targetOrgDn: ou=engineering,ou=departments,dc=example,dc=com
     * responses:
     *   '200':
     *     description: Entry moved; returns the new department path and link.
     *     content:
     *       application/json:
     *         example:
     *           success: true
     *           departmentPath: /engineering
     *           departmentLink: ou=engineering,ou=departments,dc=example,dc=com
     *   '400':
     *     description: |
     *       Missing or invalid `targetOrgDn`, or schema does not support
     *       the move operation.
     *     content:
     *       application/json:
     *         schema: { $ref: '#/components/schemas/Error' }
     *   '404':
     *     description: Entry or target organisation not found.
     *     content:
     *       application/json:
     *         schema: { $ref: '#/components/schemas/Error' }
     */
    // Move entry to different organization
    app.post(
      `${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id/move`,
      asyncHandler(async (req, res) => this.apiMove(req, res))
    );
  }

  async apiGet(req: Request, res: Response): Promise<void> {
    if (!wantJson(req, res)) return;
    const id = decodeURIComponent(req.params.id as string);
    try {
      const dn = this.resolveDn(id);
      const result = (await this.ldap.search(
        { paged: false, scope: 'base' },
        dn
      )) as SearchResult;
      if (result.searchEntries.length === 0) {
        throw new NotFoundError(`${this.singularName} not found`);
      }
      res.json(result.searchEntries[0]);
    } catch (err) {
      // LDAP NoSuchObjectError (code 32) means not found
      if (
        (err as { code?: number }).code &&
        (err as { code?: number }).code === 32
      ) {
        throw new NotFoundError(`${this.singularName} not found`);
      }
      throw err;
    }
  }

  async apiAdd(req: Request, res: Response): Promise<void> {
    const body = jsonBody(req, res, this.mainAttribute) as
      | Record<string, AttributeValue>
      | false;
    if (!body) return;

    const id = body[this.mainAttribute] as string;
    const additional = { ...body };
    delete additional[this.mainAttribute];
    // Remove dn if provided - it will be constructed by addEntry
    delete additional.dn;

    await this.addEntry(id, additional, req);
    const entry = await this.searchEntriesByName(id, false);
    return created(res, entry[id]);
  }

  async apiDelete(req: Request, res: Response): Promise<void> {
    if (!wantJson(req, res)) return;
    const id = decodeURIComponent(req.params.id as string);
    await tryMethod(res, this.deleteEntry.bind(this), id);
  }

  async apiModify(req: Request, res: Response): Promise<void> {
    const body = jsonBody(req, res) as ModifyRequest | false;
    if (!body) return;
    const id = decodeURIComponent(req.params.id as string);
    await tryMethod(res, this.modifyEntry.bind(this), id, body);
  }

  async apiMove(req: Request, res: Response): Promise<void> {
    if (!wantJson(req, res)) return;

    const body = jsonBody(req, res, 'targetOrgDn') as
      | { targetOrgDn: string }
      | false;
    if (!body) return;

    const id = decodeURIComponent(req.params.id as string);
    const { targetOrgDn } = body;

    if (!targetOrgDn || typeof targetOrgDn !== 'string') {
      throw new BadRequestError(
        'Missing or invalid targetOrgDn in request body'
      );
    }

    const result = await this.moveEntry(id, targetOrgDn, req);
    res.json({
      success: true,
      ...result,
    });
  }

  /**
   * Resolve an id (either an RDN value or a full DN) into a DN inside this
   * instance's base branch.
   *
   * A value is treated as a full DN only when it starts with
   * `mainAttribute=`; otherwise it is treated as a raw RDN value and escaped.
   * This matters for cn-based branches where values like `Smith, John` are
   * legal and must not be misclassified as DNs.
   *
   * When the id is a full DN we enforce that its parent equals this.base
   * (LdapFlat entries are, by definition, direct children of the base).
   * The comparison is done on the parsed RDN components so escape-aware
   * payloads like `cn=pwn\,ou=titles,ou=nomenclature,dc=example,dc=com`
   * cannot sneak past a naive textual suffix check: `\,` is a literal comma
   * inside the first RDN's value, so the actual parent of that DN is
   * `ou=nomenclature,dc=example,dc=com` — one level above the expected base.
   *
   * @throws BadRequestError if the provided DN is not a direct child of this.base
   */
  protected resolveDn(id: string): string {
    const dnPrefix = new RegExp(`^${escapeRegex(this.mainAttribute)}=`, 'i');
    if (dnPrefix.test(id)) {
      const parent = getParentDn(id);
      // If the DN has a single RDN, getParentDn returns the DN itself; treat
      // that as "missing parent" rather than "parent equals base".
      const hasParent = parent !== id;
      if (!hasParent || parent.toLowerCase() !== this.base.toLowerCase()) {
        throw new BadRequestError(
          `DN must be a direct child of "${this.base}". ` +
            `Provided DN "${id}" has parent "${hasParent ? parent : '<none>'}"`
        );
      }
      return id;
    }
    return `${this.mainAttribute}=${escapeDnValue(id)},${this.base}`;
  }

  async addEntry(
    id: string,
    additional: AttributesList = {},
    req?: Request
  ): Promise<boolean> {
    let dn: string;
    if (new RegExp(`^${this.mainAttribute}=`, 'i').test(id)) {
      // DN provided - validate it's in the correct flat branch
      dn = this.resolveDn(id);
      // Extract the RDN value, correctly handling escaped commas
      // e.g. uid=Smith\,John,ou=users -> id = "Smith\,John"
      id = id.replace(
        new RegExp(`^${this.mainAttribute}=((?:\\\\.|[^,])+)(?:,.*)?$`, 'i'),
        '$1'
      );
    } else {
      validateDnValue(id, this.mainAttribute);
      dn = this.resolveDn(id);
    }
    await this.validateNewEntry(dn, {
      objectClass: this.objectClass,
      [this.mainAttribute]: id,
      ...additional,
    });

    // Build entry
    let entry: AttributesList = {
      objectClass: this.objectClass,
      ...this.defaultAttributes,
      [this.mainAttribute]: id,
      ...additional,
    };

    // Note: LDAP attribute values with DNs do NOT need escaping
    // ldapts handles this automatically
    // Only the main DN of the entry itself needs proper formatting
    if (this.schema) {
      this.logger.debug(
        'Schema loaded, attributes:',
        Object.keys(this.schema.attributes)
      );
    } else {
      this.logger.warn('No schema available');
    }

    [dn, entry] = await launchHooksChained(
      this.registeredHooks[`${this.hookPrefix}add`],
      [dn, entry]
    );

    // Debug log entry before sending to LDAP
    this.logger.debug('Adding LDAP entry:', {
      dn,
      entry: JSON.stringify(entry, null, 2),
    });

    // Log each attribute to see exact values
    this.logger.debug('Entry attributes breakdown:');
    for (const [key, value] of Object.entries(entry)) {
      this.logger.debug(`  ${key}: ${typeof value} = ${JSON.stringify(value)}`);
    }

    let res;
    try {
      res = await this.ldap.add(dn, entry, req);
    } catch (err) {
      // Log detailed error information
      this.logger.error('LDAP add failed:', {
        dn,
        entry: JSON.stringify(entry, null, 2),
        error: err,
      });
      // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
      throw new Error(`Failed to add ${this.singularName} ${dn}: ${err}`);
    }
    void launchHooks(this.registeredHooks[`${this.hookPrefix}adddone`], [
      dn,
      entry,
    ]);
    return res;
  }

  async modifyEntry(id: string, changes: ModifyRequest): Promise<boolean> {
    let dn = this.resolveDn(id);
    const op = this.opNumber();
    [dn, changes] = await launchHooksChained(
      this.registeredHooks[`${this.hookPrefix}modify`],
      [dn, changes, op]
    );
    if (changes.add) {
      if (changes.add[this.mainAttribute])
        throw new ConflictError(
          `${this.mainAttribute} attribute is unique, cannot add`
        );
    }
    if (changes.delete) {
      if (changes.delete instanceof Object) {
        if ((changes.delete as AttributesList)[this.mainAttribute])
          throw new BadRequestError(
            `Cannot delete ${this.mainAttribute} attribute`
          );
      }
      if (Array.isArray(changes.delete)) {
        if (changes.delete.includes(this.mainAttribute))
          throw new BadRequestError(
            `Cannot delete ${this.mainAttribute} attribute`
          );
      }
    }
    if (changes.replace) {
      if (changes.replace[this.mainAttribute])
        throw new BadRequestError(
          `Use dedicated API to change ${this.mainAttribute} attribute`
        );
    }

    await this.validateChanges(dn, changes);
    const res = await this.ldap.modify(dn, changes);
    void launchHooks(this.registeredHooks[`${this.hookPrefix}modifydone`], [
      dn,
      changes,
      op,
    ]);
    return res;
  }

  async renameEntry(id: string, newId: string): Promise<boolean> {
    if (!/,/.test(id)) {
      validateDnValue(id, this.mainAttribute);
    }
    if (!/,/.test(newId)) {
      validateDnValue(newId, this.mainAttribute);
    }
    let dn = this.resolveDn(id);
    let newDn = this.resolveDn(newId);
    [dn, newDn] = await launchHooksChained(
      this.registeredHooks[`${this.hookPrefix}rename`],
      [dn, newDn]
    );
    const res = await this.ldap.rename(dn, newDn);
    void launchHooks(this.registeredHooks[`${this.hookPrefix}renamedone`], [
      dn,
      newDn,
    ]);
    return res;
  }

  async deleteEntry(id: string): Promise<boolean> {
    let dn = this.resolveDn(id);
    dn = await launchHooksChained(
      this.registeredHooks[`${this.hookPrefix}delete`],
      dn
    );
    const res = await this.ldap.delete(dn);
    void launchHooks(this.registeredHooks[`${this.hookPrefix}deletedone`], dn);
    return res;
  }

  /**
   * Move entry to a different organization
   * Updates department link and path attributes
   */
  async moveEntry(
    id: string,
    targetOrgDn: string,
    req?: Request
  ): Promise<{ departmentPath: string; departmentLink: string }> {
    const dn = this.resolveDn(id);

    // Get link and path attribute names from config
    const linkAttr =
      this.config.ldap_organization_link_attribute || 'twakeDepartmentLink';
    const pathAttr =
      this.config.ldap_organization_path_attribute || 'twakeDepartmentPath';

    // Validate that the schema supports these attributes
    if (
      !this.schema?.attributes[linkAttr] ||
      !this.schema?.attributes[pathAttr]
    ) {
      throw new BadRequestError(
        `Schema for ${this.singularName} does not support move operation (missing ${linkAttr} or ${pathAttr})`
      );
    }

    // Fetch current entry to get old organization
    const currentEntry = (await this.ldap.search(
      { paged: false, scope: 'base', attributes: [linkAttr] },
      dn,
      req
    )) as SearchResult;

    if (
      !currentEntry.searchEntries ||
      currentEntry.searchEntries.length === 0
    ) {
      throw new NotFoundError(`Entry ${dn} not found`);
    }

    // Get department path from target organization
    const departmentPath = await this.getDepartmentPath(targetOrgDn, req);

    // Launch pre-move hook (chained - can modify targetOrgDn or cancel)
    [, targetOrgDn] = await launchHooksChained(
      this.registeredHooks[`${this.hookPrefix}move`],
      [dn, targetOrgDn, req]
    );

    // Prepare LDAP modify request
    const changes: ModifyRequest = {
      replace: {
        [linkAttr]: targetOrgDn,
        [pathAttr]: departmentPath,
      },
    };

    // Execute the modification (will trigger onLdapChange hook)
    await this.modifyEntry(id, changes);

    return {
      departmentPath,
      departmentLink: targetOrgDn,
    };
  }

  /**
   * Get department path from an organization DN
   * Fetches the path attribute directly from the organization entry
   */
  private async getDepartmentPath(
    orgDn: string,
    req?: Request
  ): Promise<string> {
    const pathAttr =
      this.config.ldap_organization_path_attribute || 'twakeDepartmentPath';

    try {
      const result = (await this.ldap.search(
        { paged: false, scope: 'base', attributes: [pathAttr, 'ou', 'o'] },
        orgDn,
        req
      )) as SearchResult;

      if (!result.searchEntries || result.searchEntries.length === 0) {
        throw new NotFoundError(`Organization ${orgDn} not found`);
      }

      const org = result.searchEntries[0];

      // Return the path attribute if it exists
      if (org[pathAttr]) {
        const path = org[pathAttr];
        return Array.isArray(path) ? String(path[0]) : String(path);
      }

      // Fallback: construct path from ou or o attribute
      const ou = org.ou || org.o;
      if (ou) {
        const name = Array.isArray(ou) ? String(ou[0]) : String(ou);
        return `/${name}`;
      }

      // Last resort: use the DN
      this.logger.warn(
        `Organization ${orgDn} has no ${pathAttr} attribute, using DN`
      );
      return orgDn;
    } catch (err) {
      // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
      throw new Error(`Failed to fetch organization ${orgDn}: ${err}`);
    }
  }

  /**
   * List entries from LDAP
   */
  async listEntries({
    filter,
    attributes,
  }: {
    filter?: string;
    attributes?: string[];
  }): Promise<LdapList> {
    filter = filter || '(objectClass=*)';
    const args: {
      paged: boolean;
      filter: string;
      attributes?: string[];
    } = {
      paged: true,
      filter,
    };
    if (attributes && attributes.length > 0) args.attributes = attributes;
    const ldapRes = await this.ldap.search(args, this.base);
    const res: LdapList = {};
    for await (const tmp of ldapRes as AsyncGenerator<SearchResult>) {
      tmp.searchEntries.forEach(e => {
        if (e[this.mainAttribute]) {
          const value = e[this.mainAttribute];
          let id: string;
          if (Array.isArray(value)) {
            id = typeof value[0] === 'string' ? value[0] : String(value[0]);
          } else {
            id = typeof value === 'string' ? value : String(value);
          }
          res[id] = e;
        }
      });
    }
    return res;
  }

  async searchEntriesByName(
    name: string,
    partial = false,
    attrs: string[] = [this.mainAttribute]
  ): Promise<LdapList> {
    const filter = partial
      ? `(${this.mainAttribute}=*${name}*)`
      : `(${this.mainAttribute}=${name})`;
    return await this.listEntries({ filter, attributes: attrs });
  }

  async validateNewEntry(dn: string, entry: AttributesList): Promise<boolean> {
    if (!this.schema) return true;

    // First, enforce fixed attributes
    for (const [field, attr] of Object.entries(this.schema.attributes)) {
      if (attr.fixed && attr.default !== undefined) {
        // Force the default value for fixed attributes
        entry[field] = attr.default;
      }
    }

    for (const [field, value] of Object.entries(entry)) {
      if (!this.schema.attributes[field]) {
        if (this.schema.strict)
          throw new BadRequestError(
            `Unknown attribute "${field}" for ${this.singularName}`
          );
        continue;
      }
      const attr = this.schema.attributes[field];

      // Check if trying to modify a fixed attribute
      if (attr.fixed && attr.default !== undefined) {
        const defaultStr = JSON.stringify(attr.default);
        const valueStr = JSON.stringify(value);
        if (defaultStr !== valueStr) {
          throw new BadRequestError(
            `Attribute "${field}" is fixed and cannot be modified. Expected: ${defaultStr}`
          );
        }
      }

      if (!(await this._validateOneChange(field, value))) {
        throw new BadRequestError(`Invalid value for attribute "${field}"`);
      }
      if (attr.required && !value) {
        throw new BadRequestError(`Attribute "${field}" is required`);
      }
    }
    // Check required fields
    for (const [field, attr] of Object.entries(this.schema.attributes)) {
      if (attr.required && !entry[field]) {
        throw new BadRequestError(`Attribute "${field}" is required`);
      }
    }
    return true;
  }

  async validateChanges(dn: string, changes: ModifyRequest): Promise<boolean> {
    if (!this.schema) return true;

    // Check for fixed attributes in add/replace operations
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    const checkFixed = (field: string, value: AttributeValue): void => {
      const attr = this.schema?.attributes[field];
      if (attr?.fixed) {
        throw new BadRequestError(
          `Attribute "${field}" is fixed and cannot be modified`
        );
      }
    };

    if (changes.add) {
      for (const [field, value] of Object.entries(changes.add)) {
        checkFixed(field, value);
        if (!(await this._validateOneChange(field, value))) {
          throw new BadRequestError(`Invalid value for attribute "${field}"`);
        }
      }
    }
    if (changes.replace) {
      for (const [field, value] of Object.entries(changes.replace)) {
        checkFixed(field, value);
        if (!(await this._validateOneChange(field, value))) {
          throw new BadRequestError(`Invalid value for attribute "${field}"`);
        }
      }
    }
    if (changes.delete) {
      const deleteFields = Array.isArray(changes.delete)
        ? changes.delete
        : Object.keys(changes.delete);
      for (const field of deleteFields) {
        const attr = this.schema.attributes[field];
        if (attr?.fixed) {
          throw new BadRequestError(
            `Attribute "${field}" is fixed and cannot be deleted`
          );
        }
      }
    }
    return true;
  }

  async _validateOneChange(
    field: string,
    value: AttributeValue | null
  ): Promise<boolean> {
    if (!this.schema) return true;
    const attr = this.schema.attributes[field];
    if (!attr) {
      if (this.schema.strict) return false;
      return true;
    }
    if (!value) return true;

    // Handle pointer type
    if (attr.type === 'pointer') {
      if (typeof value !== 'string') {
        throw new BadRequestError(
          `Field ${field} must be a string (DN pointer)`
        );
      }

      const dnValue: string = value;

      // Check branch restriction if provided
      if (attr.branch && attr.branch.length > 0) {
        const isInBranch = attr.branch.some(branch => {
          const branchPattern = getCompiledRegex(
            `,?${escapeRegex(branch)}$`,
            'i'
          );
          return branchPattern.test(dnValue);
        });
        if (!isInBranch) {
          throw new BadRequestError(
            `Field ${field} must point to a DN within allowed branches: ${attr.branch.join(', ')}`
          );
        }
      }

      // Verify that the DN exists in LDAP
      try {
        const result = (await this.ldap.search(
          { paged: false, scope: 'base' },
          dnValue
        )) as SearchResult;
        if (
          !result ||
          !result.searchEntries ||
          result.searchEntries.length === 0
        )
          throw new BadRequestError(
            `Field ${field} points to non-existent DN: ${dnValue}`
          );
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
      } catch (err) {
        throw new BadRequestError(
          `Field ${field} points to invalid or non-existent DN: ${dnValue}`
        );
      }
    }

    if (attr.test) {
      const regex =
        typeof attr.test === 'string' ? getCompiledRegex(attr.test) : attr.test;
      if (Array.isArray(value)) {
        return value.every(v => regex.test(v as string));
      }
      return regex.test(value as string);
    }
    return true;
  }
}
