// Copyright (C) 2024 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import {addDebugCounterTrack} from '../../components/tracks/debug_tracks';
import {Time} from '../../base/time';
import type {PerfettoPlugin} from '../../public/plugin';
import type {Trace} from '../../public/trace';
import {NUM} from '../../trace_processor/query_result';
import {randomColor} from '../../components/colorizer';
import {
  createView,
  createVirtualTable,
  createPerfettoTable,
  type DisposableSqlEntity,
} from '../../trace_processor/sql_utils';
import {AsyncDisposableStack} from '../../base/disposable_stack';

const CREATE_BREAKDOWN_TABLE_SQL = `
  DROP TABLE IF EXISTS process_memory_breakdown;
  CREATE VIRTUAL TABLE process_memory_breakdown
  USING
    SPAN_OUTER_JOIN(
      android_gpu_memory_per_process PARTITIONED upid,
      memory_rss_and_swap_per_process PARTITIONED upid
    );
`;

const MAX_AGGREGATED_PIDS = 5;

export default class implements PerfettoPlugin {
  static readonly id = 'com.google.PixelMemory';
  private tablesInitialized = false;

  // Helper to set up the breakdown tables idempotently.
  private async setupTables(ctx: Trace) {
    if (this.tablesInitialized) {
      return;
    }
    await ctx.engine.query('INCLUDE PERFETTO MODULE android.gpu.memory;');
    await ctx.engine.query('INCLUDE PERFETTO MODULE linux.memory.process;');
    await ctx.engine.query(CREATE_BREAKDOWN_TABLE_SQL);
    this.tablesInitialized = true;
  }

  // Helper to find the max value from a SQL query and add a note to the
  // timeline.
  private async addMaxMemoryAnnotation(
    ctx: Trace,
    findMaxSql: string,
    noteTarget: string,
  ) {
    try {
      const maxResult = await ctx.engine.query(findMaxSql);
      // Using .firstRow() as LIMIT 1 is in the SQL
      if (maxResult.numRows() > 0) {
        const maxRow = maxResult.firstRow({ts: NUM, value: NUM});
        const maxTs = BigInt(maxRow.ts);
        const maxValueInBytes = maxRow.value;
        const maxValueInKib = (maxValueInBytes / 1024.0).toFixed(2);
        const noteText = `${maxValueInKib} KiB : Max PID (${noteTarget})`;
        const color = randomColor();

        ctx.notes.addNote({
          timestamp: Time.fromRaw(maxTs),
          text: noteText,
          color,
        });
      }
    } catch (e) {
      console.error('Failed to add max memory annotation:', e);
    }
  }

  // Helper function to handle the aggregation logic for multiple PIDs.
  private async createAggregatedTrackAndGetTable(
    ctx: Trace,
    id: string,
    pidList: string[],
    sqlValueExpr: string,
    titleSuffix: string,
    pidsIdentifier: string,
    trash: AsyncDisposableStack,
  ): Promise<string> {
    const runId = id.replace(/[#\.]/g, '_');
    const valueColNames = pidList.map((pid) => `value_${pid}`);
    const aggTableName = `__agg_${runId}`;

    // 1. Create a separate VIEW for each PID's memory data.
    const pidViews: DisposableSqlEntity[] = [];
    for (let i = 0; i < pidList.length; i++) {
      const pid = pidList[i];
      const viewName = `__view_${runId}_${pid}`;
      const createViewAs = `
        SELECT
            ts,
            dur,
            (${sqlValueExpr}) AS ${valueColNames[i]}
        FROM process_memory_breakdown
        WHERE pid = ${pid};
      `;
      const view = await createView({
        engine: ctx.engine,
        name: viewName,
        as: createViewAs,
      });
      pidViews.push(view);
      trash.use(view);
    }
    const viewNames = pidViews.map((v) => v.name);

    // 2. Iteratively SPAN_OUTER_JOIN the views together.
    let previousTableName = viewNames[0];
    for (let i = 1; i < pidList.length; i++) {
      const newJoinedTableName = `__joined_${runId}_${i}`;
      const joinUsing = `SPAN_OUTER_JOIN(${previousTableName}, ${viewNames[i]})`;
      const joinedTable = await createVirtualTable({
        engine: ctx.engine,
        name: newJoinedTableName,
        using: joinUsing,
      });
      trash.use(joinedTable);
      previousTableName = joinedTable.name;
    }

    const finalSelectTable = previousTableName;
    const sumOfValues = valueColNames
      .map((col) => `IFNULL(${col}, 0)`)
      .join(' + ');

    // 3. Materialize the aggregated sum into a PERFETTO table
    const createAggTableAs = `
      SELECT
        CAST(ts AS BIGINT) AS ts,
        (${sumOfValues}) AS value
      FROM ${finalSelectTable}
      WHERE ts IS NOT NULL;
    `;
    const aggTable = await createPerfettoTable({
      engine: ctx.engine,
      name: aggTableName,
      as: createAggTableAs,
    });
    trash.use(aggTable);

    // 4. Add the debug track using the materialized aggregate table
    await addDebugCounterTrack({
      trace: ctx,
      data: {
        sqlSource: `SELECT ts, value FROM ${aggTableName} ORDER BY ts`,
        columns: ['ts', 'value'],
      },
      title: `${pidsIdentifier}${titleSuffix}`,
    });

    // 5. Return the aggregate table name
    return aggTableName;
  }

  // Prepares the SQL and target name for the max memory annotation.
  private async prepareAnnotationData(
    ctx: Trace,
    id: string,
    pidList: string[],
    sqlValueExpr: string,
    titleSuffix: string,
    trash: AsyncDisposableStack,
  ): Promise<{findMaxSql: string; noteTarget: string}> {
    if (pidList.length > 1) {
      const pidsIdentifierForTracks = pidList.join('_');
      const noteTarget = pidList.join('+');
      const aggTableName = await this.createAggregatedTrackAndGetTable(
        ctx,
        id,
        pidList,
        sqlValueExpr,
        titleSuffix,
        pidsIdentifierForTracks,
        trash,
      );

      const findMaxSql = `
        SELECT ts, value
        FROM ${aggTableName}
        WHERE value IS NOT NULL
        ORDER BY value DESC, ts ASC
        LIMIT 1
      `;
      return {findMaxSql, noteTarget};
    } else {
      // pidList.length === 1
      const noteTarget = pidList[0];
      const findMaxSql = `
        SELECT
          ts,
          (${sqlValueExpr}) AS value
        FROM process_memory_breakdown
        WHERE pid = ${pidList[0]} AND value IS NOT NULL
        ORDER BY value DESC, ts ASC
        LIMIT 1
      `;
      return {findMaxSql, noteTarget};
    }
  }

  // Helper to register a command that adds a memory counter track.
  private registerMemoryCommand(
    ctx: Trace,
    id: string,
    name: string,
    sqlValueExpr: string,
    titleSuffix: string,
  ) {
    ctx.commands.registerCommand({
      id,
      name,
      callback: async (pidsArg) => {
        // Use the PIDs argument if provided, otherwise prompt.
        const pids =
          typeof pidsArg === 'string'
            ? pidsArg
            : await ctx.omnibox.prompt(
                `Enter up to ${MAX_AGGREGATED_PIDS} process pids, separated by commas (e.g. 1234, 5678)`,
              );

        if (!pids) return;

        const pidList = pids
          .split(',')
          .map((pid) => pid.trim())
          .filter((pid) => pid);

        if (pidList.length === 0) {
          return;
        }

        if (pidList.length > MAX_AGGREGATED_PIDS) {
          alert(
            `Please enter at most ${MAX_AGGREGATED_PIDS} PIDs. You entered ${pidList.length}.`,
          );
          return;
        }

        const trash = new AsyncDisposableStack();
        await using _disposer = trash;

        try {
          await this.setupTables(ctx);

          // Add individual tracks for each PID.
          for (const pid of pidList) {
            await addDebugCounterTrack({
              trace: ctx,
              data: {
                sqlSource: `
                  SELECT
                    ts,
                    (${sqlValueExpr}) AS value
                  FROM process_memory_breakdown
                  WHERE pid = ${pid}
                `,
                columns: ['ts', 'value'],
              },
              title: `${pid}${titleSuffix}`,
            });
          }

          const {findMaxSql, noteTarget} = await this.prepareAnnotationData(
            ctx,
            id,
            pidList,
            sqlValueExpr,
            titleSuffix,
            trash,
          );

          await this.addMaxMemoryAnnotation(ctx, findMaxSql, noteTarget);
        } catch (e) {
          console.error(`PixelMemory Plugin: Error in command ${id}:`, e);
          alert(
            `PixelMemory Plugin Error: ${e instanceof Error ? e.message : e}`,
          );
        }
      },
    });
  }

  async onTraceLoad(ctx: Trace): Promise<void> {
    this.registerMemoryCommand(
      ctx,
      'com.google.ShowPixelTotalMemory',
      'Add tracks: show process total memory',
      'COALESCE(rss_and_swap, 0) + COALESCE(gpu_memory, 0)',
      '_rss_anon_file_swap_shmem_gpu',
    );

    this.registerMemoryCommand(
      ctx,
      'com.google.ShowPixelRssAnonShmemSwapGpuMemory',
      'Add tracks: show process total memory (excluding file RSS)',
      'COALESCE(anon_rss_and_swap, 0) + COALESCE(shmem_rss, 0) + ' +
        'COALESCE(gpu_memory, 0)',
      '_rss_anon_shmem_swap_gpu',
    );
  }
}
