@use 'sass:list';
@use 'sass:map';
@use 'sass:meta';
@use 'palette';
@use 'compatibility';

// Whether duplication warnings should be disabled. Warnings enabled by default.
$theme-ignore-duplication-warnings: false !default;

// Whether density should be generated by default.
$_generate-default-density: true !default;

// Warning that will be printed if duplicated styles are generated by a theme.
$_duplicate-warning: 'Read more about how style duplication can be avoided in a dedicated ' +
  'guide. https://github.com/angular/components/blob/master/guides/duplicate-theming-styles.md';

// These variable are not intended to be overridden externally. They use `!default` to
// avoid being reset every time this file is imported.
$_emitted-color: () !default;
$_emitted-typography: () !default;
$_emitted-density: () !default;

/// For a given hue in a palette, return the contrast color from the map of contrast palettes.
/// @param {Map} $palette The palette from which to extract a color.
/// @param {String | Number} $hue The hue for which to get a contrast color.
/// @returns {Color} The contrast color for the given palette and hue.
@function get-contrast-color-from-palette($palette, $hue) {
  @return map.get(map.get($palette, contrast), $hue);
}


/// Creates a map of hues to colors for a theme. This is used to define a theme palette in terms
/// of the Material Design hues.
/// @param {Map} $base-palette Map of hue keys to color values for the basis for this palette.
/// @param {String | Number} $default Default hue for this palette.
/// @param {String | Number} $lighter "lighter" hue for this palette.
/// @param {String | Number} $darker "darker" hue for this palette.
/// @param {String | Number} $text "text" hue for this palette.
/// @returns {Map} A complete Angular Material theming palette.
@function define-palette($base-palette, $default: 500, $lighter: 100, $darker: 700,
  $text: $default) {
  $result: map.merge($base-palette, (
    default: map.get($base-palette, $default),
    lighter: map.get($base-palette, $lighter),
    darker: map.get($base-palette, $darker),
    text: map.get($base-palette, $text),

    default-contrast: get-contrast-color-from-palette($base-palette, $default),
    lighter-contrast: get-contrast-color-from-palette($base-palette, $lighter),
    darker-contrast: get-contrast-color-from-palette($base-palette, $darker)
  ));

  // For each hue in the palette, add a "-contrast" color to the map.
  @each $hue, $color in $base-palette {
    $result: map.merge($result, (
      '#{$hue}-contrast': get-contrast-color-from-palette($base-palette, $hue)
    ));
  }

  @return $result;
}


/// Gets a color from a theme palette (the output of mat-palette).
/// The hue can be one of the standard values (500, A400, etc.), one of the three preconfigured
/// hues (default, lighter, darker), or any of the aforementioned prefixed with "-contrast".
///
/// @param {Map} $palette The palette from which to extract a color.
/// @param {String | Number} $hue The hue from the palette to use. If this is a value between 0
//     and 1, it will be treated as opacity.
/// @param {Number} $opacity The alpha channel value for the color.
/// @returns {Color} The color for the given palette, hue, and opacity.
@function get-color-from-palette($palette, $hue: default, $opacity: null) {
  // If hueKey is a number between zero and one, then it actually contains an
  // opacity value, so recall this function with the default hue and that given opacity.
  @if meta.type-of($hue) == number and $hue >= 0 and $hue <= 1 {
    @return get-color-from-palette($palette, default, $hue);
  }

  $color: map.get($palette, $hue);

  @if (meta.type-of($color) != color) {
    // If the $color resolved to something different from a color (e.g. a CSS variable),
    // we can't apply the opacity anyway so we return the value as is, otherwise Sass can
    // throw an error or output something invalid.
    @return $color;
  }

  @return rgba($color, if($opacity == null, opacity($color), $opacity));
}

// Validates the specified theme by ensuring that the optional color config defines
// a primary, accent and warn palette. Returns the theme if no failures were found.
@function _mat-validate-theme($theme) {
  @if map.get($theme, color) {
    $color: map.get($theme, color);
    @if not map.get($color, primary) {
      @error 'Theme does not define a valid "primary" palette.';
    }
    @else if not map.get($color, accent) {
      @error 'Theme does not define a valid "accent" palette.';
    }
    @else if not map.get($color, warn) {
      @error 'Theme does not define a valid "warn" palette.';
    }
  }
  @return $theme;
}

// Creates a light-themed color configuration from the specified
// primary, accent and warn palettes.
@function _mat-create-light-color-config($primary, $accent, $warn: null) {
  @return (
    primary: $primary,
    accent: $accent,
    warn: if($warn != null, $warn, define-palette(palette.$red-palette)),
    is-dark: false,
    foreground: palette.$light-theme-foreground-palette,
    background: palette.$light-theme-background-palette,
  );
}

// Creates a dark-themed color configuration from the specified
// primary, accent and warn palettes.
@function _mat-create-dark-color-config($primary, $accent, $warn: null) {
  @return (
    primary: $primary,
    accent: $accent,
    warn: if($warn != null, $warn, define-palette(palette.$red-palette)),
    is-dark: true,
    foreground: palette.$dark-theme-foreground-palette,
    background: palette.$dark-theme-background-palette,
  );
}

// TODO: Remove legacy API and rename `$primary` below to `$config`. Currently it cannot be renamed
// as it would break existing apps that set the parameter by name.

/// Creates a container object for a light theme to be given to individual component theme mixins.
/// @param {Map} $primary The theme configuration object.
/// @returns {Map} A complete Angular Material theme map.
@function define-light-theme($primary, $accent: null, $warn: define-palette(palette.$red-palette)) {
  // This function creates a container object for the individual component theme mixins. Consumers
  // can construct such an object by calling this function, or by building the object manually.
  // There are two possible ways to invoke this function in order to create such an object:
  //
  //    (1) Passing in a map that holds optional configurations for individual parts of the
  //        theming system. For `color` configurations, the function only expects the palettes
  //        for `primary` and `accent` (and optionally `warn`). The function will expand the
  //        shorthand into an actual configuration that can be consumed in `-color` mixins.
  //    (2) Legacy pattern: Passing in the palettes as parameters. This is not as flexible
  //        as passing in a configuration map because only the `color` system can be configured.
  //
  // If the legacy pattern is used, we generate a container object only with a light-themed
  // configuration for the `color` theming part.
  @if $accent != null {
    @return private-create-backwards-compatibility-theme(_mat-validate-theme((
      _is-legacy-theme: true,
      color: _mat-create-light-color-config($primary, $accent, $warn),
    )));
  }
  // If the map pattern is used (1), we just pass-through the configurations for individual
  // parts of the theming system, but update the `color` configuration if set. As explained
  // above, the color shorthand will be expanded to an actual light-themed color configuration.
  $result: $primary;
  @if map.get($primary, color) {
    $color-settings: map.get($primary, color);
    $primary: map.get($color-settings, primary);
    $accent: map.get($color-settings, accent);
    $warn: map.get($color-settings, warn);
    $result: map.merge($result, (color: _mat-create-light-color-config($primary, $accent, $warn)));
  }
  @return private-create-backwards-compatibility-theme(_mat-validate-theme($result));
}

// TODO: Remove legacy API and rename below `$primary` to `$config`. Currently it cannot be renamed
// as it would break existing apps that set the parameter by name.

/// Creates a container object for a dark theme to be given to individual component theme mixins.
/// @param {Map} $primary The theme configuration object.
/// @returns {Map} A complete Angular Material theme map.
@function define-dark-theme($primary, $accent: null, $warn: define-palette(palette.$red-palette)) {
  // This function creates a container object for the individual component theme mixins. Consumers
  // can construct such an object by calling this function, or by building the object manually.
  // There are two possible ways to invoke this function in order to create such an object:
  //
  //    (1) Passing in a map that holds optional configurations for individual parts of the
  //        theming system. For `color` configurations, the function only expects the palettes
  //        for `primary` and `accent` (and optionally `warn`). The function will expand the
  //        shorthand into an actual configuration that can be consumed in `-color` mixins.
  //    (2) Legacy pattern: Passing in the palettes as parameters. This is not as flexible
  //        as passing in a configuration map because only the `color` system can be configured.
  //
  // If the legacy pattern is used, we generate a container object only with a dark-themed
  // configuration for the `color` theming part.
  @if $accent != null {
    @return private-create-backwards-compatibility-theme(_mat-validate-theme((
      _is-legacy-theme: true,
      color: _mat-create-dark-color-config($primary, $accent, $warn),
    )));
  }
  // If the map pattern is used (1), we just pass-through the configurations for individual
  // parts of the theming system, but update the `color` configuration if set. As explained
  // above, the color shorthand will be expanded to an actual dark-themed color configuration.
  $result: $primary;
  @if map.get($primary, color) {
    $color-settings: map.get($primary, color);
    $primary: map.get($color-settings, primary);
    $accent: map.get($color-settings, accent);
    $warn: map.get($color-settings, warn);
    $result: map.merge($result, (color: _mat-create-dark-color-config($primary, $accent, $warn)));
  }
  @return private-create-backwards-compatibility-theme(_mat-validate-theme($result));
}

/// Gets the color configuration from the given theme or configuration.
/// @param {Map} $theme The theme map returned from `define-light-theme` or `define-dark-theme`.
/// @param {Map} $default The default value returned if the given `$theme` does not include a
///     `color` configuration.
/// @returns {Map} Color configuration for a theme.
@function get-color-config($theme, $default: null) {
  // If a configuration has been passed, return the config directly.
  @if not private-is-theme-object($theme) {
    @return $theme;
  }
  // If the theme has been constructed through the legacy theming API, we use the theme object
  // as color configuration instead of the dedicated `color` property. We do this because for
  // backwards compatibility, we copied the color configuration from `$theme.color` to `$theme`.
  // Hence developers could customize the colors at top-level and want to respect these changes
  // TODO: Remove when legacy theming API is removed.
  @if private-is-legacy-constructed-theme($theme) {
    @return $theme;
  }
  @if map.has-key($theme, color) {
    @return map.get($theme, color);
  }
  @return $default;
}

/// Gets the density configuration from the given theme or configuration.
/// @param {Map} $theme-or-config  The theme map returned from `define-light-theme` or
///     `define-dark-theme`.
/// @param {Map} $default The default value returned if the given `$theme` does not include a
///     `density` configuration.
/// @returns {Map} Density configuration for a theme.
@function get-density-config($theme-or-config, $default: 0) {
  // If a configuration has been passed, return the config directly.
  @if not private-is-theme-object($theme-or-config) {
    @return $theme-or-config;
  }
  // In case a theme has been passed, extract the configuration if present,
  // or fall back to the default density config.
  @if map.has-key($theme-or-config, density) {
    @return map.get($theme-or-config, density);
  }
  @return $default;
}

/// Gets the typography configuration from the given theme or configuration.
/// For backwards compatibility, typography is not included by default.
/// @param {Map} $theme-or-config  The theme map returned from `define-light-theme` or
///     `define-dark-theme`.
/// @param {Map} $default The default value returned if the given `$theme` does not include a
///     `typography` configuration.
/// @returns {Map} Typography configuration for a theme.
@function get-typography-config($theme-or-config, $default: null) {
  // If a configuration has been passed, return the config directly.
  @if not private-is-theme-object($theme-or-config) {
    @return $theme-or-config;
  }
  // In case a theme has been passed, extract the configuration if present,
  // or fall back to the default typography config.
  @if (map.has-key($theme-or-config, typography)) {
    @return map.get($theme-or-config, typography);
  }
  @return $default;
}


//
// Private APIs
//

// Checks if configurations that have been declared in the given theme have been generated
// before. If so, warnings will be reported. This should notify developers in case duplicate
// styles are accidentally generated due to wrong usage of the all-theme mixins.
//
// Additionally, this mixin controls the default value for the density configuration. By
// default, density styles are generated at scale zero. If the same density styles would be
// generated a second time though, the default value will change to avoid duplicate styles.
//
// The mixin keeps track of all configurations in a list that is scoped to the specified
// id. This is necessary because a given theme can be passed to multiple disjoint theme mixins
// (e.g. `all-component-themes` and `all-mdc-component-themes`) without causing any
// style duplication.
@mixin private-check-duplicate-theme-styles($theme-or-color-config, $id) {
  $theme: private-legacy-get-theme($theme-or-color-config);
  $color-config: get-color-config($theme);
  $density-config: get-density-config($theme);
  $typography-config: get-typography-config($theme);
  // Lists of previous `color`, `density` and `typography` configurations.
  $previous-color: map.get($_emitted-color, $id) or ();
  $previous-typography: map.get($_emitted-typography, $id) or ();
  $previous-density: map.get($_emitted-density, $id) or ();
  // Whether duplicate legacy density styles would be generated.
  $duplicate-legacy-density: false;

  // Check if the color configuration has been generated before.
  @if $color-config != null {
    @if list.index($previous-color, $color-config) != null and
        not $theme-ignore-duplication-warnings {
      @warn 'The same color styles are generated multiple times. ' + $_duplicate-warning;
    }
    $previous-color: list.append($previous-color, $color-config);
  }

  // Check if the typography configuration has been generated before.
  @if $typography-config != null {
    @if list.index($previous-typography, $typography-config) != null and
        not $theme-ignore-duplication-warnings {
      @warn 'The same typography styles are generated multiple times. ' + $_duplicate-warning;
    }
    $previous-typography: list.append($previous-typography, $typography-config);
  }

  // Check if the density configuration has been generated before.
  @if $density-config != null {
    @if list.index($previous-density, $density-config) != null {
      // Only report a warning if density styles would be duplicated for non-legacy theme
      // definitions. For legacy themes, we have compatibility logic that avoids duplication
      // of default density styles. We don't want to report a warning in those cases.
      @if private-is-legacy-constructed-theme($theme) {
        $duplicate-legacy-density: true;
      }
      @else if not $theme-ignore-duplication-warnings {
        @warn 'The same density styles are generated multiple times. ' + $_duplicate-warning;
      }
    }
    $previous-density: list.append($previous-density, $density-config);
  }

  $_emitted-color: map.merge($_emitted-color, ($id: $previous-color)) !global;
  $_emitted-density: map.merge($_emitted-density, ($id: $previous-density)) !global;
  $_emitted-typography: map.merge($_emitted-typography, ($id: $previous-typography)) !global;

  // Optionally, consumers of this mixin can wrap contents inside so that nested
  // duplicate style checks do not report another warning. e.g. if developers include
  // the `all-component-themes` mixin twice, only the top-level duplicate styles check
  // should report a warning. Not all individual components should report a warning too.
  $orig-mat-theme-ignore-duplication-warnings: $theme-ignore-duplication-warnings;
  $theme-ignore-duplication-warnings: true !global;

  // If duplicate default density styles would be generated for a legacy constructed theme,
  // we adjust the density generation so that no density styles are generated by default.
  // If no default density styles have been generated yet, we ensure that the styles
  // are generated at root. For legacy themes our goal is to generate default density
  // styles **once** and at root. This matches the old behavior where density styles were
  // part of the base component styles (that did not use view encapsulation).
  // TODO: Remove this compatibility logic when the legacy theming API is removed.
  compatibility.$private-density-generate-at-root: private-is-legacy-constructed-theme($theme);
  compatibility.$private-density-generate-styles: not $duplicate-legacy-density;

  @content;
  $theme-ignore-duplication-warnings: $orig-mat-theme-ignore-duplication-warnings !global;

  compatibility.$private-density-generate-at-root: false;
  compatibility.$private-density-generate-styles: true;
}

// Checks whether the given value resolves to a theme object. Theme objects are always
// of type `map` and can optionally only specify `color`, `density` or `typography`.
@function private-is-theme-object($value) {
  @return meta.type-of($value) == 'map' and (
    map.has-key($value, color) or
    map.has-key($value, density) or
    map.has-key($value, typography) or
    list.length($value) == 0
  );
}

// Checks whether a given value corresponds to a legacy constructed theme.
@function private-is-legacy-constructed-theme($value) {
  @return meta.type-of($value) == 'map' and map.get($value, '_is-legacy-theme');
}

// Creates a backwards compatible theme. Previously in Angular Material, theme objects
// contained the color configuration directly. With the recent refactoring of the theming
// system to allow for density and typography configurations, this is no longer the case.
// To ensure that constructed themes which will be passed to custom theme mixins do not break,
// we copy the color configuration and put its properties at the top-level of the theme object.
// Here is an example of a pattern that should still work until it's officially marked as a
// breaking change:
//
//    @mixin my-custom-component-theme($theme) {
//      .my-comp {
//        background-color: mat-color(map-get($theme, primary));
//      }
//    }
//
// Note that the `$theme.primary` key does usually not exist since the color configuration
// is stored in `$theme.color` which contains a property for `primary`. This method copies
// the map from `$theme.color` to `$theme` for backwards compatibility.
@function private-create-backwards-compatibility-theme($theme) {
  @if not map.get($theme, color) {
    @return $theme;
  }
  $color: map.get($theme, color);
  @return map.merge($theme, $color);
}

// Gets the theme from the given value that is either already a theme, or a color configuration.
// This handles the legacy case where developers pass a color configuration directly to the
// theme mixin. Before we introduced the new pattern for constructing a theme, developers passed
// the color configuration directly to the theme mixins. This can be still the case if developers
// construct a theme manually and pass it to a theme. We support this for backwards compatibility.
// TODO(devversion): remove this in the future. Constructing themes manually is rare,
// and the code can be easily updated to the new API.
@function private-legacy-get-theme($theme-or-color-config) {
  @if private-is-theme-object($theme-or-color-config) {
    @return $theme-or-color-config;
  }
  @return private-create-backwards-compatibility-theme((
    _is-legacy-theme: true,
    color: $theme-or-color-config
  ));
}
