/**
 * Twake Drive Plugin
 *
 * Synchronizes LDAP user changes with Twake Drive via Admin API.
 * Propagates email address, display name, and disk quota changes.
 *
 * @see https://docs.cozy.io/en/cozy-stack/admin/
 */
import fetch from 'node-fetch';

import TwakePlugin from '../../abstract/twakePlugin';
import { type Role } from '../../abstract/plugin';
import type { DM } from '../../bin';
import type { AttributeValue } from '../../lib/ldapActions';
import { Hooks } from '../../hooks';

export default class Drive extends TwakePlugin {
  name = 'drive';
  roles: Role[] = ['consistency'] as const;

  dependencies = {
    onLdapChange: 'core/ldap/onChange',
  };

  // Drive-specific configuration attributes
  private cozyDomainAttr: string;
  private defaultDomainTemplate: string;
  private templateAttrs: string[];
  private driveQuotaAttr: string;

  constructor(server: DM) {
    super(
      server,
      'twake_drive_webadmin_url',
      'twake_drive_webadmin_token',
      'twake_drive_concurrency'
    );

    // LDAP attribute that stores the Cozy instance domain (e.g., "john.mycozy.cloud")
    this.cozyDomainAttr =
      (this.config.twake_drive_domain_attribute as string) || 'twakeCozyDomain';

    // Default domain template for fallback (e.g., "{uid}.company.cloud")
    this.defaultDomainTemplate =
      (this.config.twake_drive_default_domain_template as string) || '';

    // Extract attribute names from template placeholders (e.g., "{uid}" → "uid")
    this.templateAttrs = this.defaultDomainTemplate
      ? [...this.defaultDomainTemplate.matchAll(/\{(\w+)\}/g)].map(m => m[1])
      : [];

    // LDAP attribute that stores the drive quota in bytes
    this.driveQuotaAttr =
      (this.config.drive_quota_attribute as string) || 'twakeDriveQuota';
  }

  /**
   * Override createHeaders to use Basic Auth for Cozy Admin API
   * Cozy uses Basic Auth with empty username and password as the token
   * @param contentType Optional Content-Type header value
   * @returns Headers object with Basic Auth
   */
  protected override createHeaders(contentType?: string): {
    Authorization?: string;
    'Content-Type'?: string;
  } {
    const headers: { Authorization?: string; 'Content-Type'?: string } = {};
    if (this.webadminToken) {
      // Cozy Admin API uses Basic Auth with empty username
      const basicAuth = Buffer.from(`:${this.webadminToken}`).toString(
        'base64'
      );
      headers.Authorization = `Basic ${basicAuth}`;
    }
    if (contentType) {
      headers['Content-Type'] = contentType;
    }
    return headers;
  }

  /**
   * Validate Cozy domain format to prevent URL injection attacks
   * @param domain Cozy domain to validate
   * @returns true if domain format is valid
   */
  private isValidCozyDomain(domain: string): boolean {
    // Allow only valid domain characters (alphanumeric, dots, hyphens)
    // Reject any path separators, query strings, or fragments
    // Domain must start and end with alphanumeric, and be between 1-253 chars
    if (!domain || domain.length > 253) return false;
    if (
      domain.includes('/') ||
      domain.includes('\\') ||
      domain.includes('?') ||
      domain.includes('#')
    ) {
      return false;
    }
    // Valid domain pattern: one or more labels separated by dots
    // Each label must start and end with alphanumeric, may contain hyphens in the middle
    // Single-label domains (e.g., "localhost", "cozy-server") are allowed for local/search domain resolution
    const domainRegex =
      /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
    return domainRegex.test(domain);
  }

  /**
   * Get Cozy domain for a user from LDAP
   * Falls back to template-based domain if attribute not found
   * @param dn User's LDAP DN
   * @returns Cozy domain or null if not found
   */
  async getCozyDomain(dn: string): Promise<string | null> {
    // Fetch cozy domain attribute + any attributes needed for template
    const attrsToFetch = [this.cozyDomainAttr, ...this.templateAttrs];
    const entry = await this.ldapGetAttributes(dn, attrsToFetch);

    // First try the explicit Cozy domain attribute
    const explicitDomain = entry
      ? this.attributeToString(entry[this.cozyDomainAttr])
      : null;
    if (explicitDomain) {
      return explicitDomain;
    }

    // Fallback: use template if configured
    if (this.defaultDomainTemplate && entry) {
      let domain = this.defaultDomainTemplate;
      for (const attr of this.templateAttrs) {
        const value = this.attributeToString(entry[attr]);
        if (!value) {
          // Missing attribute, can't build domain from template
          return null;
        }
        domain = domain.replace(`{${attr}}`, value);
      }
      return domain;
    }

    return null;
  }

  /**
   * Update Cozy instance via Admin API
   * @param hookname Hook name for logging
   * @param cozyDomain Cozy instance domain
   * @param dn User's LDAP DN
   * @param params Query parameters to update
   */
  private async updateCozyInstance(
    hookname: string,
    cozyDomain: string,
    dn: string,
    params: Record<string, string>
  ): Promise<void> {
    // Validate cozyDomain to prevent URL injection attacks
    if (!this.isValidCozyDomain(cozyDomain)) {
      this.logger.error({
        plugin: this.name,
        event: hookname,
        result: 'error',
        dn,
        cozyDomain,
        error: 'Invalid Cozy domain format (potential URL injection attempt)',
      });
      return;
    }

    // Build URL with query parameters
    const url = new URL(`${this.webadminUrl}/instances/${cozyDomain}`);

    // Always add FromCloudery=true to prevent callback loops
    url.searchParams.set('FromCloudery', 'true');

    // Add update parameters
    for (const [key, value] of Object.entries(params)) {
      url.searchParams.set(key, value);
    }

    return this.requestLimit(async () => {
      const log = {
        plugin: this.name,
        event: hookname,
        result: 'error',
        dn,
        cozyDomain,
        ...params,
      };

      try {
        const res = await fetch(url.toString(), {
          method: 'PATCH',
          headers: this.createHeaders('application/json'),
        });

        if (!res.ok) {
          // 404 is acceptable - instance may not exist yet
          if (res.status === 404) {
            this.logger.debug({
              ...log,
              result: 'ignored',
              http_status: res.status,
              http_status_text: res.statusText,
              message: 'Cozy instance not found (may not be provisioned yet)',
            });
          } else {
            this.logger.error({
              ...log,
              http_status: res.status,
              http_status_text: res.statusText,
              url: url.toString(),
            });
          }
        } else {
          this.logger.info({
            ...log,
            result: 'success',
            http_status: res.status,
          });
        }
      } catch (err) {
        this.logger.error({
          ...log,
          error: err,
          url: url.toString(),
        });
      }
    });
  }

  hooks: Hooks = {
    /**
     * Handle email address changes
     * Updates the Cozy instance's Email parameter
     */
    onLdapMailChange: async (
      dn: string,
      oldmail: AttributeValue | null,
      newmail: AttributeValue | null
    ) => {
      // Convert to strings
      const oldmailStr = oldmail
        ? Array.isArray(oldmail)
          ? String(oldmail[0])
          : String(oldmail)
        : null;
      const newmailStr = newmail
        ? Array.isArray(newmail)
          ? String(newmail[0])
          : String(newmail)
        : null;

      // Skip if oldmail is empty/null (this is an add, not a change)
      if (!oldmailStr) {
        this.logger.debug(
          `Skipping mail change for ${dn}: oldmail is empty (mail attribute was added, not changed)`
        );
        return;
      }

      // Skip if newmail is empty
      if (!newmailStr) {
        this.logger.debug(
          `Skipping mail change for ${dn}: newmail is empty (mail attribute was deleted)`
        );
        return;
      }

      // Get Cozy domain for this user
      const cozyDomain = await this.getCozyDomain(dn);
      if (!cozyDomain) {
        this.logger.debug(
          `Skipping mail change for ${dn}: no Cozy domain attribute (${this.cozyDomainAttr})`
        );
        return;
      }

      // Update Cozy instance email
      await this.updateCozyInstance('onLdapMailChange', cozyDomain, dn, {
        Email: newmailStr,
      });
    },

    /**
     * Handle display name changes
     * Updates the Cozy instance's PublicName parameter
     */
    onLdapDisplayNameChange: async (
      dn: string,
      _oldDisplayName: string | null,
      _newDisplayName: string | null
    ) => {
      // Get Cozy domain and display name
      const entry = await this.ldapGetAttributes(dn, [
        this.cozyDomainAttr,
        this.displayNameAttr,
        'cn',
        'givenName',
        'sn',
      ]);

      if (!entry) {
        this.logger.warn(
          `Cannot update Cozy display name: entry not found for ${dn}`
        );
        return;
      }

      const cozyDomain = this.attributeToString(entry[this.cozyDomainAttr]);
      if (!cozyDomain) {
        this.logger.debug(
          `Skipping display name change for ${dn}: no Cozy domain attribute (${this.cozyDomainAttr})`
        );
        return;
      }

      // Get display name with fallback logic
      const displayName = this.getDisplayNameFromAttributes(entry);
      if (!displayName) {
        this.logger.warn(
          `Cannot update Cozy display name: no display name found for ${dn}`
        );
        return;
      }

      // Update Cozy instance public name
      await this.updateCozyInstance('onLdapDisplayNameChange', cozyDomain, dn, {
        PublicName: displayName,
      });
    },

    /**
     * Handle drive quota changes
     * Updates the Twake Drive instance's DiskQuota parameter
     */
    onLdapDriveQuotaChange: async (
      dn: string,
      oldDriveQuota: number | null,
      newDriveQuota: number | null
    ) => {
      // Skip if newQuota is null (quota was deleted)
      if (newDriveQuota === null) {
        this.logger.debug(
          `Skipping drive quota change for ${dn}: quota was deleted`
        );
        return;
      }

      // Get Twake Drive domain for this user
      const cozyDomain = await this.getCozyDomain(dn);
      if (!cozyDomain) {
        this.logger.debug(
          `Skipping drive quota change for ${dn}: no Twake Drive domain attribute (${this.cozyDomainAttr})`
        );
        return;
      }

      // Update Twake Drive instance disk quota (in bytes)
      await this.updateCozyInstance('onLdapDriveQuotaChange', cozyDomain, dn, {
        DiskQuota: String(newDriveQuota),
      });
    },
  };

  /**
   * Extract display name from LDAP attributes
   * Fallback logic: displayName → cn → givenName+sn
   */
  private getDisplayNameFromAttributes(
    attributes: import('../../lib/ldapActions').AttributesList
  ): string | null {
    // 1. Try displayName first
    const displayName = this.attributeToString(
      attributes[this.displayNameAttr]
    );
    if (displayName) return displayName;

    // 2. Try cn
    const cn = this.attributeToString(attributes.cn);
    if (cn) return cn;

    // 3. Try givenName + sn
    const givenName = this.attributeToString(attributes.givenName);
    const sn = this.attributeToString(attributes.sn);
    if (givenName || sn) {
      const parts = [];
      if (givenName) parts.push(givenName);
      if (sn) parts.push(sn);
      return parts.join(' ');
    }

    return null;
  }

  /**
   * Public method to get display name from DN
   * Useful for external integrations
   */
  async getDisplayNameFromDN(dn: string): Promise<string | null> {
    const attrs = [this.displayNameAttr, 'cn', 'givenName', 'sn'];
    const entry = await this.ldapGetAttributes(dn, attrs);
    return entry ? this.getDisplayNameFromAttributes(entry) : null;
  }

  /**
   * Public method to get mail from DN
   * Useful for external integrations
   */
  async getMailFromDN(dn: string): Promise<string | null> {
    const entry = await this.ldapGetAttributes(dn, [this.mailAttr]);
    return entry ? this.attributeToString(entry[this.mailAttr]) : null;
  }

  /**
   * Public method to get drive quota from DN (in bytes)
   * Useful for external integrations
   */
  async getDriveQuotaFromDN(dn: string): Promise<number | null> {
    const entry = await this.ldapGetAttributes(dn, [this.driveQuotaAttr]);
    if (!entry) return null;
    const quota = this.attributeToString(entry[this.driveQuotaAttr]);
    return quota ? Number(quota) : null;
  }

  /**
   * Public method to manually sync a user to Twake Drive
   * Useful for initial sync or manual refresh
   */
  async syncUserToCozy(dn: string): Promise<boolean> {
    const entry = await this.ldapGetAttributes(dn, [
      this.cozyDomainAttr,
      this.mailAttr,
      this.displayNameAttr,
      this.driveQuotaAttr,
      'cn',
      'givenName',
      'sn',
    ]);

    if (!entry) {
      this.logger.error(`Cannot sync user: entry not found for ${dn}`);
      return false;
    }

    const cozyDomain = this.attributeToString(entry[this.cozyDomainAttr]);
    if (!cozyDomain) {
      this.logger.error(
        `Cannot sync user: no Twake Drive domain attribute for ${dn}`
      );
      return false;
    }

    const mail = this.attributeToString(entry[this.mailAttr]);
    const displayName = this.getDisplayNameFromAttributes(entry);
    const driveQuota = this.attributeToString(entry[this.driveQuotaAttr]);

    const params: Record<string, string> = {};
    if (mail) params.Email = mail;
    if (displayName) params.PublicName = displayName;
    if (driveQuota) params.DiskQuota = driveQuota;

    if (Object.keys(params).length === 0) {
      this.logger.warn(`No attributes to sync for ${dn}`);
      return false;
    }

    await this.updateCozyInstance('syncUserToCozy', cozyDomain, dn, params);
    return true;
  }

  /**
   * Block a Cozy instance
   * @param dn User's LDAP DN
   * @param reason Optional blocking reason (e.g., 'PAYMENT_FAILED', 'LOGIN_FAILED', 'SUSPENDED')
   * @returns true if successful, false otherwise
   */
  async blockInstance(dn: string, reason?: string): Promise<boolean> {
    const cozyDomain = await this.getCozyDomain(dn);
    if (!cozyDomain) {
      this.logger.error(`Cannot block instance: no Cozy domain for ${dn}`);
      return false;
    }

    const params: Record<string, string> = { Blocked: 'true' };
    if (reason) {
      params.BlockingReason = reason;
    }

    await this.updateCozyInstance('blockInstance', cozyDomain, dn, params);
    return true;
  }

  /**
   * Unblock a Cozy instance
   * @param dn User's LDAP DN
   * @returns true if successful, false otherwise
   */
  async unblockInstance(dn: string): Promise<boolean> {
    const cozyDomain = await this.getCozyDomain(dn);
    if (!cozyDomain) {
      this.logger.error(`Cannot unblock instance: no Cozy domain for ${dn}`);
      return false;
    }

    await this.updateCozyInstance('unblockInstance', cozyDomain, dn, {
      Blocked: 'false',
    });
    return true;
  }

  /**
   * Delete a Cozy instance and all its data
   * Calls DELETE /instances/:domain on the Cozy Admin API
   * @param dn User's LDAP DN
   * @returns true if successful, false otherwise
   */
  async deleteInstance(dn: string): Promise<boolean> {
    const cozyDomain = await this.getCozyDomain(dn);
    if (!cozyDomain) {
      this.logger.error(`Cannot delete instance: no Cozy domain for ${dn}`);
      return false;
    }

    if (!this.isValidCozyDomain(cozyDomain)) {
      this.logger.error({
        plugin: this.name,
        event: 'deleteInstance',
        result: 'error',
        dn,
        cozyDomain,
        error: 'Invalid Cozy domain format (potential URL injection attempt)',
      });
      return false;
    }

    const url = `${this.webadminUrl}/instances/${cozyDomain}`;

    return this.requestLimit(async () => {
      try {
        const res = await fetch(url, {
          method: 'DELETE',
          headers: this.createHeaders(),
        });

        if (!res.ok) {
          if (res.status === 404) {
            this.logger.info({
              plugin: this.name,
              event: 'deleteInstance',
              result: 'ignored',
              dn,
              cozyDomain,
              http_status: 404,
              message:
                'Cozy instance not found (already deleted or never created)',
            });
            return true;
          }
          this.logger.error({
            plugin: this.name,
            event: 'deleteInstance',
            result: 'error',
            dn,
            cozyDomain,
            http_status: res.status,
            http_status_text: res.statusText,
            url,
          });
          return false;
        }

        this.logger.info({
          plugin: this.name,
          event: 'deleteInstance',
          result: 'success',
          dn,
          cozyDomain,
          http_status: res.status,
        });
        return true;
      } catch (err) {
        this.logger.error({
          plugin: this.name,
          event: 'deleteInstance',
          result: 'error',
          dn,
          cozyDomain,
          error: err,
          url,
        });
        return false;
      }
    });
  }
}
