// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/* eslint-disable @devtools/no-imperative-dom-api */

/*
 * Copyright (C) 2007 Apple Inc.  All rights reserved.
 * Copyright (C) 2009 Joseph Pecoraro
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 *
 * 1.  Redistributions of source code must retain the above copyright
 *     notice, this list of conditions and the following disclaimer.
 * 2.  Redistributions in binary form must reproduce the above copyright
 *     notice, this list of conditions and the following disclaimer in the
 *     documentation and/or other materials provided with the distribution.
 * 3.  Neither the name of Apple Computer, Inc. ("Apple") nor the names of
 *     its contributors may be used to endorse or promote products derived
 *     from this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY
 * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY
 * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
 * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
 * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
 * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

import '../../ui/legacy/legacy.js';

import * as Common from '../../core/common/common.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import type * as ComputedStyleModule from '../../models/computed_style/computed_style.js';
import * as TreeOutline from '../../ui/components/tree_outline/tree_outline.js';
import * as InlineEditor from '../../ui/legacy/components/inline_editor/inline_editor.js';
import * as Components from '../../ui/legacy/components/utils/utils.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as Lit from '../../ui/lit/lit.js';

import * as ElementsComponents from './components/components.js';
import computedStyleWidgetStyles from './computedStyleWidget.css.js';
import {ImagePreviewPopover} from './ImagePreviewPopover.js';
import {categorizePropertyName, type Category, DefaultCategoryOrder} from './PropertyNameCategories.js';
import {Renderer, rendererBase, type RenderingContext, StringRenderer, URLRenderer} from './PropertyRenderer.js';
import {StylePropertiesSection} from './StylePropertiesSection.js';

const {html, render} = Lit;
const {bindToSetting} = UI.UIUtils;

const UIStrings = {
  /**
   * @description Text for a checkbox setting that controls whether the user-supplied filter text
   * excludes all CSS propreties which are filtered out, or just greys them out. In Computed Style
   * Widget of the Elements panel
   */
  showAll: 'Show all',
  /**
   * @description Text for a checkbox setting that controls whether similar CSS properties should be
   * grouped together or not. In Computed Style Widget of the Elements panel.
   */
  group: 'Group',
  /**
   * [
   * @description Text shown to the user when a filter is applied to the computed CSS properties, but
   * no properties matched the filter and thus no results were returned.
   */
  noMatchingProperty: 'No matching property',
  /**
   * @description Context menu item in Elements panel to navigate to the source code location of the
   * CSS selector that was clicked on.
   */
  navigateToSelectorSource: 'Navigate to selector source',
  /**
   * @description Context menu item in Elements panel to navigate to the corresponding CSS style rule
   * for this computed property.
   */
  navigateToStyle: 'Navigate to styles',
  /**
   * @description Text announced to screen readers when a filter is applied to the computed styles list, informing them of the filter term and the number of results.
   * @example {example} PH1
   * @example {5} PH2
   */
  filterUpdateAriaText: `Filter applied: {PH1}. Total Results: {PH2}`,
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/elements/ComputedStyleWidget.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

function matchProperty(name: string, value: string): SDK.CSSPropertyParser.BottomUpTreeMatching|null {
  return SDK.CSSPropertyParser.matchDeclaration(name, value, [
    new SDK.CSSPropertyParserMatchers.ColorMatcher(), new SDK.CSSPropertyParserMatchers.URLMatcher(),
    new SDK.CSSPropertyParserMatchers.StringMatcher()
  ]);
}

function renderPropertyContents(
    node: SDK.DOMModel.DOMNode, cache: Map<string, {name: Element, value: Element}>, propertyName: string,
    propertyValue: string): {name: Element, value: Element} {
  const cacheKey = propertyName + ':' + propertyValue;
  const valueFromCache = cache.get(cacheKey);
  if (valueFromCache) {
    return valueFromCache;
  }
  const name = Renderer.renderNameElement(propertyName);
  name.slot = 'name';
  const value = Renderer
                    .renderValueElement(
                        {name: propertyName, value: propertyValue}, matchProperty(propertyName, propertyValue),
                        [new ColorRenderer(), new URLRenderer(null, node), new StringRenderer()])
                    .valueElement;
  value.slot = 'value';
  cache.set(cacheKey, {name, value});
  return {name, value};
}

/**
 * Note: this function is called for each tree node on each render, so we need
 * to ensure nothing expensive runs here, or if it does it is safely cached.
 **/
const createPropertyElement =
    (node: SDK.DOMModel.DOMNode, cache: Map<string, {name: Element, value: Element}>, propertyName: string,
     propertyValue: string, traceable: boolean, inherited: boolean,
     activeProperty: SDK.CSSProperty.CSSProperty|undefined,
     onContextMenu: ((event: Event) => void)): Lit.TemplateResult => {
      const {name, value} = renderPropertyContents(node, cache, propertyName, propertyValue);
      // clang-format off
      return html`<devtools-computed-style-property
        .traceable=${traceable}
        .inherited=${inherited}
        @oncontextmenu=${onContextMenu}
        @onnavigatetosource=${(event: ElementsComponents.ComputedStyleProperty.NavigateToSourceEvent):void => {
          if (activeProperty) {
            navigateToSource(activeProperty, event);
          }
        }}>
          ${name}
          ${value}
      </devtools-computed-style-property>`;
      // clang-format on
    };

const createTraceElement =
    (node: SDK.DOMModel.DOMNode, property: SDK.CSSProperty.CSSProperty, isPropertyOverloaded: boolean,
     matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles,
     linkifier: Components.Linkifier.Linkifier): ElementsComponents.ComputedStyleTrace.ComputedStyleTrace => {
      const trace = new ElementsComponents.ComputedStyleTrace.ComputedStyleTrace();

      const {valueElement} = Renderer.renderValueElement(
          property, matchProperty(property.name, property.value),
          [new ColorRenderer(), new URLRenderer(null, node), new StringRenderer()]);
      valueElement.slot = 'trace-value';
      trace.appendChild(valueElement);

      const rule = (property.ownerStyle.parentRule as SDK.CSSRule.CSSStyleRule | null);
      let ruleOriginNode;
      if (rule) {
        ruleOriginNode = StylePropertiesSection.createRuleOriginNode(matchedStyles, linkifier, rule);
      }

      let selector = 'element.style';
      if (rule) {
        selector = rule.selectorText();
      } else if (property.ownerStyle.type === SDK.CSSStyleDeclaration.Type.Animation) {
        selector = property.ownerStyle.animationName() ? `${property.ownerStyle.animationName()} animation` :
                                                         'animation style';
      } else if (property.ownerStyle.type === SDK.CSSStyleDeclaration.Type.Transition) {
        selector = 'transitions style';
      }

      trace.data = {
        selector,
        active: !isPropertyOverloaded,
        onNavigateToSource: navigateToSource.bind(null, property),
        ruleOriginNode,
      };

      return trace;
    };

// clang-format off
class ColorRenderer extends rendererBase(SDK.CSSPropertyParserMatchers.ColorMatch) {
  // clang-format on
  override render(match: SDK.CSSPropertyParserMatchers.ColorMatch, context: RenderingContext): Node[] {
    const color = Common.Color.parse(match.text);
    if (!color) {
      return [document.createTextNode(match.text)];
    }

    const swatch = new InlineEditor.ColorSwatch.ColorSwatch();
    swatch.setReadonly(true);
    swatch.renderColor(color);
    const valueElement = document.createElement('span');
    valueElement.textContent = match.text;

    swatch.addEventListener(
        InlineEditor.ColorSwatch.ColorChangedEvent.eventName, (event: InlineEditor.ColorSwatch.ColorChangedEvent) => {
          const {data: {color}} = event;
          valueElement.textContent = color.getAuthoredText() ?? color.asString();
        });

    context.addControl('color', swatch);
    return [swatch, valueElement];
  }

  matcher(): SDK.CSSPropertyParserMatchers.ColorMatcher {
    return new SDK.CSSPropertyParserMatchers.ColorMatcher();
  }
}

const navigateToSource = (cssProperty: SDK.CSSProperty.CSSProperty, event?: Event): void => {
  if (!event) {
    return;
  }
  void Common.Revealer.reveal(cssProperty);
  event.consume(true);
};

const propertySorter = (propA: string, propB: string): number => {
  if (propA.startsWith('--') !== propB.startsWith('--')) {
    return propA.startsWith('--') ? 1 : -1;
  }
  if (propA.startsWith('-webkit') !== propB.startsWith('-webkit')) {
    return propA.startsWith('-webkit') ? 1 : -1;
  }
  const canonicalA = SDK.CSSMetadata.cssMetadata().canonicalPropertyName(propA);
  const canonicalB = SDK.CSSMetadata.cssMetadata().canonicalPropertyName(propB);
  return Platform.StringUtilities.compare(canonicalA, canonicalB);
};

type ComputedStyleData = {
  tag: 'property',
  propertyName: string,
  propertyValue: string,
  inherited: boolean,
}|{
  tag: 'traceElement',
  property: SDK.CSSProperty.CSSProperty,
  rule: SDK.CSSRule.CSSRule | null,
}|{
  tag: 'category',
  name: string,
};

interface ComputedStyleWidgetInput {
  computedStylesTree: TreeOutline.TreeOutline.TreeOutline<ComputedStyleData>;
  hasMatches: boolean;
  showInheritedComputedStylePropertiesSetting: Common.Settings.Setting<boolean>;
  groupComputedStylesSetting: Common.Settings.Setting<boolean>;
  onFilterChanged: (event: CustomEvent<string>) => void;
  filterText: string;
  onRegexToggled: () => void;
  includeToolbar: boolean;
}

type View = (input: ComputedStyleWidgetInput, output: null, target: HTMLElement) => void;

export const DEFAULT_VIEW: View = (input, _output, target) => {
  // clang-format off
  render(html`
    <style>${computedStyleWidgetStyles}</style>
    ${input.includeToolbar ? html`
      <div class="styles-sidebar-pane-toolbar">
        <devtools-toolbar class="styles-pane-toolbar" role="presentation">
          <devtools-toolbar-input
            type="filter"
            autofocus
            ?regex=${true}
            value=${input.filterText}
            @change=${input.onFilterChanged}
            @regextoggle=${input.onRegexToggled}
          ></devtools-toolbar-input>
          <devtools-checkbox
            title=${i18nString(UIStrings.showAll)}
            ${bindToSetting(input.showInheritedComputedStylePropertiesSetting)}
          >${i18nString(UIStrings.showAll)}</devtools-checkbox>
          <devtools-checkbox
            title=${i18nString(UIStrings.group)}
            ${bindToSetting(input.groupComputedStylesSetting)}
          >${i18nString(UIStrings.group)}</devtools-checkbox>
        </devtools-toolbar>
      </div>
      ` : Lit.nothing}
    ${input.computedStylesTree}
    ${!input.hasMatches ? html`<div class="gray-info-message">${i18nString(UIStrings.noMatchingProperty)}</div>` : ''}
  `, target);
  // clang-format on
};

export class ComputedStyleWidget extends UI.Widget.VBox {
  /**
   * We store these because they are used when calculating the dimensions for the image preview.
   * When we need to get those dimensions, we try to resolve them against the
   * node fresh (in case the dimensions have changed), but if that doesn't work,
   * we fallback to the precomputed ones, which helps to deal with situations
   * where the node might have been removed from the DOM.
   */
  #storedNodeFeatures: Components.ImagePreview.PrecomputedFeatures|null = null;
  #nodeStyle: ComputedStyleModule.ComputedStyleModel.ComputedStyle|null = null;
  #matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles|null = null;
  #propertyTraces: Map<string, SDK.CSSProperty.CSSProperty[]>|null = null;
  private readonly showInheritedComputedStylePropertiesSetting: Common.Settings.Setting<boolean>;
  private readonly groupComputedStylesSetting: Common.Settings.Setting<boolean>;
  private filterRegex: RegExp|null = null;
  private readonly linkifier: Components.Linkifier.Linkifier;
  private readonly imagePreviewPopover: ImagePreviewPopover;
  /**
   * Rendering a property's name and value is expensive, and each time we do it
   * it generates a new HTML element. If we call this directly from our Lit
   * components, we will generate a brand new DOM element on each single render.
   * This is very expensive and unnecessary - for the majority of re-renders a
   * property's name and value does not change. So we cache the rest of rendering
   * the name and value in a map, where the key used is a combination of the
   * property's name and value. This ensures that we only re-generate this element
   * if the node itself changes.
   * The resulting Element nodes are inserted into the ComputedStyleProperty
   * component via <slot>s, ensuring that Lit doesn't directly render/re-render
   * the element.
   * We have to store this cache per widget because it is possible to have
   * multiple widgets for the same NodeId showing at once. In that case, if we
   * reuse the same element from the cache, only one of the widgets will be
   * populated, because an HTML node cannot be in two locations at once.
   */
  #propertyElementsCache = new Map<string, {name: Element, value: Element}>();

  #computedStylesTree = new TreeOutline.TreeOutline.TreeOutline<ComputedStyleData>();
  #treeData?: TreeOutline.TreeOutline.TreeOutlineData<ComputedStyleData>;
  #enableNarrowViewResizing = true;
  readonly #view: View;

  /**
   * TODO(b/407751272): the state here is confusing (3 instance variables relating to filtering).
   * There is also a bug where the Toolbar Input's regex flag cannot be
   * controlled, so if you set a regex filter here, the toolbar might not
   * reflect it.
   */
  #filterText = '';
  #filterIsRegex = false;
  #allowUserControl = true;

  constructor(element?: HTMLElement, view = DEFAULT_VIEW) {
    super(element, {useShadowDom: true});
    this.#view = view;

    this.contentElement.classList.add('styles-sidebar-computed-style-widget');

    this.showInheritedComputedStylePropertiesSetting =
        Common.Settings.Settings.instance().createSetting('show-inherited-computed-style-properties', false);
    this.showInheritedComputedStylePropertiesSetting.addChangeListener(this.requestUpdate.bind(this));

    this.groupComputedStylesSetting = Common.Settings.Settings.instance().createSetting('group-computed-styles', false);
    this.groupComputedStylesSetting.addChangeListener(() => {
      this.requestUpdate();
    });

    this.filterRegex = null;
    this.linkifier = new Components.Linkifier.Linkifier(maxLinkLength);
    this.imagePreviewPopover = new ImagePreviewPopover(
        this.contentElement,
        event => {
          const link = event.composedPath()[0];
          if (link instanceof Element) {
            return link;
          }
          return null;
        },
        async () => {
          const liveFeatures = await Components.ImagePreview.loadPrecomputedFeatures(this.#nodeStyle?.node);
          return liveFeatures ?? this.#storedNodeFeatures ?? undefined;
        });

    this.#updateView({hasMatches: true});
  }

  override onResize(): void {
    const isNarrow = this.#enableNarrowViewResizing && this.contentElement.offsetWidth < 260;
    this.#computedStylesTree.classList.toggle('computed-narrow', isNarrow);
  }

  get enableNarrowViewResizing(): boolean {
    return this.#enableNarrowViewResizing;
  }

  set enableNarrowViewResizing(enable: boolean) {
    this.#enableNarrowViewResizing = enable;
    this.onResize();
  }

  get filterText(): RegExp|string {
    if (this.#filterIsRegex) {
      return new RegExp(this.#filterText);
    }
    return this.#filterText;
  }

  get filterIsRegex(): boolean {
    return this.#filterIsRegex;
  }

  set filterText(newFilter: RegExp|string) {
    if (typeof newFilter === 'string') {
      this.#filterText = newFilter;
      this.#filterIsRegex = false;
    } else {
      this.#filterText = newFilter.source;
      this.#filterIsRegex = true;
    }
    this.filterRegex = this.#buildFilterRegex(this.#filterText);
    this.requestUpdate();
  }

  get allowUserControl(): boolean {
    return this.#allowUserControl;
  }

  set allowUserControl(inc: boolean) {
    this.#allowUserControl = inc;
    this.requestUpdate();
  }

  /**
   * @param input.hasMatches Whether any properties matched the current filter (or if any properties exist at all).
   */
  #updateView({hasMatches}: {hasMatches: boolean}): void {
    this.#view(
        {
          computedStylesTree: this.#computedStylesTree,
          includeToolbar: this.#allowUserControl,
          hasMatches,
          showInheritedComputedStylePropertiesSetting: this.showInheritedComputedStylePropertiesSetting,
          groupComputedStylesSetting: this.groupComputedStylesSetting,
          onFilterChanged: this.onFilterChanged.bind(this),
          filterText: this.#filterText,
          onRegexToggled: this.onRegexToggled.bind(this),
        },
        null, this.contentElement);
  }

  get nodeStyle(): ComputedStyleModule.ComputedStyleModel.ComputedStyle|null {
    return this.#nodeStyle;
  }

  set nodeStyle(nodeStyle: ComputedStyleModule.ComputedStyleModel.ComputedStyle|null) {
    this.#nodeStyle = nodeStyle;
    if (nodeStyle) {
      // Make sure we get the node features before we request an update so we
      // don't run the update before we have the features fetched.
      void this.#storeNodeFeatures(nodeStyle.node).then(() => this.requestUpdate());
    } else {
      this.requestUpdate();
    }
  }

  get matchedStyles(): SDK.CSSMatchedStyles.CSSMatchedStyles|null {
    return this.#matchedStyles;
  }

  set matchedStyles(matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles|null) {
    this.#matchedStyles = matchedStyles;
    this.requestUpdate();
  }

  set propertyTraces(propertyTraces: Map<string, SDK.CSSProperty.CSSProperty[]>|null) {
    this.#propertyTraces = propertyTraces;
    this.requestUpdate();
  }

  async #storeNodeFeatures(node: SDK.DOMModel.DOMNode|null): Promise<void> {
    if (node) {
      const features = await Components.ImagePreview.loadPrecomputedFeatures(node);
      this.#storedNodeFeatures = features ?? null;
    } else {
      this.#storedNodeFeatures = null;
    }
  }

  #shouldGroupStyles(): boolean {
    return this.#allowUserControl && this.groupComputedStylesSetting.get();
  }

  #shouldShowAllStyles(): boolean {
    return this.#allowUserControl && this.showInheritedComputedStylePropertiesSetting.get();
  }

  override async performUpdate(): Promise<void> {
    const nodeStyles = this.#nodeStyle;
    const matchedStyles = this.#matchedStyles;
    if (!nodeStyles || !matchedStyles) {
      this.#updateView({hasMatches: false});
      return;
    }
    if (this.#shouldGroupStyles()) {
      await this.rebuildGroupedList(nodeStyles, matchedStyles);
    } else {
      await this.rebuildAlphabeticalList(nodeStyles, matchedStyles);
    }
  }

  private async rebuildAlphabeticalList(
      nodeStyle: ComputedStyleModule.ComputedStyleModel.ComputedStyle,
      matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles): Promise<void> {
    this.imagePreviewPopover.hide();
    this.linkifier.reset();

    const uniqueProperties = [...nodeStyle.computedStyle.keys()];
    uniqueProperties.sort(propertySorter);

    const node = nodeStyle.node;
    const propertyTraces = this.#propertyTraces || new Map();
    const nonInheritedProperties = this.computeNonInheritedProperties(matchedStyles);
    const showInherited = this.#shouldShowAllStyles();
    const tree: Array<TreeOutline.TreeOutlineUtils.TreeNode<ComputedStyleData>> = [];
    for (const propertyName of uniqueProperties) {
      const propertyValue = nodeStyle.computedStyle.get(propertyName) || '';
      const canonicalName = SDK.CSSMetadata.cssMetadata().canonicalPropertyName(propertyName);
      const isInherited = !nonInheritedProperties.has(canonicalName);
      if (!showInherited && isInherited && !alwaysShownComputedProperties.has(propertyName)) {
        continue;
      }
      if (!showInherited && propertyName.startsWith('--')) {
        continue;
      }
      if (propertyName !== canonicalName && propertyValue === nodeStyle.computedStyle.get(canonicalName)) {
        continue;
      }
      tree.push(this.buildTreeNode(propertyTraces, propertyName, propertyValue, isInherited));
    }

    const defaultRenderer = this.createTreeNodeRenderer(propertyTraces, node, matchedStyles);
    this.#treeData = {
      tree,
      compact: true,
      defaultRenderer,
    };
    this.filterAlphabeticalList();
  }

  private async rebuildGroupedList(
      nodeStyle: ComputedStyleModule.ComputedStyleModel.ComputedStyle|null,
      matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles|null): Promise<void> {
    this.imagePreviewPopover.hide();
    this.linkifier.reset();
    if (!nodeStyle || !matchedStyles) {
      this.#updateView({hasMatches: false});
      return;
    }

    const node = nodeStyle.node;
    const propertyTraces = this.#propertyTraces || new Map();
    const nonInheritedProperties = this.computeNonInheritedProperties(matchedStyles);
    const showInherited = this.showInheritedComputedStylePropertiesSetting.get();

    const propertiesByCategory = new Map<Category, string[]>();

    const tree: Array<TreeOutline.TreeOutlineUtils.TreeNode<ComputedStyleData>> = [];
    for (const [propertyName, propertyValue] of nodeStyle.computedStyle) {
      const canonicalName = SDK.CSSMetadata.cssMetadata().canonicalPropertyName(propertyName);
      const isInherited = !nonInheritedProperties.has(canonicalName);
      if (!showInherited && isInherited && !alwaysShownComputedProperties.has(propertyName)) {
        continue;
      }
      if (!showInherited && propertyName.startsWith('--')) {
        continue;
      }
      if (propertyName !== canonicalName && propertyValue === nodeStyle.computedStyle.get(canonicalName)) {
        continue;
      }

      const categories = categorizePropertyName(propertyName);
      for (const category of categories) {
        if (!propertiesByCategory.has(category)) {
          propertiesByCategory.set(category, []);
        }
        propertiesByCategory.get(category)?.push(propertyName);
      }
    }

    this.#computedStylesTree.removeChildren();
    for (const category of DefaultCategoryOrder) {
      const properties = propertiesByCategory.get(category);
      if (properties && properties.length > 0) {
        const propertyNodes: Array<TreeOutline.TreeOutlineUtils.TreeNode<ComputedStyleData>> = [];
        for (const propertyName of properties) {
          const propertyValue = nodeStyle.computedStyle.get(propertyName) || '';
          const canonicalName = SDK.CSSMetadata.cssMetadata().canonicalPropertyName(propertyName);
          const isInherited = !nonInheritedProperties.has(canonicalName);
          propertyNodes.push(this.buildTreeNode(propertyTraces, propertyName, propertyValue, isInherited));
        }
        tree.push({id: category, treeNodeData: {tag: 'category', name: category}, children: async () => propertyNodes});
      }
    }
    const defaultRenderer = this.createTreeNodeRenderer(propertyTraces, node, matchedStyles);
    this.#treeData = {
      tree,
      compact: true,
      defaultRenderer,
    };
    return await this.filterGroupLists();
  }

  private buildTraceNode(property: SDK.CSSProperty.CSSProperty):
      TreeOutline.TreeOutlineUtils.TreeNode<ComputedStyleData> {
    const rule = property.ownerStyle.parentRule;
    return {
      treeNodeData: {
        tag: 'traceElement',
        property,
        rule,
      },
      id: (rule?.origin || '') + ': ' + property.ownerStyle.styleSheetId + (property.range || property.name),
    };
  }

  private createTreeNodeRenderer(
      propertyTraces: Map<string, SDK.CSSProperty.CSSProperty[]>,
      domNode: SDK.DOMModel.DOMNode,
      matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles,
      ):
      (node: TreeOutline.TreeOutlineUtils.TreeNode<ComputedStyleData>,
       state: {isExpanded: boolean}) => Lit.TemplateResult {
    return node => {
      const data = node.treeNodeData;
      if (data.tag === 'property') {
        const trace = propertyTraces.get(data.propertyName);
        const activeProperty = trace?.find(
            property => matchedStyles.propertyState(property) === SDK.CSSMatchedStyles.PropertyState.ACTIVE);
        const propertyElement = createPropertyElement(
            domNode, this.#propertyElementsCache, data.propertyName, data.propertyValue,
            propertyTraces.has(data.propertyName), data.inherited, activeProperty, event => {
              if (activeProperty) {
                this.handleContextMenuEvent(matchedStyles, activeProperty, event);
              }
            });
        return propertyElement;
      }
      if (data.tag === 'traceElement') {
        const isPropertyOverloaded =
            matchedStyles.propertyState(data.property) === SDK.CSSMatchedStyles.PropertyState.OVERLOADED;
        const traceElement =
            createTraceElement(domNode, data.property, isPropertyOverloaded, matchedStyles, this.linkifier);
        traceElement.addEventListener(
            'contextmenu', this.handleContextMenuEvent.bind(this, matchedStyles, data.property));
        return html`${traceElement}`;
      }
      return html`<span style="cursor: text; color: var(--sys-color-on-surface-subtle);">${data.name}</span>`;
    };
  }

  private buildTreeNode(
      propertyTraces: Map<string, SDK.CSSProperty.CSSProperty[]>, propertyName: string, propertyValue: string,
      isInherited: boolean): TreeOutline.TreeOutlineUtils.TreeNode<ComputedStyleData> {
    const treeNodeData: ComputedStyleData = {
      tag: 'property',
      propertyName,
      propertyValue,
      inherited: isInherited,
    };
    const trace = propertyTraces.get(propertyName);
    const jslogContext = propertyName.startsWith('--') ? 'custom-property' : propertyName;
    if (!trace) {
      return {
        treeNodeData,
        jslogContext,
        id: propertyName,
      };
    }
    return {
      treeNodeData,
      jslogContext,
      id: propertyName,
      children: async () => trace.map(this.buildTraceNode),
    };
  }

  private handleContextMenuEvent(
      matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles, property: SDK.CSSProperty.CSSProperty, event: Event): void {
    const contextMenu = new UI.ContextMenu.ContextMenu(event);
    const rule = property.ownerStyle.parentRule;

    if (rule) {
      const header = rule.header;
      if (header && !header.isAnonymousInlineStyleSheet()) {
        contextMenu.defaultSection().appendItem(i18nString(UIStrings.navigateToSelectorSource), () => {
          StylePropertiesSection.tryNavigateToRuleLocation(matchedStyles, rule);
        }, {jslogContext: 'navigate-to-selector-source'});
      }
    }

    contextMenu.defaultSection().appendItem(
        i18nString(UIStrings.navigateToStyle), () => Common.Revealer.reveal(property),
        {jslogContext: 'navigate-to-style'});
    void contextMenu.show();
  }

  private computeNonInheritedProperties(matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles): Set<string> {
    const result = new Set<string>();
    for (const style of matchedStyles.nodeStyles()) {
      for (const property of style.allProperties()) {
        if (!matchedStyles.propertyState(property)) {
          continue;
        }
        result.add(SDK.CSSMetadata.cssMetadata().canonicalPropertyName(property.name));
      }
    }
    return result;
  }

  #buildFilterRegex(text: string): RegExp|null {
    if (!text) {
      return null;
    }
    if (this.#filterIsRegex) {
      try {
        return new RegExp(text, 'i');
      } catch {
        // Invalid regex: fall through to plain-text matching.
      }
    }
    return new RegExp(Platform.StringUtilities.escapeForRegExp(text), 'i');
  }

  private async onRegexToggled(): Promise<void> {
    this.#filterIsRegex = !this.#filterIsRegex;
    await this.filterComputedStyles(this.#buildFilterRegex(this.#filterText));
  }

  private async onFilterChanged(event: CustomEvent<string>): Promise<void> {
    this.#filterText = event.detail;
    await this.filterComputedStyles(this.#buildFilterRegex(event.detail));

    if (event.detail && this.#computedStylesTree.data && this.#computedStylesTree.data.tree) {
      UI.ARIAUtils.LiveAnnouncer.alert(i18nString(
          UIStrings.filterUpdateAriaText, {PH1: event.detail, PH2: this.#computedStylesTree.data.tree.length}));
    }
  }

  async filterComputedStyles(regex: RegExp|null): Promise<void> {
    this.filterRegex = regex;
    if (this.groupComputedStylesSetting.get()) {
      return await this.filterGroupLists();
    }
    return this.filterAlphabeticalList();
  }

  private nodeFilter(node: TreeOutline.TreeOutlineUtils.TreeNode<ComputedStyleData>): boolean {
    const regex = this.filterRegex;
    const data = node.treeNodeData;
    if (data.tag === 'property') {
      const matched = !regex || regex.test(data.propertyName) || regex.test(data.propertyValue);
      return matched;
    }
    return true;
  }

  private filterAlphabeticalList(): void {
    if (!this.#treeData) {
      return;
    }
    const tree = this.#treeData.tree.filter(this.nodeFilter.bind(this));
    this.#computedStylesTree.data = {
      tree,
      defaultRenderer: this.#treeData.defaultRenderer,
      compact: this.#treeData.compact,
    };
    this.#updateView({hasMatches: Boolean(tree.length)});
  }

  private async filterGroupLists(): Promise<void> {
    if (!this.#treeData) {
      return;
    }
    const tree: Array<TreeOutline.TreeOutlineUtils.TreeNode<ComputedStyleData>> = [];
    for (const group of this.#treeData.tree) {
      const data = group.treeNodeData;
      if (data.tag !== 'category' || !group.children) {
        continue;
      }
      const properties = await group.children();
      const filteredChildren = properties.filter(this.nodeFilter.bind(this));
      if (filteredChildren.length) {
        tree.push(
            {id: data.name, treeNodeData: {tag: 'category', name: data.name}, children: async () => filteredChildren});
      }
    }

    this.#computedStylesTree.data = {
      tree,
      defaultRenderer: this.#treeData.defaultRenderer,
      compact: this.#treeData.compact,
    };
    await this.#computedStylesTree.expandRecursively(0);
    this.#updateView({hasMatches: Boolean(tree.length)});
  }
}

const maxLinkLength = 30;
const alwaysShownComputedProperties = new Set<string>(['display', 'height', 'width']);
