/* eslint-disable @typescript-eslint/no-use-before-define */
/*---------------------------------------------------------------------------------------------
 *  Copyright (c) Microsoft Corporation. All rights reserved.
 *  Licensed under the MIT License. See License.txt in the project root for license information.
 *--------------------------------------------------------------------------------------------*/

import type { Event, IStringDictionary } from '@difizen/mana-common';
import { Emitter, types } from '@difizen/mana-common';

import * as nls from '../../../nls';
import type { IJSONContributionRegistry } from '../jsonContributionRegistry';
import { Extensions as JSONExtensions } from '../jsonContributionRegistry';
import type { IJSONSchema } from '../jsonSchema';
import { Registry } from '../platform';

export const Extensions = {
  Configuration: 'base.contributions.configuration',
};

export interface IConfigurationRegistry {
  /**
   * Register a configuration to the registry.
   */
  registerConfiguration: (configuration: IConfigurationNode) => void;

  /**
   * Register multiple configurations to the registry.
   */
  registerConfigurations: (
    configurations: IConfigurationNode[],
    validate?: boolean,
  ) => void;

  /**
   * Deregister multiple configurations from the registry.
   */
  deregisterConfigurations: (configurations: IConfigurationNode[]) => void;

  /**
   * Register multiple default configurations to the registry.
   */
  registerDefaultConfigurations: (
    defaultConfigurations: IStringDictionary<any>[],
  ) => void;

  /**
   * Deregister multiple default configurations from the registry.
   */
  deregisterDefaultConfigurations: (
    defaultConfigurations: IStringDictionary<any>[],
  ) => void;

  /**
   * Signal that the schema of a configuration setting has changes. It is currently only supported to change enumeration values.
   * Property or default value changes are not allowed.
   */
  notifyConfigurationSchemaUpdated: (...configurations: IConfigurationNode[]) => void;

  /**
   * Event that fires whenver a configuration has been
   * registered.
   */
  onDidSchemaChange: Event<void>;

  /**
   * Event that fires whenver a configuration has been
   * registered.
   */
  onDidUpdateConfiguration: Event<string[]>;

  /**
   * Returns all configuration nodes contributed to this registry.
   */
  getConfigurations: () => IConfigurationNode[];

  /**
   * Returns all configurations settings of all configuration nodes contributed to this registry.
   */
  getConfigurationProperties: () => Record<string, IConfigurationPropertySchema>;

  /**
   * Returns all excluded configurations settings of all configuration nodes contributed to this registry.
   */
  getExcludedConfigurationProperties: () => Record<
    string,
    IConfigurationPropertySchema
  >;

  /**
   * Register the identifiers for editor configurations
   */
  registerOverrideIdentifiers: (identifiers: string[]) => void;
}

export enum ConfigurationScope {
  /**
   * Application specific configuration, which can be configured only in local user settings.
   */
  APPLICATION = 1,
  /**
   * Machine specific configuration, which can be configured only in local and remote user settings.
   */
  MACHINE,
  /**
   * Window specific configuration, which can be configured in the user or workspace settings.
   */
  WINDOW,
  /**
   * Resource specific configuration, which can be configured in the user, workspace or folder settings.
   */
  RESOURCE,
  /**
   * Resource specific configuration that can be configured in language specific settings
   */
  LANGUAGE_OVERRIDABLE,
  /**
   * Machine specific configuration that can also be configured in workspace or folder settings.
   */
  MACHINE_OVERRIDABLE,
}

export interface IConfigurationPropertySchema extends IJSONSchema {
  scope?: ConfigurationScope | undefined;
  included?: boolean;
  tags?: string[];
  /**
   * When enabled this setting is ignored during sync and user can override this.
   */
  ignoreSync?: boolean;
  /**
   * When enabled this setting is ignored during sync and user cannot override this.
   */
  disallowSyncIgnore?: boolean;
  enumItemLabels?: string[];
}

export interface IConfigurationExtensionInfo {
  id: string;
}

export interface IConfigurationNode {
  id?: string;
  order?: number;
  type?: string | string[];
  title?: string;
  description?: string;
  properties?: Record<string, IConfigurationPropertySchema>;
  allOf?: IConfigurationNode[];
  scope?: ConfigurationScope;
  extensionInfo?: IConfigurationExtensionInfo;
}

type SettingProperties = Record<string, any>;

export const allSettings: {
  properties: SettingProperties;
  patternProperties: SettingProperties;
} = { properties: {}, patternProperties: {} };
export const applicationSettings: {
  properties: SettingProperties;
  patternProperties: SettingProperties;
} = { properties: {}, patternProperties: {} };
export const machineSettings: {
  properties: SettingProperties;
  patternProperties: SettingProperties;
} = { properties: {}, patternProperties: {} };
export const machineOverridableSettings: {
  properties: SettingProperties;
  patternProperties: SettingProperties;
} = { properties: {}, patternProperties: {} };
export const windowSettings: {
  properties: SettingProperties;
  patternProperties: SettingProperties;
} = { properties: {}, patternProperties: {} };
export const resourceSettings: {
  properties: SettingProperties;
  patternProperties: SettingProperties;
} = { properties: {}, patternProperties: {} };

export const resourceLanguageSettingsSchemaId =
  'vscode://schemas/settings/resourceLanguage';

const contributionRegistry = Registry.as<IJSONContributionRegistry>(
  JSONExtensions.JSONContribution,
);

class ConfigurationRegistry implements IConfigurationRegistry {
  private readonly defaultValues: IStringDictionary<any>;
  private readonly defaultLanguageConfigurationOverridesNode: IConfigurationNode;
  private readonly configurationContributors: IConfigurationNode[];
  private readonly configurationProperties: Record<string, IJSONSchema>;
  private readonly excludedConfigurationProperties: Record<string, IJSONSchema>;
  private readonly resourceLanguageSettingsSchema: IJSONSchema;
  private readonly overrideIdentifiers = new Set<string>();

  private readonly _onDidSchemaChange = new Emitter<void>();
  readonly onDidSchemaChange: Event<void> = this._onDidSchemaChange.event;

  private readonly _onDidUpdateConfiguration: Emitter<string[]> = new Emitter<
    string[]
  >();
  readonly onDidUpdateConfiguration: Event<string[]> =
    this._onDidUpdateConfiguration.event;

  constructor() {
    this.defaultValues = {};
    this.defaultLanguageConfigurationOverridesNode = {
      id: 'defaultOverrides',
      title: nls.localize(
        'defaultLanguageConfigurationOverrides.title',
        'Default Language Configuration Overrides',
      ),
      properties: {},
    };
    this.configurationContributors = [this.defaultLanguageConfigurationOverridesNode];
    this.resourceLanguageSettingsSchema = {
      properties: {},
      patternProperties: {},
      additionalProperties: false,
      errorMessage: 'Unknown editor configuration setting',
      allowTrailingCommas: true,
      allowComments: true,
    };
    this.configurationProperties = {};
    this.excludedConfigurationProperties = {};

    contributionRegistry.registerSchema(
      resourceLanguageSettingsSchemaId,
      this.resourceLanguageSettingsSchema,
    );
  }

  public registerConfiguration(
    configuration: IConfigurationNode,
    validate = true,
  ): void {
    this.registerConfigurations([configuration], validate);
  }

  public registerConfigurations(
    configurations: IConfigurationNode[],
    validate = true,
  ): void {
    const properties: string[] = [];
    configurations.forEach((configuration) => {
      properties.push(...this.validateAndRegisterProperties(configuration, validate)); // fills in defaults
      this.configurationContributors.push(configuration);
      this.registerJSONConfiguration(configuration);
    });

    contributionRegistry.registerSchema(
      resourceLanguageSettingsSchemaId,
      this.resourceLanguageSettingsSchema,
    );
    this._onDidSchemaChange.fire();
    this._onDidUpdateConfiguration.fire(properties);
  }

  public deregisterConfigurations(configurations: IConfigurationNode[]): void {
    const properties: string[] = [];
    const deregisterConfiguration = (configuration: IConfigurationNode) => {
      if (configuration.properties) {
        for (const key in configuration.properties) {
          properties.push(key);
          delete this.configurationProperties[key];
          this.removeFromSchema(key, configuration.properties[key]);
        }
      }
      if (configuration.allOf) {
        configuration.allOf.forEach((node) => deregisterConfiguration(node));
      }
    };
    for (const configuration of configurations) {
      deregisterConfiguration(configuration);
      const index = this.configurationContributors.indexOf(configuration);
      if (index !== -1) {
        this.configurationContributors.splice(index, 1);
      }
    }

    contributionRegistry.registerSchema(
      resourceLanguageSettingsSchemaId,
      this.resourceLanguageSettingsSchema,
    );
    this._onDidSchemaChange.fire();
    this._onDidUpdateConfiguration.fire(properties);
  }

  public registerDefaultConfigurations(
    defaultConfigurations: IStringDictionary<any>[],
  ): void {
    const properties: string[] = [];
    const overrideIdentifiers: string[] = [];

    for (const defaultConfiguration of defaultConfigurations) {
      for (const key in defaultConfiguration) {
        properties.push(key);

        if (OVERRIDE_PROPERTY_PATTERN.test(key)) {
          this.defaultValues[key] = {
            ...(this.defaultValues[key] || {}),
            ...defaultConfiguration[key],
          };
          const property: IConfigurationPropertySchema = {
            type: 'object',
            default: this.defaultValues[key],
            description: nls.localize(
              'defaultLanguageConfiguration.description',
              'Configure settings to be overridden for {0} language.',
              key,
            ),
            $ref: resourceLanguageSettingsSchemaId,
          };
          overrideIdentifiers.push(overrideIdentifierFromKey(key));
          this.configurationProperties[key] = property;
          this.defaultLanguageConfigurationOverridesNode.properties![key] = property;
        } else {
          this.defaultValues[key] = defaultConfiguration[key];
          const property = this.configurationProperties[key];
          if (property) {
            this.updatePropertyDefaultValue(key, property);
            this.updateSchema(key, property);
          }
        }
      }
    }

    this.registerOverrideIdentifiers(overrideIdentifiers);
    this._onDidSchemaChange.fire();
    this._onDidUpdateConfiguration.fire(properties);
  }

  public deregisterDefaultConfigurations(
    defaultConfigurations: IStringDictionary<any>[],
  ): void {
    const properties: string[] = [];
    for (const defaultConfiguration of defaultConfigurations) {
      for (const key in defaultConfiguration) {
        properties.push(key);
        delete this.defaultValues[key];
        if (OVERRIDE_PROPERTY_PATTERN.test(key)) {
          delete this.configurationProperties[key];
          delete this.defaultLanguageConfigurationOverridesNode.properties![key];
        } else {
          const property = this.configurationProperties[key];
          if (property) {
            this.updatePropertyDefaultValue(key, property);
            this.updateSchema(key, property);
          }
        }
      }
    }

    this.updateOverridePropertyPatternKey();
    this._onDidSchemaChange.fire();
    this._onDidUpdateConfiguration.fire(properties);
  }

  public notifyConfigurationSchemaUpdated(..._configurations: IConfigurationNode[]) {
    this._onDidSchemaChange.fire();
  }

  public registerOverrideIdentifiers(overrideIdentifiers: string[]): void {
    for (const overrideIdentifier of overrideIdentifiers) {
      this.overrideIdentifiers.add(overrideIdentifier);
    }
    this.updateOverridePropertyPatternKey();
  }

  private validateAndRegisterProperties(
    configuration: IConfigurationNode,
    validate = true,
    scope: ConfigurationScope = ConfigurationScope.WINDOW,
  ): string[] {
    scope = types.isUndefinedOrNull(configuration.scope) ? scope : configuration.scope;
    const propertyKeys: string[] = [];
    const { properties } = configuration;
    if (properties) {
      for (const key in properties) {
        if (validate && validateProperty(key)) {
          delete properties[key];
          continue;
        }

        const property = properties[key];

        // update default value
        this.updatePropertyDefaultValue(key, property);

        // update scope
        if (OVERRIDE_PROPERTY_PATTERN.test(key)) {
          property.scope = undefined; // No scope for overridable properties `[${identifier}]`
        } else {
          property.scope = types.isUndefinedOrNull(property.scope)
            ? scope
            : property.scope;
        }

        // Add to properties maps
        // Property is included by default if 'included' is unspecified
        if (
          Object.prototype.hasOwnProperty.call(properties[key], 'included') &&
          !properties[key].included
        ) {
          this.excludedConfigurationProperties[key] = properties[key];
          delete properties[key];
          continue;
        } else {
          this.configurationProperties[key] = properties[key];
        }

        if (
          !properties[key].deprecationMessage &&
          properties[key].markdownDeprecationMessage
        ) {
          // If not set, default deprecationMessage to the markdown source
          properties[key].deprecationMessage =
            properties[key].markdownDeprecationMessage;
        }

        propertyKeys.push(key);
      }
    }
    const subNodes = configuration.allOf;
    if (subNodes) {
      for (const node of subNodes) {
        propertyKeys.push(...this.validateAndRegisterProperties(node, validate, scope));
      }
    }
    return propertyKeys;
  }

  getConfigurations(): IConfigurationNode[] {
    return this.configurationContributors;
  }

  getConfigurationProperties(): Record<string, IConfigurationPropertySchema> {
    return this.configurationProperties;
  }

  getExcludedConfigurationProperties(): Record<string, IConfigurationPropertySchema> {
    return this.excludedConfigurationProperties;
  }

  private registerJSONConfiguration(configuration: IConfigurationNode) {
    const register = (configuration: IConfigurationNode) => {
      const { properties } = configuration;
      if (properties) {
        for (const key in properties) {
          this.updateSchema(key, properties[key]);
        }
      }
      const subNodes = configuration.allOf;
      if (subNodes) {
        subNodes.forEach(register);
      }
    };
    register(configuration);
  }

  private updateSchema(key: string, property: IConfigurationPropertySchema): void {
    allSettings.properties[key] = property;
    switch (property.scope) {
      case ConfigurationScope.APPLICATION:
        applicationSettings.properties[key] = property;
        break;
      case ConfigurationScope.MACHINE:
        machineSettings.properties[key] = property;
        break;
      case ConfigurationScope.MACHINE_OVERRIDABLE:
        machineOverridableSettings.properties[key] = property;
        break;
      case ConfigurationScope.WINDOW:
        windowSettings.properties[key] = property;
        break;
      case ConfigurationScope.RESOURCE:
        resourceSettings.properties[key] = property;
        break;
      case ConfigurationScope.LANGUAGE_OVERRIDABLE:
        resourceSettings.properties[key] = property;
        this.resourceLanguageSettingsSchema.properties![key] = property;
        break;
    }
  }

  private removeFromSchema(key: string, property: IConfigurationPropertySchema): void {
    delete allSettings.properties[key];
    switch (property.scope) {
      case ConfigurationScope.APPLICATION:
        delete applicationSettings.properties[key];
        break;
      case ConfigurationScope.MACHINE:
        delete machineSettings.properties[key];
        break;
      case ConfigurationScope.MACHINE_OVERRIDABLE:
        delete machineOverridableSettings.properties[key];
        break;
      case ConfigurationScope.WINDOW:
        delete windowSettings.properties[key];
        break;
      case ConfigurationScope.RESOURCE:
      case ConfigurationScope.LANGUAGE_OVERRIDABLE:
        delete resourceSettings.properties[key];
        break;
    }
  }

  private updateOverridePropertyPatternKey(): void {
    for (const overrideIdentifier of this.overrideIdentifiers.values()) {
      const overrideIdentifierProperty = `[${overrideIdentifier}]`;
      const resourceLanguagePropertiesSchema: IJSONSchema = {
        type: 'object',
        description: nls.localize(
          'overrideSettings.defaultDescription',
          'Configure editor settings to be overridden for a language.',
        ),
        errorMessage: nls.localize(
          'overrideSettings.errorMessage',
          'This setting does not support per-language configuration.',
        ),
        $ref: resourceLanguageSettingsSchemaId,
      };
      this.updatePropertyDefaultValue(
        overrideIdentifierProperty,
        resourceLanguagePropertiesSchema,
      );
      allSettings.properties[overrideIdentifierProperty] =
        resourceLanguagePropertiesSchema;
      applicationSettings.properties[overrideIdentifierProperty] =
        resourceLanguagePropertiesSchema;
      machineSettings.properties[overrideIdentifierProperty] =
        resourceLanguagePropertiesSchema;
      machineOverridableSettings.properties[overrideIdentifierProperty] =
        resourceLanguagePropertiesSchema;
      windowSettings.properties[overrideIdentifierProperty] =
        resourceLanguagePropertiesSchema;
      resourceSettings.properties[overrideIdentifierProperty] =
        resourceLanguagePropertiesSchema;
    }
    this._onDidSchemaChange.fire();
  }

  private updatePropertyDefaultValue(
    key: string,
    property: IConfigurationPropertySchema,
  ): void {
    let defaultValue = this.defaultValues[key];
    if (types.isUndefined(defaultValue)) {
      defaultValue = property.default;
    }
    if (types.isUndefined(defaultValue)) {
      defaultValue = getDefaultValue(property.type);
    }
    property.default = defaultValue;
  }
}

const OVERRIDE_PROPERTY = '\\[.*\\]$';
export const OVERRIDE_PROPERTY_PATTERN = new RegExp(OVERRIDE_PROPERTY);

export function overrideIdentifierFromKey(key: string): string {
  return key.substring(1, key.length - 1);
}

export function getDefaultValue(type: string | string[] | undefined): any {
  const t = Array.isArray(type) ? (<string[]>type)[0] : <string>type;
  switch (t) {
    case 'boolean':
      return false;
    case 'integer':
    case 'number':
      return 0;
    case 'string':
      return '';
    case 'array':
      return [];
    case 'object':
      return {};
    default:
      return null;
  }
}

const configurationRegistry = new ConfigurationRegistry();
Registry.add(Extensions.Configuration, configurationRegistry);

export function validateProperty(property: string): string | null {
  if (!property.trim()) {
    return nls.localize('config.property.empty', 'Cannot register an empty property');
  }
  if (OVERRIDE_PROPERTY_PATTERN.test(property)) {
    return nls.localize(
      'config.property.languageDefault',
      "Cannot register '{0}'. This matches property pattern '\\\\[.*\\\\]$' for describing language specific editor settings. Use 'configurationDefaults' contribution.",
      property,
    );
  }
  if (configurationRegistry.getConfigurationProperties()[property] !== undefined) {
    return nls.localize(
      'config.property.duplicate',
      "Cannot register '{0}'. This property is already registered.",
      property,
    );
  }
  return null;
}

export function getScopes(): [string, ConfigurationScope | undefined][] {
  const scopes: [string, ConfigurationScope | undefined][] = [];
  const configurationProperties = configurationRegistry.getConfigurationProperties();
  for (const key of Object.keys(configurationProperties)) {
    scopes.push([key, configurationProperties[key].scope]);
  }
  scopes.push(['launch', ConfigurationScope.RESOURCE]);
  scopes.push(['task', ConfigurationScope.RESOURCE]);
  return scopes;
}
