// Copyright 2026 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import * as Host from '../../../core/host/host.js';
import * as i18n from '../../../core/i18n/i18n.js';
import * as Root from '../../../core/root/root.js';
import * as SDK from '../../../core/sdk/sdk.js';
import type * as LHModel from '../../lighthouse/lighthouse.js';
import {ChangeManager} from '../ChangeManager.js';
import {LighthouseFormatter} from '../data_formatters/LighthouseFormatter.js';
import {debugLog} from '../debug.js';
import {ExtensionScope} from '../ExtensionScope.js';

import {
  AiAgent,
  type AiWidget,
  type ContextDetail,
  type ContextResponse,
  ConversationContext,
  type RequestOptions,
  ResponseType,
} from './AiAgent.js';
import {
  type CreateExtensionScopeFunction,
  executeJavaScriptFunction,
  type ExecuteJsAgentOptions,
  executeJsCode,
  JavascriptExecutor
} from './ExecuteJavascript.js';

/**
 * WARNING: preamble defined in code is only used when userTier is
 * TESTERS. Otherwise, a server-side preamble is used (see
 * chrome_preambles.gcl). Sync local changes with the server-side.
 */
const preamble = `You are an accessibility expert agent integrated into Chrome DevTools.
Your role is to help users understand and fix accessibility issues found in Lighthouse reports.

# Style Guidelines
* **General style**: Use the precision of Strunk & White, the brevity of Hemingway, and the simple clarity of Vonnegut. Don't add repeated information, and keep the whole answer short.
* **Structured**: Organize your findings by problem, root cause, and next steps, but do NOT use those literal words as headings.
* **No Internal Identifiers**: NEVER show Lighthouse paths (e.g., "1,HTML,1,BODY...") to the user. Refer to elements by their tag name, classes, or IDs.
* **Managing Volume**: If the report contains many issues, provide a brief summary of the top 2-3 most critical ones. Tell the user that there are more issues and invite them to ask for more details or to explore a specific area.

# Workflow
1. **Identify**: Find the most critical accessibility issues in the Lighthouse report.
2. **Investigate**: For any element identified as failing, you **MUST** call \`getStyles\` or \`getElementAccessibilityDetails\` first to confirm its current state and gather details.
3. **Analyze**: Use the live data from your tools to determine the exact root cause.
4. **Respond**: Provide a succinct summary of the problem, why it's happening based on your investigation, and a clear fix.

# Capabilities
* \`getLighthouseAudits\`: Get detailed audit data.
* \`runAccessibilityAudits\`: Trigger new accessibility snapshot audits.
* \`getStyles\`: Get computed styles for an element by its path.
* \`getElementAccessibilityDetails\`: Get A11y properties for an element by its path.
* \`executeJavaScript\`: Run JavaScript code on the inspected page to gather additional information or investigate the page state.

# Linkification
* **Linkify elements**: When you know the Lighthouse path of an element (found in the report audits), linkify it using \`([Label](#path-PATH))\` syntax. Never show the path to the user directly, only use it in the link href.

# Constraints
* **CRITICAL**: ALWAYS call a tool before providing an answer if an element path is available.
* **CRITICAL**: You are an accessibility agent. NEVER provide answers to questions of unrelated topics such as legal advice, financial advice, personal opinions, medical advice, or any other non web-development topics.
* **CRITICAL**: If the Lighthouse report shows scores as "n/a" or indicates a failure, it means the data is missing or the run failed. Do NOT assume that the page passed or has no issues.

## Response Structure

If the user asks a question that requires an investigation of a problem, use this structure:
- If available, point out the root cause(s) of the problem.
  - Example: "**Root Cause**: The page is slow because of [reason]."
  - Example: "**Root Causes**:"
    - [Reason 1]
    - [Reason 2]
- if applicable, list actionable solution suggestion(s) in order of impact:
  - Example: "**Suggestion**: [Suggestion 1]
  - Example: "**Suggestions**:"
    - [Suggestion 1]
    - [Suggestion 2]
`;

export class AccessibilityContext extends ConversationContext<LHModel.ReporterTypes.ReportJSON> {
  #lh: LHModel.ReporterTypes.ReportJSON;

  constructor(report: LHModel.ReporterTypes.ReportJSON) {
    super();
    this.#lh = report;
  }

  #url(): string {
    return this.#lh.finalUrl ?? this.#lh.finalDisplayedUrl;
  }

  override getOrigin(): string {
    return new URL(this.#url()).origin;
  }

  override getItem(): LHModel.ReporterTypes.ReportJSON {
    return this.#lh;
  }

  override getTitle(): string {
    return `Lighthouse report: ${this.#url()}`;
  }
}

/**
 * One agent instance handles one conversation. Create a new agent
 * instance for a new conversation.
 */
export class AccessibilityAgent extends AiAgent<LHModel.ReporterTypes.ReportJSON> {
  readonly preamble = preamble;
  readonly clientFeature = Host.AidaClient.ClientFeature.CHROME_ACCESSIBILITY_AGENT;
  readonly #lighthouseRecording?:
      (overrides?: LHModel.RunTypes.RunOverrides) => Promise<LHModel.ReporterTypes.ReportJSON|null>;

  #execJs: typeof executeJsCode;
  #javascriptExecutor: JavascriptExecutor;
  #changes: ChangeManager;
  #createExtensionScope: CreateExtensionScopeFunction;
  #currentTurnId = 0;

  constructor(opts: ExecuteJsAgentOptions) {
    super(opts);
    this.#lighthouseRecording = opts.lighthouseRecording;
    this.#changes = opts.changeManager || new ChangeManager();
    this.#execJs = opts.execJs ?? executeJsCode;
    this.#createExtensionScope =
        opts.createExtensionScope ?? ((changes: ChangeManager) => {
          return new ExtensionScope(changes, this.sessionId, this.#getDocumentBodyNode(), this.#currentTurnId);
        });
    this.#javascriptExecutor = new JavascriptExecutor(
        {
          executionMode: this.executionMode,
          getContextNode: () => this.#getDocumentBodyNode(),
          createExtensionScope: this.#createExtensionScope.bind(this),
          changes: this.#changes,
        },
        this.#execJs);
  }

  get userTier(): string|undefined {
    return Root.Runtime.hostConfig.devToolsFreestyler?.userTier;
  }

  get executionMode(): Root.Runtime.HostConfigFreestylerExecutionMode {
    return Root.Runtime.hostConfig.devToolsFreestyler?.executionMode ??
        Root.Runtime.HostConfigFreestylerExecutionMode.ALL_SCRIPTS;
  }

  get options(): RequestOptions {
    // TODO(b/491772868): tidy up userTier & feature flags in the backend.
    const temperature = Root.Runtime.hostConfig.devToolsAiAssistanceFileAgent?.temperature;
    const modelId = Root.Runtime.hostConfig.devToolsAiAssistanceFileAgent?.modelId;

    return {
      temperature,
      modelId,
    };
  }

  override preambleFeatures(): string[] {
    return ['function_calling'];
  }

  protected override async preRun(): Promise<void> {
    this.#currentTurnId++;
    const target = SDK.TargetManager.TargetManager.instance().primaryPageTarget();
    const domModel = target?.model(SDK.DOMModel.DOMModel);
    // We need to ensure the document is requested so that #getDocumentBodyNode()
    // can return a valid node for the JavaScript execution context.
    if (domModel && !domModel.existingDocument()) {
      try {
        await domModel.requestDocument();
      } catch (e) {
        debugLog('Failed to request document', e);
      }
    }
  }

  /**
   * For the Accessibility Agent, there is no single "selected" node.
   * We use the document body as the default context node for JavaScript execution
   * so that the AI has a valid $0 to start with.
   */
  #getDocumentBodyNode(): SDK.DOMModel.DOMNode|null {
    const document = SDK.TargetManager.TargetManager.instance()
                         .primaryPageTarget()
                         ?.model(SDK.DOMModel.DOMModel)
                         ?.existingDocument();
    return document?.body ?? document ?? null;
  }

  async *
      handleContextDetails(lhr: ConversationContext<LHModel.ReporterTypes.ReportJSON>|null):
          AsyncGenerator<ContextResponse, void, void> {
    if (!lhr) {
      return;
    }

    yield {
      type: ResponseType.CONTEXT,
      details: this.#createContextDetails(lhr),
    };
  }

  async #resolvePathToNode(path: string): Promise<SDK.DOMModel.DOMNode|null> {
    const target = SDK.TargetManager.TargetManager.instance().primaryPageTarget();
    if (!target) {
      return null;
    }
    const domModel = target.model(SDK.DOMModel.DOMModel);
    if (!domModel) {
      return null;
    }
    const nodeId = await domModel.pushNodeByPathToFrontend(path);
    if (!nodeId) {
      return null;
    }
    return domModel.nodeForId(nodeId);
  }

  #declareFunctions(): void {
    this.declareFunction('executeJavaScript', executeJavaScriptFunction(this.#javascriptExecutor));

    this.declareFunction<{explanation: string}, {audits: string}>('runAccessibilityAudits', {
      description:
          'Triggers new Lighthouse accessibility audits in snapshot mode. Use this if the user has made changes to the page and you want to re-evaluate the accessibility audits.',
      parameters: {
        type: Host.AidaClient.ParametersTypes.OBJECT,
        description: '',
        nullable: false,
        properties: {
          explanation: {
            type: Host.AidaClient.ParametersTypes.STRING,
            description: 'Explain why you want to run new audits.',
            nullable: false,
          },
        },
        required: ['explanation'],
      },
      displayInfoFromArgs: params => {
        return {
          title: i18n.i18n.lockedString('Running accessibility audits'),
          thought: params.explanation,
          action: 'runAccessibilityAudits()'
        };
      },
      handler: async params => {
        debugLog('Function call: runAccessibilityAudits', params);
        if (!this.#lighthouseRecording) {
          return {error: 'Lighthouse recording is not available.'};
        }
        const report = await this.#lighthouseRecording({
          mode: 'snapshot',
          categoryIds: ['accessibility'],
          isAIControlled: true,
        });
        if (!report) {
          return {error: 'Failed to run accessibility audits.'};
        }
        const audits = new LighthouseFormatter().audits(report, 'accessibility');
        return {result: {audits}};
      }
    });

    this.declareFunction<{categoryId: LHModel.RunTypes.CategoryId}, {audits: string}>('getLighthouseAudits', {
      description:
          'Returns the audits for a specific Lighthouse category. Use this to get more information about the performance, accessibility, best-practices, or seo audits.',
      parameters: {
        type: Host.AidaClient.ParametersTypes.OBJECT,
        description: '',
        nullable: false,
        properties: {
          categoryId: {
            type: Host.AidaClient.ParametersTypes.STRING,
            description:
                'The category of audits to retrieve. Valid values are "performance", "accessibility", "best-practices", "seo".',
            nullable: false,
          },
        },
        required: ['categoryId'],
      },
      displayInfoFromArgs: params => {
        return {
          title: i18n.i18n.lockedString(`Getting Lighthouse audits for ${params.categoryId}`),
          action: `getLighthouseAudits('${params.categoryId}')`
        };
      },
      handler: async params => {
        debugLog('Function call: getLighthouseAudits', params);
        const report = this.context?.getItem();
        if (!report) {
          return {error: 'No Lighthouse report available.'};
        }
        const audits = new LighthouseFormatter().audits(report, params.categoryId);
        return {result: {audits}};
      }
    });

    this.declareFunction<{
      path: string,
      styleProperties: string[],
      explanation: string,
    }>('getStyles', {
      description:
          'Get computed styles for an element on the inspected page by its Lighthouse path. **CRITICAL** You MUST provide a specific list of CSS property names. Do not use generic values like "all" or "*".',
      parameters: {
        type: Host.AidaClient.ParametersTypes.OBJECT,
        description: '',
        nullable: false,
        properties: {
          explanation: {
            type: Host.AidaClient.ParametersTypes.STRING,
            description: 'Explain why you want to get styles.',
            nullable: false,
          },
          path: {
            type: Host.AidaClient.ParametersTypes.STRING,
            description:
                'The Lighthouse path of the element (e.g., "1,HTML,1,BODY,2,DIV"). Find this in the report data.',
            nullable: false,
          },
          styleProperties: {
            type: Host.AidaClient.ParametersTypes.ARRAY,
            description:
                'One or more specific CSS style property names to fetch. Generic values like "all" or "*" are not supported.',
            nullable: false,
            items: {
              type: Host.AidaClient.ParametersTypes.STRING,
              description: 'A CSS style property name to retrieve. For example, \'background-color\'.'
            }
          },
        },
        required: ['explanation', 'path', 'styleProperties']
      },
      displayInfoFromArgs: params => {
        return {
          title: 'Reading computed styles',
          thought: params.explanation,
          action: `getStyles('${params.path}', ${JSON.stringify(params.styleProperties)})`,
        };
      },
      handler: async params => {
        debugLog('Function call: getStyles', params);
        const node = await this.#resolvePathToNode(params.path);
        if (!node) {
          return {error: `Could not find the element with path: ${params.path}`};
        }
        const styles = await node.domModel().cssModel().getComputedStyle(node.id);
        if (!styles) {
          return {error: 'Could not get computed styles.'};
        }
        const result: Record<string, string|number|undefined> = {};
        for (const prop of params.styleProperties) {
          result[prop] = styles.get(prop);
        }

        result['backendNodeId'] = node.backendNodeId();

        const widgets: AiWidget[] = [];
        const matchedStyles = await node.domModel().cssModel().getMatchedStyles(node.id);
        if (matchedStyles) {
          widgets.push({
            name: 'COMPUTED_STYLES',
            data: {
              computedStyles: styles,
              backendNodeId: node.backendNodeId(),
              matchedCascade: matchedStyles,
              properties: params.styleProperties,
            }
          });
        }

        return {
          result: JSON.stringify(result, null, 2),
          widgets: widgets.length > 0 ? widgets : undefined,
        };
      },
    });

    this.declareFunction<{
      path: string,
      explanation: string,
    }>('getElementAccessibilityDetails', {
      description:
          'Get detailed accessibility information for an element on the inspected page by its Lighthouse path.',
      parameters: {
        type: Host.AidaClient.ParametersTypes.OBJECT,
        description: '',
        nullable: false,
        properties: {
          explanation: {
            type: Host.AidaClient.ParametersTypes.STRING,
            description: 'Explain why you want to get accessibility details.',
            nullable: false,
          },
          path: {
            type: Host.AidaClient.ParametersTypes.STRING,
            description:
                'The Lighthouse path of the element (e.g., "1,HTML,1,BODY,2,DIV"). Find this in the report data.',
            nullable: false,
          },
        },
        required: ['explanation', 'path']
      },
      displayInfoFromArgs: params => {
        return {
          title: 'Reading accessibility details',
          thought: params.explanation,
          action: `getElementAccessibilityDetails('${params.path}')`,
        };
      },
      handler: async params => {
        debugLog('Function call: getElementAccessibilityDetails', params);
        const node = await this.#resolvePathToNode(params.path);
        if (!node) {
          return {error: `Could not find the element with path: ${params.path}`};
        }
        const accessibilityModel = node.domModel().target().model(SDK.AccessibilityModel.AccessibilityModel);
        if (!accessibilityModel) {
          return {error: 'Accessibility model not found.'};
        }
        await accessibilityModel.requestAndLoadSubTreeToNode(node);
        const axNode = accessibilityModel.axNodeForDOMNode(node);
        if (!axNode) {
          return {error: 'Could not find accessibility node for the element.'};
        }

        const result = {
          role: axNode.role()?.value,
          name: axNode.name()?.value,
          nameSource: axNode.name()?.sources?.[0]?.type,
          properties: {
            focusable: node.getAttribute('tabindex') !== undefined || axNode.role()?.value === 'button' ||
                axNode.role()?.value === 'link',
            hidden: axNode.ignored(),
          },
          ariaAttributes: node.attributes()
                              .filter(attr => attr.name.startsWith('aria-') || attr.name === 'role')
                              .reduce(
                                  (acc, attr) => {
                                    acc[attr.name] = attr.value;
                                    return acc;
                                  },
                                  {} as Record<string, string>),
          isIgnored: axNode.ignored(),
          ignoredReasons: axNode.ignoredReasons(),
          backendNodeId: node.backendNodeId(),
        };

        return {result: JSON.stringify(result, null, 2)};
      },
    });
  }

  /**
   * This is the initial payload we send at the start of a conversation.
   * Because the agent is focused on Accessibility, we include the
   * Accessibility Audits summary in the payload to avoid an extra round step of
   * the AI querying them.
   */
  #getInitialPayload(context: ConversationContext<LHModel.ReporterTypes.ReportJSON>): string {
    const report = context.getItem();
    const formatter = new LighthouseFormatter();
    const summary = formatter.summary(report);
    const audits = formatter.audits(report, 'accessibility');
    const allFailed = Object.values(report.categories).every(category => category.score === null);
    if (allFailed) {
      return '**CRITICAL**: The Lighthouse report failed to record or all category scores are error/unavailable (n/a). This indicates a failed run or missing data.';
    }
    return `# Lighthouse Report:\n${summary}\n${audits}`;
  }

  override async enhanceQuery(query: string, lhr: ConversationContext<LHModel.ReporterTypes.ReportJSON>|null):
      Promise<string> {
    this.clearDeclaredFunctions();
    if (lhr) {
      this.#declareFunctions();
    }
    const enhancedQuery = lhr ? `${this.#getInitialPayload(lhr)}\n# User request:\n\n` : '';
    return `${enhancedQuery}${query}`;
  }

  #createContextDetails(lhr: ConversationContext<LHModel.ReporterTypes.ReportJSON>):
      [ContextDetail, ...ContextDetail[]] {
    return [
      {title: 'Lighthouse report', text: this.#getInitialPayload(lhr)},
    ];
  }
}
