// Copyright (C) 2026 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 type {PerfettoPlugin} from '../../public/plugin';
import type {Trace} from '../../public/trace';
import {TrackNode} from '../../public/workspace';
import {CounterTrack} from '../../components/tracks/counter_track';
import {SliceTrack} from '../../components/tracks/slice_track';
import {
  BreakdownTrackAggType,
  BreakdownTracks,
} from '../../components/tracks/breakdown_tracks';
import {uuidv4} from '../../base/uuid';
import {getTimeSpanOfSelectionOrVisibleWindow} from '../../public/utils';
import type {TimeSpan} from '../../base/time';
import {SourceDataset} from '../../trace_processor/dataset';
import {
  LONG,
  NUM,
  NUM_NULL,
  STR,
  STR_NULL,
} from '../../trace_processor/query_result';
import {createPerfettoTable} from '../../trace_processor/sql_utils';
import {HSLColor} from '../../base/color';
import {makeColorScheme} from '../../components/colorizer';
import StandardGroupsPlugin from '../dev.perfetto.StandardGroups';

const KSWAPD_COLOR = makeColorScheme(new HSLColor('#2196F3')); // Blue 500

export default class MemoryViz implements PerfettoPlugin {
  static readonly id = 'com.android.MemoryViz';
  static readonly dependencies = [StandardGroupsPlugin];

  async onTraceLoad(ctx: Trace): Promise<void> {
    const memoryGroup = ctx.plugins
      .getPlugin(StandardGroupsPlugin)
      .getOrCreateStandardGroup(ctx.defaultWorkspace, 'MEMORY');

    await ctx.engine.query(`
      INCLUDE PERFETTO MODULE intervals.overlap;
      INCLUDE PERFETTO MODULE slices.with_context;
      INCLUDE PERFETTO MODULE android.memory.lmk;
    `);

    await this.addKswapdTrack(ctx, memoryGroup);
    await this.addDirectReclaimTracks(ctx, memoryGroup);
    await this.addMemcgReclaimTracks(ctx, memoryGroup);
    await this.addLmkTracks(ctx, memoryGroup);

    ctx.commands.registerCommand({
      id: `com.android.visualizeMemory`,
      name: 'Memory: Visualize (over selection)',
      callback: async () => {
        await ctx.engine.query(`
          INCLUDE PERFETTO MODULE intervals.intersect;
          INCLUDE PERFETTO MODULE android.memory.memory_breakdown;
        `);
        const window = await getTimeSpanOfSelectionOrVisibleWindow(ctx);

        const tracks = [
          ['mem.rss.file', 'RSS File'],
          ['mem.rss.shmem', 'RSS Shmem'],
          ['mem.dmabuf_rss', 'DMA buffer RSS'],
          ['mem.heap', 'Heap Size'],
          ['mem.locked', 'Locked Memory'],
          ['GPU Memory', 'GPU Memory'],
        ];

        const trackPromises: Promise<TrackNode | undefined>[] = [
          this.createRssAnonSwapTrack(ctx, window),
        ];
        trackPromises.push(
          ...tracks.map(([trackName, displayName]) =>
            this.createBreakdownTrack(ctx, window, trackName, displayName),
          ),
        );
        const createdTracks = await Promise.all(trackPromises);

        for (const track of createdTracks) {
          if (track) {
            ctx.defaultWorkspace.pinnedTracksNode.addChildLast(track);
          }
        }
      },
    });
  }

  private async addKswapdTrack(ctx: Trace, parent: TrackNode): Promise<void> {
    const tableName = 'memory_viz_kswapd';
    await createPerfettoTable({
      engine: ctx.engine,
      name: tableName,
      as: `
        SELECT
          row_number() OVER (ORDER BY ts) AS id,
          ts,
          dur,
          thread.name AS name
        FROM sched
        JOIN thread USING (utid)
        WHERE thread.name GLOB 'kswapd0*' AND dur > 0
      `,
    });

    const rowCount = await ctx.engine.query(
      `SELECT COUNT(*) AS n FROM ${tableName}`,
    );
    if (rowCount.firstRow({n: NUM}).n === 0) {
      return;
    }

    const uri = `${MemoryViz.id}#kswapd`;
    ctx.tracks.registerTrack({
      uri,
      description:
        'Shows when the background page reclaim daemon (kswapd) is running on a CPU. ',
      renderer: SliceTrack.create({
        trace: ctx,
        uri,
        dataset: new SourceDataset({
          src: tableName,
          schema: {id: NUM, ts: LONG, dur: LONG, name: STR},
        }),
        colorizer: () => KSWAPD_COLOR,
      }),
    });
    parent.addChildInOrder(
      new TrackNode({uri, name: 'Kswapd', sortOrder: 101}),
    );
  }

  private async addDirectReclaimTracks(
    ctx: Trace,
    parent: TrackNode,
  ): Promise<void> {
    const tableName = 'memory_viz_direct_reclaim';
    await createPerfettoTable({
      engine: ctx.engine,
      name: tableName,
      as: `
        SELECT id, ts, dur, name, process_name, thread_name
        FROM thread_slice
        WHERE name GLOB 'mm_vmscan_direct_reclaim' AND dur > 0
      `,
    });

    const rowCount = await ctx.engine.query(
      `SELECT COUNT(*) AS n FROM ${tableName}`,
    );
    if (rowCount.firstRow({n: NUM}).n === 0) {
      return;
    }

    const breakdowns = new BreakdownTracks({
      trace: ctx,
      trackTitle: 'Direct Reclaim',
      description: `Shows synchronous page reclaim events. This usually indicates
        severe system-wide memory pressure, forcing an app's own threads
        to perform synchronous reclaim.`,
      aggregationType: BreakdownTrackAggType.COUNT,
      aggregation: {
        columns: ['process_name', 'thread_name'],
        tsCol: 'ts',
        durCol: 'dur',
        tableName,
      },
      slice: {
        columns: ['name'],
        tsCol: 'ts',
        durCol: 'dur',
        tableName,
      },
      sliceIdColumn: 'id',
      sortTracks: true,
    });

    const directReclaimNode = await breakdowns.createTracks();
    directReclaimNode.sortOrder = 102;
    parent.addChildInOrder(directReclaimNode);
  }

  private async addMemcgReclaimTracks(
    ctx: Trace,
    parent: TrackNode,
  ): Promise<void> {
    const tableName = 'memory_viz_memcg_reclaim';
    await createPerfettoTable({
      engine: ctx.engine,
      name: tableName,
      as: `
        SELECT id, ts, dur, name,
        COALESCE(process_name, 'Unknown Process') AS process_name,
        COALESCE(thread_name, 'Unknown Thread') AS thread_name
        FROM thread_slice
        WHERE name GLOB 'mm_vmscan_memcg_reclaim*' AND dur > 0
      `,
    });

    const rowCount = await ctx.engine.query(
      `SELECT COUNT(*) AS n FROM ${tableName}`,
    );
    if (rowCount.firstRow({n: NUM}).n === 0) {
      return;
    }

    const breakdowns = new BreakdownTracks({
      trace: ctx,
      trackTitle: 'Memory Cgroup Reclaim',
      description: `Shows memory cgroup reclaim events. This indicates
        an app is allocating past its memory budget, forcing its own threads
        to perform synchronous reclaim.`,
      aggregationType: BreakdownTrackAggType.COUNT,
      aggregation: {
        columns: ['process_name', 'thread_name'],
        tsCol: 'ts',
        durCol: 'dur',
        tableName,
      },
      slice: {
        columns: ['name'],
        tsCol: 'ts',
        durCol: 'dur',
        tableName,
      },
      sliceIdColumn: 'id',
      sortTracks: true,
    });

    const memcgReclaimNode = await breakdowns.createTracks();
    memcgReclaimNode.sortOrder = 103;
    parent.addChildInOrder(memcgReclaimNode);
  }

  private async addLmkTracks(ctx: Trace, parent: TrackNode): Promise<void> {
    const tableName = 'memory_viz_lmk_slices';
    await createPerfettoTable({
      engine: ctx.engine,
      name: tableName,
      as: `
        SELECT
          row_number() OVER (ORDER BY ts) AS id,
          ts,
          0 AS dur,
          upid,
          pid,
          COALESCE(process_name, 'Unknown') AS process_name,
          oom_score_adj,
          android_oom_adj_score_to_bucket_name(oom_score_adj) AS oom_bucket,
          kill_reason
        FROM android_lmk_events
      `,
    });

    const buckets = await ctx.engine.query(`
      SELECT DISTINCT oom_bucket
      FROM ${tableName}
      WHERE oom_bucket IS NOT NULL
      ORDER BY oom_bucket
    `);
    if (buckets.numRows() === 0) {
      return;
    }

    const lmkUri = `${MemoryViz.id}#lmk`;
    ctx.tracks.registerTrack({
      uri: lmkUri,
      description:
        'Shows Android Low Memory Killer (LMK) events. ' +
        'Each event marks a process kill.',
      renderer: SliceTrack.create({
        trace: ctx,
        uri: lmkUri,
        dataset: new SourceDataset({
          src: tableName,
          schema: {
            id: NUM,
            ts: LONG,
            dur: LONG,
            upid: NUM_NULL,
            pid: NUM_NULL,
            process_name: STR,
            oom_score_adj: NUM,
            oom_bucket: STR,
            kill_reason: STR_NULL,
          },
        }),
        sliceName: (row) => row.process_name,
      }),
    });
    const lmkGroup = new TrackNode({
      uri: lmkUri,
      name: 'LMK',
      isSummary: true,
      sortOrder: 100,
    });
    parent.addChildInOrder(lmkGroup);

    for (const it = buckets.iter({oom_bucket: STR}); it.valid(); it.next()) {
      const bucket = it.oom_bucket;
      const uri = `${MemoryViz.id}#lmk.${bucket}`;
      ctx.tracks.registerTrack({
        uri,
        description: `Low Memory Killer events for processes in the '${bucket}' OOM adjustment bucket.`,
        renderer: SliceTrack.create({
          trace: ctx,
          uri,
          dataset: new SourceDataset({
            src: tableName,
            schema: {
              id: NUM,
              ts: LONG,
              dur: LONG,
              upid: NUM_NULL,
              pid: NUM_NULL,
              process_name: STR,
              oom_score_adj: NUM,
              oom_bucket: STR,
              kill_reason: STR_NULL,
            },
            filter: {col: 'oom_bucket', eq: bucket},
          }),
          sliceName: (row) => row.process_name,
        }),
      });
      lmkGroup.addChildInOrder(new TrackNode({uri, name: bucket}));
    }
  }

  private async createTrack(
    ctx: Trace,
    uri: string,
    sqlSource: string,
    name: string,
    description: string,
    removable = true,
    sortOrder?: number,
  ): Promise<TrackNode> {
    const track = CounterTrack.create({
      trace: ctx,
      uri,
      sqlSource,
    });
    ctx.tracks.registerTrack({
      uri,
      renderer: track,
      description,
    });

    return new TrackNode({
      uri,
      name,
      removable,
      sortOrder,
    });
  }

  private async createRssAnonSwapTrack(
    ctx: Trace,
    window: TimeSpan,
  ): Promise<TrackNode | undefined> {
    const uri = `${MemoryViz.id}.rss_anon_swap.${uuidv4()}`;
    const sqlSource = this.getSqlSource(window, [
      `memory_track_name IN ('mem.rss.anon', 'mem.swap')`,
    ]);
    const rootNode = await this.createTrack(
      ctx,
      uri,
      sqlSource,
      'RSS Anon + Swap',
      'Sum of mem.rss.anon and mem.swap memory across all processes.',
      true,
    );

    const rssAnonNode = await this.createBreakdownTrack(
      ctx,
      window,
      'mem.rss.anon',
      'RSS Anon',
    );
    const swapNode = await this.createBreakdownTrack(
      ctx,
      window,
      'mem.swap',
      'Swap',
    );

    if (rssAnonNode) {
      rootNode.addChildLast(rssAnonNode);
    }
    if (swapNode) {
      rootNode.addChildLast(swapNode);
    }

    return rootNode;
  }

  private async createBreakdownTrack(
    ctx: Trace,
    window: TimeSpan,
    trackName: string,
    name: string,
  ): Promise<TrackNode | undefined> {
    const uri = `${MemoryViz.id}.${trackName}.${uuidv4()}`;
    const sqlSource = this.getSqlSource(window, [
      `memory_track_name = '${trackName}'`,
    ]);
    const breakdownNode = await this.createTrack(
      ctx,
      uri,
      sqlSource,
      name,
      `Sum of ${trackName} memory across all processes.`,
      true,
    );

    const buckets = await ctx.engine.query(`
      SELECT DISTINCT bucket
      FROM android_process_memory_intervals_by_oom_bucket
      WHERE memory_track_name = '${trackName}'
      ORDER BY bucket ASC
    `);

    for (const iter = buckets.iter({}); iter.valid(); iter.next()) {
      const bucket = iter.get('bucket') as string;
      const bucketNode = await this.createOomBucketTrack(
        ctx,
        window,
        trackName,
        bucket,
      );
      if (bucketNode) {
        breakdownNode.addChildInOrder(bucketNode);
      }
    }

    return breakdownNode;
  }

  private async createOomBucketTrack(
    ctx: Trace,
    window: TimeSpan,
    trackName: string,
    bucket: string,
  ): Promise<TrackNode | undefined> {
    const processes = await ctx.engine.query(`
      SELECT
        upid,
        pid,
        process_name,
        MAX(zygote_adjusted_value) AS max_value
      FROM android_process_memory_intervals_by_oom_bucket
      WHERE
        bucket = '${bucket}' AND
        memory_track_name = '${trackName}' AND
        ts < ${window.end} AND
        ts + dur > ${window.start}
      GROUP BY upid, pid, process_name
      HAVING max_value > 0
      ORDER BY max_value DESC
    `);

    const numProcesses = processes.numRows();
    if (numProcesses === 0) {
      return undefined;
    }

    const plural = numProcesses === 1 ? '' : 'es';
    const name = `${bucket} (${numProcesses} process${plural})`;

    const uri = `${MemoryViz.id}.${trackName}.${bucket}.${uuidv4()}`;
    const sqlSource = this.getSqlSource(window, [
      `bucket = '${bucket}'`,
      `memory_track_name = '${trackName}'`,
    ]);
    const peak = await ctx.engine.query(
      `SELECT MAX(value) AS peak FROM (${sqlSource})`,
    );
    const peakValue = peak.iter({}).get('peak') as number;

    const bucketNode = await this.createTrack(
      ctx,
      uri,
      sqlSource,
      name,
      `Sum of ${trackName} memory across all processes while in the '${
        bucket
      }' OOM bucket (0 otherwise).`,
      true,
      -peakValue, // Sort buckets in descending order of peak memory usage
    );

    for (
      const procIter = processes.iter({});
      procIter.valid();
      procIter.next()
    ) {
      const upid = procIter.get('upid') as number;
      const pid = procIter.get('pid') as number;
      const processName = procIter.get('process_name') as string;
      const processNode = await this.createSingleProcessTrack(
        ctx,
        window,
        upid,
        pid,
        processName,
        trackName,
        bucket,
      );
      bucketNode.addChildLast(processNode);
    }
    return bucketNode;
  }

  private async createSingleProcessTrack(
    ctx: Trace,
    window: TimeSpan,
    upid: number,
    pid: number,
    processName: string,
    trackName: string,
    bucket: string,
  ): Promise<TrackNode> {
    const name = `${processName} ${pid}`;
    const uri = `${MemoryViz.id}.process.${upid}.${trackName}.${uuidv4()}`;
    const sqlSource = this.getSqlSource(window, [
      `bucket = '${bucket}'`,
      `memory_track_name = '${trackName}'`,
      `upid = ${upid}`,
    ]);
    return this.createTrack(
      ctx,
      uri,
      sqlSource,
      name,
      `Process ${processName} (${pid}) ${trackName} memory while in the '${
        bucket
      }' OOM bucket (0 otherwise).`,
      true,
    );
  }

  private getSqlSource(window: TimeSpan, whereClauses: string[] = []): string {
    const whereClause =
      whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
    return `
      SELECT iss.ts, SUM(IIF(iss.interval_ends_at_ts = FALSE, m.zygote_adjusted_value, 0)) as value FROM interval_self_intersect!((
        SELECT
          id,
          MAX(ts, ${window.start}) as ts,
          MIN(ts + dur, ${window.end}) - MAX(ts, ${window.start}) as dur
        FROM android_process_memory_intervals_by_oom_bucket
        ${whereClause} AND ts < ${window.end} and ts + dur > ${window.start}
      )) iss JOIN android_process_memory_intervals_by_oom_bucket m USING(id)
      GROUP BY group_id
    `;
  }
}
