//
// Copyright 2021 Google Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
//

@use 'sass:list';
@use 'sass:map';
@use 'sass:meta';
@use 'sass:string';
@use './custom-properties';

/// A flat Map of keys and their values. Keys may be set to any static CSS
/// value, or another key's name to resolve to that key's value.
///
/// @example - scss
///   $_store: (
///     primary: purple, // keys may be set to CSS values...
///     button-color: primary, // ...or resolve to another key's value
///   );
///
/// @type {Map}
$_store: ();
/// A flat Map of relationship links between keys. While key values may
/// resolve to another key's value in the key store, the store does not
/// preserve or infer relationships between keys.
///
/// Instead, this link Map records the original relationship between keys as
/// their values are updated and potentially overridden with customizations.
///
/// @example - scss
///   // Given these keys...
///   $primary: purple;
///   $button-color: $primary; // ...button-color is linked to the primary key
///
///   // A key store with value customizations may look like this:
///   $_store: (
///     primary: amber,
///     button-color: teal, // the relationship is lost with a customization
///   );
///
///   // The links Map preserves the relationship for custom property
///   // generation, while the store Map is only focused on values.
///   $_links: (
///     button-color: primary,
///   );
///
/// @type {Map}
$_links: ();
/// A map of key options. If a key has options, it will have an entry in this
/// variable with a Map value with options for the key.
///
/// @example - scss
///   // Option structure
///   $_options: (
///     key-name: (
///       // An additional prefix to add when generating the varname of a
///       // key's custom property
///       // --mdc-<prefix>-key-name
///       custom-property-prefix: prefix
///     )
///   );
///
/// @type {Map}
$_options: ();

/// Indicates whether or not the provided value is a registered key.
///
/// @param {String} $key - One or more key parts to check
/// @return {Bool} True if the key is registered, or false if it is not.
@function is-key($key...) {
  $key: combine($key...);
  @return map.has-key($_store, $key);
}

/// Retrieves a List of all keys matching the provided key group prefix.
///
/// @example - scss
///   $keys: get-keys(typography);
///   // (typography-headline,
///   //  typography-body,
///   //  typography-body-font,
///   //  typography-body-size)
///
/// @param {String} $group - Optional group prefix to search by. If ommitted,
///     all registered keys will be returned.
/// @return {List} A List of all keys matching the group prefix.
@function get-keys($group: '') {
  $keys: ();
  @each $key in map.keys($_store) {
    @if string.index($key, $group) == 1 {
      $keys: list.append($keys, $key);
    }
  }

  @return $keys;
}

/// Registers a Map of keys and their values with the key store. Key values may
/// either be CSS values or other key strings.
///
/// @example - scss
///   @include set-values((
///     primary: teal,
///     label-color: primary
///   ));
///
/// Options may also be added for each key by providing an `$options` parameter.
///
/// @example - scss
///   @include set-values(
///     $key-map,
///     $options: (
///       // An additional prefix to add when generating the varname of a
///       // key's custom property
///       // --mdc-<prefix>-key-name
///       custom-property-prefix: prefix
///     )
///   );
///
/// Note that this mixin only sets key values. If a key points to another key,
/// it does not link those keys when custom properties are emitted. Use
/// `add-link()` or `register-theme()` to create links between keys.
///
/// @see {mixin} set-value
/// @see {mixin} add-link
/// @see {mixin} register-theme
///
/// @param {Map} $key-map - A Map of keys to register.
/// @param {Map} $options [null] - Optional Map of options to add for each key.
@mixin set-values($key-map, $options: null) {
  $unused: set-values($key-map, $options: $options);
}

/// Function version of `set-values()`.
///
/// Mixins cannot be invoked within functions in Sass. Use this when
/// `set-values()` must be used within a function. The return value may be
/// discarded or re-assigned to the `$key-map` provided.
///
/// @example - scss
///   @function foo() {
///     $unused: set-values((primary: teal));
///   }
///
///   @function bar() {
///     $key-map: (primary: teal);
///     $key-map: set-values($key-map);
///   }
///
/// @see {mixin} set-values
///
/// @return {Map} `$key-map`, unmodified, for convenience.
@function set-values($key-map, $options: null) {
  @each $key, $value in $key-map {
    $key: set-value($key, $value, $options: $options);
  }

  @return $key-map;
}

/// Sets the value of a key. Key values may either be CSS values or other key
/// strings.
///
/// @example - scss
///   @include set-value(primary, teal);
///   @include set-value(label-color, primary);
///
/// Options may also be added for each key by providing an `$options` parameter.
///
/// @example - scss
///   @include set-value(key-name, teal, $options: (
///     // An additional prefix to add when generating the varname of a
///     // key's custom property
///     // --mdc-<prefix>-key-name
///     custom-property-prefix: prefix
///   ));
///
/// Note that this mixin only sets the key's value. If the key points to another
/// key, it does not link those keys when custom properties are emitted. Use
/// `add-link()` or `register-theme()` to create links between keys.
///
/// @see {mixin} add-link
/// @see {mixin} register-theme
///
/// @param {String} $key - The key to set a value for.
/// @param {*} $value - The value of the key.
/// @param {Map} $options [null] - Optional Map of options to add for each key.
@mixin set-value($key, $value, $options: null) {
  $unused: set-value($key, $value, $options: $options);
}

/// Function version of `set-value()`.
///
/// Mixins cannot be invoked within functions in Sass. Use this when
/// `set-value()` must be used within a function. The return value may be
/// discarded or re-assigned to the `$key` provided.
///
/// @example - scss
///   @function foo() {
///     $unused: set-value(primary, teal);
///   }
///
///   @function bar() {
///     $key: primary;
///     $key: set-value($key, teal);
///   }
///
/// @see {mixin} set-value
///
/// @return {String} `$key`, unmodified, for convenience.
@function set-value($key, $value, $options: null) {
  // Use !global to avoid shadowing
  // https://sass-lang.com/documentation/variables#shadowing
  $_store: map.set($_store, $key, $value) !global;
  @if $options {
    $_options: map.set($_options, $key, $options) !global;
  }

  @return $key;
}

/// Add a link between two keys.
///
/// When keys are linked and chained custom properties are emitted, the value
/// of `$key` will always include the `var()` function of its linked key, even
/// if it overrides its linked key's value.
///
/// @example - scss
///   @include add-link(label-color, primary);
///   @include set-values((
///     primary: teal,
///     label-color: amber
///   ));
///
///   .primary {
///     @include theme.property(color, primary);
///   }
///
///   .label-color {
///     @include theme.property(color, label-color);
///   }
///
/// @example - css
///   .primary {
///     color: var(--primary, teal);
///   }
///
///   .label-color {
///     color: var(--label-color, var(--primary, amber));
///   }
///
///
/// If a key does not already have a value set, its value will be set to the
/// linked key provided.
///
/// @param {String} $key - The key to add a link to.
/// @param {String} $link - The name to link to `$key`.
/// @throw When attempting to change the link of a key that has already been
///     linked.
@mixin add-link($key, $link) {
  $unused: add-link($key, $link);
}

/// Function version of `add-link()`.
///
/// Mixins cannot be invoked within functions in Sass. Use this when
/// `add-link()` must be used within a function. The return value may be
/// discarded or re-assigned to the `$key` provided.
///
/// @example - scss
///   @function foo() {
///     $unused: add-link(label-color, primary);
///   }
///
///   @function bar() {
///     $key: label-color;
///     $key: set-value($key, primary);
///   }
///
/// @see {mixin} `add-link()`
///
/// @return {String} `$key` for convenience.
@function add-link($key, $link) {
  @if map.has-key($_links, $key) {
    @error '#{$key} already has a link';
  }

  // Use !global to avoid shadowing
  // https://sass-lang.com/documentation/variables#shadowing
  $_links: map.set($_links, $key, $link) !global;
  @if not map.has-key($_store, $key) {
    $key: set-value($key, $link);
  }

  @return $key;
}

/// Resolve a key to its CSS value. This may be a static CSS value or a dynamic
/// `var()` value.
///
/// The value that this function returns may change depending on configuration
/// options if a key's value points to another key.
///
/// To always retrieve the static CSS value a key resolves to, even if it points
/// to another key, provide `$deep: true` as a parameter to the function.
///
/// @param {String...} $key - One or more key parts to resolve to a CSS value.
/// @param {Bool} $deep [false] - Set to true as a named parameter to always
///     resolve the key to its static CSS value and not a dynamic `var()` value.
/// @return {*} The value the key resolves to. This may be `null` if the key
///     (or the key it points to) has not been registered.
@function resolve($key...) {
  $deep: map.get(meta.keywords($key), deep);
  $key: combine($key...);

  $value: map.get($_store, $key);
  @if is-key($value) {
    $value: resolve($value);
  }

  @return $value;
}

/// Register a `$theme` Map variable's keys. This should only be done once in
/// the `theme-styles()` mixin with the canonical `$theme` Map to initialize
/// default values and linked keys.
///
/// @example - scss
///   @mixin theme-styles($theme: button-filled-theme.$light-theme) {
///     @include keys.register-theme($theme, button-filled);
///     @include button-filled-theme.theme($theme);
///   }
///
/// A component's `$theme` Map may have shared keys (such as color, shape, and
/// typography) that need linked before user customization with the `theme()`
/// mixin.
///
/// The `register-theme()` mixin handles adding these links with `add-link()`
/// dynamically from a canonical `$theme` configuration provided by a trusted
/// source in `theme-styles()`. Subsequent calls to `theme()` will not invoke
/// `register-theme()` or change the linked keys' registration.
///
/// @param {Map} $theme - The theme Map to register keys for.
/// @param {String} $prefix [null] - Optional prefix to prepend before each key.
/// @param {Map} $options [null] - Optional Map of options to add for each key.
@mixin register-theme($theme, $prefix: null, $options: null) {
  // The first $theme Map received in theme-styles() should be used to
  // register keys.
  // Subsequent calls to theme() to customize key values will not be
  // wrapped within theme-styles() and will not change the registered
  // key values (or more importantly, their links), since
  // customizations may be simple one-offs.
  @each $key, $value in $theme {
    @if $value != null {
      $key: combine($prefix, $key);
      @include set-value($key, $value, $options: $options);
      @if is-key($value) {
        @include add-link($key, $link: $value);
      }
    }
  }
}

/// Create and resolve custom properties from a user-provided `$theme` Map
/// variable. The created custom properties are returned in a Map that matches
/// the key structure of `$theme`.
///
/// This function should be used within a `theme()` mixin after validation and
/// before providing any values to subsequent mixins. This will ensure that all
/// values are custom properties to support runtime theming.
///
/// @example - scss
///   $light-theme: (
///     label-color: on-primary
///   );
///
///   @mixin theme($theme) {
///     $theme: keys.create-theme-properties($theme, button-filled);
///     /*(
///       label-color: (
///         varname: --mdc-button-filled-label-color,
///         fallback: (
///           varname: --mdc-theme-on-primary,
///           fallback: white,
///         )
///       )
///     )*/
///   }
///
/// @param {Map} $theme - The theme Map to create custom properties for.
/// @param {String} $prefix [null] - Optional prefix to prepend for each key's
///     custom property.
/// @return {Map} A similar `$theme` Map whose values are replaced with the
///     newly created and resolved custom properties.
@function create-theme-properties($theme, $prefix: null) {
  $theme-with-props: ();
  @each $name, $value in $theme {
    @if $value != null {
      @if is-key($value) {
        $value: create-custom-property($value);
      }

      $key: combine($prefix, $name);

      @if _is-map($value) {
        @each $k, $v in $value {
          $theme-with-props: map.set(
            $theme-with-props,
            $name,
            $k,
            custom-properties.create(_create-varname(combine($key, $k)), $v)
          );
        }
      } @else {
        $theme-with-props: map.set(
          $theme-with-props,
          $name,
          custom-properties.create(_create-varname($key), $value)
        );
      }
    }
  }

  @return $theme-with-props;
}

/// Create a custom property for a key that represents the key's linked
/// relationships and final resolved static value.
///
/// This function ignores customization options and is intended to return the
/// most accurate data structure representation of a key. Customization options
/// (such as custom property configuration) will change how the returned value
/// is emitted.
///
/// @param {$tring...} $key - One or more key parts to create a custom property
///     for.
/// @return {Map} A custom property Map for the key.
@function create-custom-property($key...) {
  $key: combine($key...);
  $prop: custom-properties.create(_create-varname($key));
  $link: map.get($_links, $key);
  @if $link {
    $prop: custom-properties.set-fallback($prop, create-custom-property($link));
  }

  @return custom-properties.set-fallback($prop, resolve($key, $deep: true));
}

@mixin declare-custom-properties($theme, $prefix: null) {
  $theme: create-theme-properties($theme, $prefix);

  @each $key, $value in $theme {
    @if _is-map($value) {
      @each $k, $v in $value {
        @include custom-properties.declaration($v);
      }
    } @else {
      @include custom-properties.declaration($value);
    }
  }
}

/// Creates a custom property varname for a key. This function will add a key's
/// option's `custom-property-prefix` if it exists.
///
/// @param {String...} $key - One or more key parts to create a varname for.
/// @return {String} The key's custom property varname.
@function _create-varname($key...) {
  $key: combine($key...);
  $prefix: map.get($_options, $key, custom-property-prefix);
  @if $prefix {
    $key: combine($prefix, $key);
  }

  @return custom-properties.create-varname($key);
}

/// Combines one or more key parts into a key.
///
/// @example - scss
///   $key: combine(body, font-size);
///   // body-font-size
///
/// @param {String...} $parts - Arbitrary number of string key parts to combine.
/// @return {String} A combined key string.
@function combine($parts...) {
  // Allow extra keywords to be passed to other functions without impacting this
  // function, which does not expect any keywords.
  $unused: meta.keywords($parts);
  $key: '';
  @each $part in $parts {
    @if $part {
      @if $key == '' {
        $key: $part;
      } @else {
        $key: #{$key}-#{$part};
      }
    }
  }

  @return $key;
}

@function _is-map($map) {
  @return meta.type-of($map) == 'map' and not
    custom-properties.is-custom-prop($map);
}
