//
// Copyright 2020 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:selector';
@use 'sass:string';
@use 'sass:list';
@use 'sass:map';
@use 'sass:meta';

/// Global variable used to conditionally emit CSS selector fallback
/// declarations in addition to CSS custom property overrides for IE11 support.
/// Use `enable-css-selector-fallback-declarations()` mixin to configure this
/// flag.
///
/// @example
///
///   @include shadow-dom.enable-css-selector-fallback-declarations();
///   @include foo-bar-theme.theme($theme);
///
///   CSS output =>
///
///     --foo-bar: red;
///
///     // Fallback declarations for IE11 support
///     .mdc-foo-bar__baz {
///       color: red;
///     }
$css-selector-fallback-declarations: false;

/// Enables CSS selector fallback declarations for IE11 support by setting
/// global variable `$css-selector-fallback-declarations` to true. Call this
/// mixin before theme mixin call.
/// @param {Boolean} $enable Set to `true` to emit CSS selector fallback
///     declarations.
/// @example
///   @include shadow-dom.enable-css-selector-fallback-declarations()
///   @include foo-bar-theme.theme($theme);
@mixin enable-css-selector-fallback-declarations($enable) {
  $css-selector-fallback-declarations: $enable !global;
}

$_host: ':host';
$_host-parens: ':host(';
$_end-parens: ')';

/// @deprecated - Use selector-ext.append-strict() instead:
///
/// @example - scss
///   :host([outlined]), :host, :host button {
///     @include selector-ext.append-strict(&, ':hover') {
///       --my-custom-prop: blue;
///     }
///   }
///
/// @example - css
///   :host([outlined]:hover), :host(:hover), :host button:hover {
///     --my-custom-prop: blue;
///   }
///
/// @example - scss
///   :host([outlined]), :host, :host button {
///     @at-root {
///       #{selector-ext.append-strict(&, ':hover')},
///       & {
///         --my-custom-prop: blue;
///       }
///     }
///   }
///
/// @example - css
///   :host([outlined]:hover), :host(:hover), :host button:hover,
///   :host([outlined]), :host, :host button {
///     --my-custom-prop: blue;
///   }
///
/// Given one or more selectors, this mixin will fix any invalid `:host` parent
/// nesting by adding parentheses or inserting the nested selector into the
/// parent `:host()` selector's parentheses. The content block provided to
/// this mixin
/// will be projected under the new selectors.
///
/// @example
///   :host([outlined]), :host, :host button {
///     @include host-aware(selector.append(&, ':hover'), &)) {
///       --my-custom-prop: blue;
///     }
///   }
///
/// will output (but with selectors on a single line):
/// :host([outlined]:hover), // Appended :hover argument
/// :host(:hover),
/// :host button:hover,
/// :host([outlined]), // Ampersand argument
/// :host,
/// :host button, {
///   --my-custom-prop: blue;
/// };
///
/// @param {List} $selector-args - One or more selectors to be fixed for invalid
/// :host syntax.
@mixin host-aware($selector-args...) {
  @each $selector in $selector-args {
    @if not _is-sass-selector($selector) {
      @error 'mdc-theme: host-aware() expected a sass:selector value type but received #{$selector}';
    }
  }

  @if not _share-common-parent($selector-args...) {
    @error 'mdc-theme: host-aware() requires all selectors to use the parent selector (&)';
  }

  $selectors: _flatten-selectors($selector-args...);
  $processed-selectors: ();

  @each $selector in $selectors {
    $first-selector: list.nth($selector, 1);

    @if _host-selector-needs-to-be-fixed($first-selector) {
      $selector: list.set-nth(
        $selector,
        1,
        _fix-host-selector($first-selector)
      );

      $processed-selectors: list.append(
        $processed-selectors,
        $selector,
        $separator: comma
      );
    } @else {
      // Either not in :host, or there are more selectors following the :host
      // and nothing needs to be modified. The content can be placed within the
      // original selector
      $processed-selectors: list.append(
        $processed-selectors,
        $selector,
        $separator: comma
      );
    }
  }

  @if list.length($processed-selectors) > 0 {
    @at-root {
      #{$processed-selectors} {
        @content;
      }
    }
  }
}

/// Determines whether a selector needs to be processed.
/// Selectors that need to be processed would include anything of the format
/// `^:host(\(.*\))?.+` e.g. `:host([outlined]):hover` or `:host:hover` but not
/// `:host` or `:host([outlined])`
///
/// @param {String} $selector - Selector string to be processed
/// @return {Boolean} Whether or not the given selector string needs to be fixed
///     for an invalid :host selector
@function _host-selector-needs-to-be-fixed($selector) {
  $host-index: string.index($selector, $_host);
  $begins-with-host: $host-index == 1;

  @if not $begins-with-host {
    @return false;
  }

  $_host-parens-index: _get-last-end-parens-index($selector);
  $has-parens: $_host-parens-index != null;

  @if $has-parens {
    // e.g. :host(.inside).after -> needs to be fixed
    // :host(.inside) -> does not need to be fixed
    $end-parens-index: string.index($selector, $_end-parens);
    $content-after-parens: string.slice($selector, $end-parens-index + 1);

    $has-content-after-parens: string.length($selector) > $end-parens-index;

    @return $has-content-after-parens;
  } @else {
    // e.g. :host.after -> needs to be fixed
    // :host -> does not need to be fixed
    $has-content-after-host: $selector != $_host;

    @return $has-content-after-host;
  }
}

/// Flattens a list of selectors
///
/// @param {List} $selector-args - A list of selectors to flatten
/// @return {List} Flattened selectors
@function _flatten-selectors($selector-args...) {
  $selectors: ();
  @each $selector-list in $selector-args {
    $selectors: list.join($selectors, $selector-list);
  }

  @return $selectors;
}

/// Fixes an invalid `:host` selector of the format `^:host(\(.*\))?.+` to
/// `:host(.+)`
/// @example
///   @debug _fix-host-selector(':host:hover') // :host(:hover)
///   @debug _fix-host-selector(':host([outlined]):hover) // :host([outlined]:hover)
///
/// @param {String} $selector - Selector string to be fixed that follows the
///     following format: `^:host(\(.*\))?.+`
/// @return {String} Fixed host selector.
@function _fix-host-selector($selector) {
  $_host-parens-index: string.index($selector, $_host-parens);
  $has-parens: $_host-parens-index != null;
  $new-host-inside: '';

  @if $has-parens {
    // e.g. :host(.inside).after -> :host(.inside.after)
    $end-parens-index: _get-last-end-parens-index($selector);
    $inside-host-parens: string.slice(
      $selector,
      string.length($_host-parens) + 1,
      $end-parens-index - 1
    );
    $after-host-parens: string.slice($selector, $end-parens-index + 1);

    $new-host-inside: $inside-host-parens + $after-host-parens;
  } @else {
    // e.g. :host.after -> :host(.after)
    $new-host-inside: string.slice($selector, string.length($_host) + 1);
  }

  @return ':host(#{$new-host-inside})';
}

/// Returns the index of the final occurrence of the end-parenthesis in the
/// given string or null if there is none.
///
/// @param {String} $string - The string to be searched
/// @return {null|Number}
@function _get-last-end-parens-index($string) {
  $index: string.length($string);

  @while $index > 0 {
    $char: string.slice($string, $index, $index);
    @if $char == $_end-parens {
      @return $index;
    }

    $index: $index - 1;
  }

  @return null;
}

/// Returns true if the provided List of Sass selectors share a common parent
/// selector. This function ensures that the parent selector (`&`) is used with
/// `host-aware()`.
///
/// @example
///   _share-common-parent(
///     ('.foo:hover'), ('.foo'  '.bar'), ('.baz' '.foo')
///   ); // true
///
///   _share-common-parent(
///     ('.foo:hover'), ('.foo' '.bar'), ('.baz' '.bar')
///   ); // false
///
/// The purpose of this function is to make sure that a group of selectors do
/// not violate Sass nesting rules. Due to the dynamic nature of `host-aware()`,
/// it's possible to provide invalid selector combinations.
///
/// @example
///   // Valid native nesting
///   :host {
///     &:hover,
///     .foo,
///     .bar & {
///       color: blue;
///     }
///   }
///   // Valid host-aware() nesting
///   :host {
///     @include host-aware(
///       selector.append(&, ':hover'),
///       selector.nest(&, '.foo'),
///       selector.nest('.bar', &),
///     ) {
///       color: blue;
///     }
///   }
///   // Output
///   :host(:hover),
///   :host .foo,
///   .bar :host {
///     color: blue;
///   }
///
///   // Invalid use of host-aware()
///   :host {
///     @include host-aware(
///       selector.append(&, ':hover'),
///       selector.parse('.foo') // Does not share a common parent via `&`
///     ) {
///       color: blue;
///     }
///   }
///   // Invalid output: no way to write this natively without using @at-root
///   :host(:hover),
///   .foo {
///     color: blue;
///   }
///
/// @param {Arglist} $selector-lists - An argument list of Sass selectors.
/// @return true if the selectors share a common parent selector, or false
///   if not.
@function _share-common-parent($selector-lists...) {
  // To validate, this function will extract the simple selectors from each
  // complex selector and compare them to each other. Every complex selector
  // should share at least one common simple parent selector.
  //
  // We do this by keeping track of each simple selector and if they're present
  // within a complex selector. At the end of checking all the selectors, at
  // least one of simple selectors should have been seen for each one of the
  // complex selectors.
  //
  // Each selector list index needs to track its own selector count Map. This is
  // because each comma-separated list has its own root parent selector that
  // we're looking for:
  // .foo,
  // .bar {
  //   &:hover,
  //   .baz & { ... }
  // }
  // ('.foo:hover', '.bar:hover'), ('.baz' '.foo', '.baz' '.bar')
  //
  // In the first index of each selector list, we're looking for the parent
  // ".foo". In the second index we're looking for the parent ".bar".
  $selector-counts-by-index: ();
  $expected-counts-by-index: ();
  @each $selector-list in $selector-lists {
    @each $complex-selector in $selector-list {
      $selector-list-index: list.index($selector-list, $complex-selector);
      $selector-count-map: map.get(
        $selector-counts-by-index,
        $selector-list-index
      );
      @if not $selector-count-map {
        $selector-count-map: ();
      }

      $expected-count: map.get($expected-counts-by-index, $selector-list-index);
      @if not $expected-count {
        $expected-count: 0;
      }

      $simple-selectors-set: ();
      @each $selector in $complex-selector {
        @each $simple-selector in selector.simple-selectors($selector) {
          // Don't use list.join() because there may be duplicate selectors
          // within the complex selector. We want to treat $simple-selectors-set
          // like a Set where there are no duplicate values so that we don't
          // mess up our count by counting one simple selector too many times
          // for a single complex selector.
          @if not list.index($simple-selectors-set, $simple-selector) {
            $simple-selectors-set: list.append(
              $simple-selectors-set,
              $simple-selector
            );
          }
        }
      }

      // Now that we have a "Set" of simple selectors for this complex
      // selector, we can go through each one and update the selector count Map.
      @each $simple-selector in $simple-selectors-set {
        $count: map.get($selector-count-map, $simple-selector);
        @if $count {
          $count: $count + 1;
        } @else {
          $count: 1;
        }

        $selector-count-map: map.merge(
          $selector-count-map,
          (
            $simple-selector: $count,
          )
        );
      }

      $selector-counts-by-index: map.merge(
        $selector-counts-by-index,
        (
          $selector-list-index: $selector-count-map,
        )
      );
      $expected-counts-by-index: map.merge(
        $expected-counts-by-index,
        (
          $selector-list-index: $expected-count + 1,
        )
      );
    }
  }

  @each $index, $selector-count-map in $selector-counts-by-index {
    // If one of the selectors was seen the expected number of times, then we
    // can reasonably assume that each selector shares a common parent.
    // Verify for each index if there are multiple parents.
    $found-parent: false;
    @each $selector, $count in $selector-count-map {
      $expected-count: map.get($expected-counts-by-index, $index);
      @if $count == $expected-count {
        $found-parent: true;
      }
    }

    @if not $found-parent {
      @return false;
    }
  }

  // A common parent was found for each selector, or there were no selectors
  // provided and we did not enter any for loops.
  @return true;
}

/// Returns true if the value is a Sass selector type.
///
/// Selector types are a 2D List: a comma-separated list (the selector list)
/// that contains space-separated lists (the complex selectors) that contain
/// unquoted strings (the compound selectors).
/// @link https://sass-lang.com/documentation/modules/selector
///
/// @example
///   .foo, .bar button:hover { }
///   $type: ((unquote('.foo')), (unquote('.bar') unquote('button:hover')),);
///
/// @param {*} $selector-list - A value to check.
/// @return {Boolean} true if the value is a Sass selector, or false if not.
@function _is-sass-selector($selector-list) {
  // For the purposes of these utility functions, we don't care if the lists
  // have the correct separated or if the strings are unquoted. All that
  // matters is that the type is a 2D array and the values are strings to
  // ensure "close enough" that the selector was generated by Sass.
  //
  // This function is primarily a safe-guard against an accidental string
  // slipping in and forgetting to use a selector.append() which would cause a
  // hard-to-debug problem.
  @if meta.type-of($selector-list) != 'list' {
    @return false;
  }

  // First level is the selector list: what's separated by commas
  // e.g. ".foo, .bar"
  @each $complex-selector in $selector-list {
    // Second level is the complex selector: what's separated by spaces
    // e.g. ".foo .bar"
    @if meta.type-of($complex-selector) != 'list' {
      @return false;
    }

    // Third level is the compound selector: the actual string
    // e.g. ".foo"
    @each $selector in $complex-selector {
      @if meta.type-of($selector) != 'string' {
        @return false;
      }
    }
  }

  @return true;
}
