// Copyright 2025 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
 * @fileoverview A library to associate DOM fragments with their construction code.
 */

import type {TSESLint, TSESTree} from '@typescript-eslint/utils';

import {getEnclosingProperty} from './ast.ts';
import {ClassMember} from './class-member.ts';

type Variable = TSESLint.Scope.Variable;
type Node = TSESTree.Node;
type Identifier = TSESTree.Identifier;
type SourceCode = TSESLint.SourceCode;
type Scope = TSESLint.Scope.Scope;

const domFragments = new Map<Node|ClassMember|Variable, DomFragment>();

export class DomFragment {
  tagName?: string;
  classList: Array<Node|string> = [];
  attributes: Array<{key: string, value: Node|string}> = [];
  booleanAttributes: Array<{key: string, value: Node|string}> = [];
  style: Array<{key: string, value: Node}> = [];
  eventListeners: Array<{key: string, value: Node}> = [];
  bindings: Array<{key: string, value: Node|string}> = [];
  directives: Array<{name: string, arguments: Node[]}> = [];
  textContent?: Node|string;
  children: DomFragment[] = [];
  parent?: DomFragment;
  expression?: string;
  widgetClass?: Node;
  replacer?: (fixer: TSESLint.RuleFixer, template: string) => TSESLint.RuleFix;
  initializer?: Node;
  references: Array<{node: Node, processed?: boolean}> = [];

  static get(node: Node, sourceCode: SourceCode): DomFragment|undefined {
    return domFragments.get(getKey(node, sourceCode));
  }

  static getOrCreate(node: Node, sourceCode: SourceCode): DomFragment {
    const key = getKey(node, sourceCode);

    let result = domFragments.get(key);
    if (!result) {
      result = new DomFragment();
      domFragments.set(key, result);
      if ('parent' in key) {
        result.expression = sourceCode.getText(node);
        result.references.push({node});
      } else if (key instanceof ClassMember) {
        result.expression = sourceCode.getText(node);
        const references: Array<{node: Node, processed?: boolean}> = [];
        Object.defineProperty(result, 'references', {
          get: () => {
            if (key.references.size === references.length) {
              return references;
            }
            for (const reference of key.references) {
              if (!references.some(r => r.node === reference)) {
                references.push({node: reference});
              }
            }
            return references;
          },
          set: () => {},
        });
        Object.defineProperty(result, 'initializer', {
          get: () => key.initializer,
          set: () => {},
        });
      } else if ('references' in key) {
        result.references = key.references.filter(r => !key.identifiers.includes(r.identifier as Identifier))
                                .map(r => ({node: r.identifier}));
        const initializer = key.identifiers[0];
        if (initializer?.parent?.type === 'VariableDeclarator') {
          result.initializer = initializer.parent?.init ?? undefined;
          if (result.initializer?.type === 'TSAsExpression') {
            result.initializer = result.initializer.expression;
          }
        }
        result.expression = key.name;
      }
    }
    return result;
  }

  static set(node: Node, sourceCode: SourceCode, domFragment: DomFragment) {
    const key = getKey(node, sourceCode);
    domFragments.set(key, domFragment);
  }

  static clear() {
    domFragments.clear();
  }

  static values() {
    return domFragments.values();
  }

  toTemplateLiteral(sourceCode: Readonly<SourceCode>, indent = 4): string[] {
    const components: string[] = [];
    const MAX_LINE_LENGTH = 100;
    components.push(`\n${' '.repeat(indent)}`);
    let lineLength = indent;
    if (this.expression && !this.tagName) {
      if (this.expression.startsWith('`') && this.expression.endsWith('`')) {
        components.push(this.expression.slice(1, -1).trim());
      } else {
        const expression = (this.references.every(r => r.processed) && this.initializer) ?
            sourceCode.getText(this.initializer) :
            this.expression;
        components.push('${', expression, '}');
      }

      return components;
    }

    function toOutputString(node: Node|string, quoteLiterals = false): string {
      if (typeof node === 'string') {
        return node;
      }
      if (node.type === 'Literal' && !quoteLiterals) {
        return node.value?.toString() ?? '';
      }
      if (node.type === 'UnaryExpression' && node.operator === '-' && node.argument.type === 'Literal') {
        return '-' + node.argument.value;
      }
      const text = sourceCode.getText(node);
      if (node.type === 'TemplateLiteral') {
        return text.substr(1, text.length - 2);
      }
      return '${' + text + '}';
    }

    function appendExpression(expression) {
      if (lineLength + expression.length + 1 > MAX_LINE_LENGTH) {
        components.push(`\n${' '.repeat(indent + 4)}`);
        lineLength = expression.length + indent + 4;
      } else {
        if (expression.match(/^[a-zA-Z0-9?.$@]/)) {
          components.push(' ');
          ++lineLength;
        }
        lineLength += expression.length;
      }
      components.push(expression);
    }

    if (this.tagName) {
      components.push('<', this.tagName);
      lineLength += this.tagName.length + 1;
    }
    if (this.classList.length) {
      appendExpression(
          `class="${this.classList.map(c => toOutputString(c)).join(' ')}"`,
      );
    }
    for (const attribute of this.attributes || []) {
      const value = attribute.value;
      const valueEmpty = typeof value === 'string' ? value === '' : value.type === 'Literal' && value.value === '';
      if (valueEmpty) {
        appendExpression(attribute.key);
      } else {
        appendExpression(`${attribute.key}=${attributeValue(toOutputString(value))}`);
      }
    }
    for (const attribute of this.booleanAttributes || []) {
      const value = attribute.value;
      const isFalse = typeof value === 'string' ? value === 'false' : value.type === 'Literal' && value.value === false;
      if (isFalse) {
        continue;
      }
      const isTrue = typeof value === 'string' ? value === 'true' : value.type === 'Literal' && value.value === true;
      if (isTrue) {
        appendExpression(attribute.key);
      } else {
        appendExpression(`?${attribute.key}=${attributeValue(toOutputString(value))}`);
      }
    }
    for (const eventListener of this.eventListeners || []) {
      appendExpression(
          `@${eventListener.key}=${
              attributeValue(
                  toOutputString(eventListener.value),
                  )}`,
      );
    }
    if (this.widgetClass) {
      appendExpression(`.widgetConfig=\${widgetConfig(${sourceCode.getText(this.widgetClass)}`);
      if (this.bindings.length) {
        appendExpression(',');
        if (this.bindings.length === 1) {
          appendExpression(`{${this.bindings[0].key}: ${
              typeof this.bindings[0].value === 'string' ? this.bindings[0].value :
                                                           sourceCode.getText(this.bindings[0].value)}}`);
        } else {
          appendExpression('{');
          for (const binding of this.bindings) {
            appendExpression(`${binding.key}: ${
                typeof binding.value === 'string' ? binding.value : sourceCode.getText(binding.value)},`);
          }
          appendExpression('}');
        }
      }
      appendExpression(')}');
    } else {
      for (const binding of this.bindings || []) {
        appendExpression(
            `.${binding.key}=${
                toOutputString(
                    binding.value,
                    /* quoteLiterals=*/ true,
                    )}`,
        );
      }
    }
    for (const directive of this.directives || []) {
      appendExpression(`\${${directive.name}(${directive.arguments.map(a => sourceCode.getText(a)).join(', ')})}`);
    }
    if (this.style.length) {
      const style = this.style.map(s => `${s.key}:${toOutputString(s.value)}`).join('; ');
      appendExpression(`style="${style}"`);
    }
    if (lineLength > MAX_LINE_LENGTH) {
      components.push(`\n${' '.repeat(indent)}`);
    }
    components.push('>');
    if (this.textContent) {
      components.push(toOutputString(this.textContent));
    }
    if (this.children?.length) {
      for (const child of this.children || []) {
        components.push(...child.toTemplateLiteral(sourceCode, indent + 2));
      }
      components.push(`\n${' '.repeat(indent)}`);
    }
    if (this.tagName && this.tagName !== 'input') {
      components.push('</', this.tagName, '>');
    }
    return components;
  }

  appendChild(node: Node, sourceCode: SourceCode, processed = true): DomFragment {
    return this.insertChildAt(node, this.children.length, sourceCode, processed);
  }

  insertChildAt(node: Node, index: number, sourceCode: SourceCode, processed = true): DomFragment {
    const child = DomFragment.getOrCreate(node, sourceCode);
    this.children.splice(index, 0, child);
    child.parent = this;
    if (processed) {
      for (const reference of child.references) {
        if (reference.node === node) {
          reference.processed = true;
        }
      }
    }
    return child;
  }
}

function getEnclosingVariable(node: Node, sourceCode: SourceCode): Variable|null {
  if (node.type === 'Identifier') {
    let scope: Scope|null = sourceCode.getScope(node);
    const variableName = node.name;
    while (scope) {
      const variable = scope.variables.find(v => v.name === variableName);
      if (variable) {
        return variable;
      }
      scope = scope.upper;
    }
  }
  if (node.parent?.type === 'VariableDeclarator') {
    const variables = sourceCode.getDeclaredVariables(node.parent);
    if (variables.length > 1) {
      return null;  // Destructuring assignment
    }
    return variables[0];
  }
  return null;
}

function attributeValue(outputString: string): string {
  if (outputString.startsWith('${') && outputString.endsWith('}')) {
    return outputString;
  }
  return '"' + outputString + '"';
}

function getKey(node: Node, sourceCode: SourceCode): Node|ClassMember|Variable {
  const variable = getEnclosingVariable(node, sourceCode);
  if (variable) {
    return variable;
  }
  const property = getEnclosingProperty(node);
  if (property) {
    const classMember = ClassMember.getOrCreate(property, sourceCode);
    if (classMember) {
      return classMember;
    }
  }
  return node;
}
