// 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 m from 'mithril';
import type {Engine} from '../../trace_processor/engine';
import type {Trace} from '../../public/trace';
import type {Setting} from '../../public/settings';
import {NUM} from '../../trace_processor/query_result';

import {SQL_PREAMBLE} from './components';
import {flamegraphQuery} from './views/flamegraph_objects_view';
import * as queries from './queries';
import {
  type NavState,
  type NavView,
  stateToSubpage,
  subpageToState,
} from './nav_state';
import type {OverviewData} from './types';
import type {FlamegraphState} from '../../widgets/flamegraph';
import {
  METRIC_DOMINATED_OBJECT_SIZE,
  METRIC_OBJECT_SIZE,
} from './views/flamegraph_view';

interface FlamegraphSelection {
  readonly pathHashes: string;
  readonly isDominator: boolean;
  readonly upid: number;
  readonly ts: bigint;
}

interface FlamegraphTab extends FlamegraphSelection {
  readonly id: number;
  count: number | null;
}

interface InstanceTab {
  readonly id: number;
  readonly objId: number;
  readonly label: string;
}

const INSTANCE_LABEL_MAX = 30;

function truncateInstanceLabel(label: string): string {
  return label.length > INSTANCE_LABEL_MAX
    ? label.slice(0, INSTANCE_LABEL_MAX) + '…'
    : label;
}

// Created per onTraceLoad and replaced on the next one, so per-trace
// state disappears together. Dump switching within a trace keeps the
// session but drops per-dump caches.
export class HeapDumpExplorerSession {
  private _nav: NavState = {view: 'overview', params: {}};
  private _navigateCallback?: (subpage: string) => void;

  private _dumps: ReadonlyArray<queries.HeapDump> = [];
  private _activeDump: queries.HeapDump | null = null;

  private readonly _flamegraphTabs: FlamegraphTab[] = [];
  private _nextFlamegraphId = 0;
  private _activeFlamegraphId: number | null = null;

  private readonly _instanceTabs: InstanceTab[] = [];
  private _nextInstanceId = 0;
  private _activeInstanceId: number | null = null;

  private _overview: OverviewData | null = null;

  // Persists across tab switches; reset on dump change so a new dump
  // opens with defaults instead of the prior dump's filters.
  private _flamegraphPanelState: FlamegraphState | undefined;

  // Set when the plugin auto-redirected to HDE on load; gates the
  // "default view changed" hint on the overview.
  autoNavigated = false;

  constructor(
    readonly trace: Trace,
    readonly engine: Engine,
    readonly hideDefaultChangedHint: Setting<boolean>,
  ) {}

  get dumps(): ReadonlyArray<queries.HeapDump> {
    return this._dumps;
  }

  get activeDump(): queries.HeapDump | null {
    return this._activeDump;
  }

  async loadDumps(): Promise<void> {
    this._dumps = await queries.loadDumpsList(this.engine);
    this._activeDump = this._dumps.length > 0 ? this._dumps[0] : null;
  }

  selectDump(d: queries.HeapDump): void {
    if (this._activeDump === d) return;
    this.switchToDump(d);
    if (
      this._nav.view === 'object' ||
      this._nav.view === 'flamegraph-objects'
    ) {
      this.navigate('overview');
    }
    m.redraw();
  }

  private switchToDump(d: queries.HeapDump): void {
    this._activeDump = d;
    this.resetDumpScopedState();
    void this.loadOverview();
  }

  get nav(): NavState {
    return this._nav;
  }

  setNavigateCallback(cb: ((subpage: string) => void) | undefined): void {
    this._navigateCallback = cb;
  }

  navigate(view: NavView, params: Record<string, unknown> = {}): void {
    this._nav = {view, params} as NavState;
    this._navigateCallback?.(stateToSubpage(this._nav));
    m.redraw();
  }

  // Arrow property: passed by reference into Mithril attrs.
  readonly navigateWithTabs = (
    view: NavView,
    params?: Record<string, unknown>,
  ): void => {
    if (view === 'object') {
      this.openInstanceTab(
        params?.id as number,
        params?.label as string | undefined,
      );
      this.navigate(view, params);
      return;
    }
    this._activeInstanceId = null;
    this.navigate(view, params);
  };

  readonly clearNavParam = (key: string): void => {
    delete (this._nav.params as Record<string, unknown>)[key];
  };

  syncFromSubpage(subpage: string | undefined): void {
    const sub = subpage?.startsWith('/') ? subpage.slice(1) : subpage;
    // The router strips query params from `subpage`; compare path-only.
    const currentPath = stateToSubpage(this._nav).split('?')[0];
    const incomingPath = (sub ?? '').split('?')[0];
    if (incomingPath !== currentPath) {
      this._nav = subpageToState(sub);
    }
  }

  get flamegraphTabs(): ReadonlyArray<FlamegraphTab> {
    return this._flamegraphTabs;
  }

  get activeFlamegraphId(): number | null {
    return this._activeFlamegraphId;
  }

  setActiveFlamegraphTab(id: number): void {
    this._activeFlamegraphId = id;
  }

  clearActiveFlamegraphTab(): void {
    this._activeFlamegraphId = null;
  }

  openFlamegraph(sel: FlamegraphSelection): void {
    const target = this._dumps.find(
      (d) => d.upid === sel.upid && d.ts === sel.ts,
    );
    if (target && target !== this._activeDump) {
      this.switchToDump(target);
    }
    const existing = this._flamegraphTabs.find(
      (t) =>
        t.pathHashes === sel.pathHashes && t.isDominator === sel.isDominator,
    );
    if (existing) {
      this._activeFlamegraphId = existing.id;
      this.navigate('flamegraph-objects');
      return;
    }
    const tab: FlamegraphTab = {
      id: this._nextFlamegraphId++,
      count: null,
      pathHashes: sel.pathHashes,
      isDominator: sel.isDominator,
      upid: sel.upid,
      ts: sel.ts,
    };
    this._flamegraphTabs.push(tab);
    this._activeFlamegraphId = tab.id;
    this.navigate('flamegraph-objects');

    const q = flamegraphQuery(sel.pathHashes, sel.isDominator);
    this.engine
      .query(`${SQL_PREAMBLE}; SELECT COUNT(*) AS c FROM (${q})`)
      .then((r) => {
        tab.count = Number(r.firstRow({c: NUM}).c);
        m.redraw();
      })
      .catch(console.error);
  }

  closeFlamegraph(id: number): void {
    const idx = this._flamegraphTabs.findIndex((t) => t.id === id);
    if (idx === -1) return;
    this._flamegraphTabs.splice(idx, 1);
    if (this._activeFlamegraphId === id) {
      this._activeFlamegraphId = null;
      this.navigate('overview');
    }
  }

  get instanceTabs(): ReadonlyArray<InstanceTab> {
    return this._instanceTabs;
  }

  get activeInstanceId(): number | null {
    return this._activeInstanceId;
  }

  setActiveInstanceTab(id: number): void {
    this._activeInstanceId = id;
  }

  clearActiveInstanceTab(): void {
    this._activeInstanceId = null;
  }

  private openInstanceTab(objId: number, label?: string): void {
    const existing = this._instanceTabs.find((t) => t.objId === objId);
    if (existing) {
      this._activeInstanceId = existing.id;
      return;
    }
    const tab: InstanceTab = {
      id: this._nextInstanceId++,
      objId,
      label: truncateInstanceLabel(label ?? 'Instance'),
    };
    this._instanceTabs.push(tab);
    this._activeInstanceId = tab.id;
  }

  closeInstanceTab(id: number): void {
    const idx = this._instanceTabs.findIndex((t) => t.id === id);
    if (idx === -1) return;
    this._instanceTabs.splice(idx, 1);
    if (this._activeInstanceId === id) {
      this._activeInstanceId = null;
      this.navigate('overview');
    }
  }

  syncInstanceTabFromNav(): void {
    if (this._nav.view !== 'object') {
      this._activeInstanceId = null;
      return;
    }
    const {id, label} = this._nav.params;
    const existing = this._instanceTabs.find((t) => t.objId === id);
    if (existing) {
      this._activeInstanceId = existing.id;
    } else {
      this.openInstanceTab(id, label);
    }
  }

  private resetDumpScopedState(): void {
    this._overview = null;
    this._flamegraphTabs.length = 0;
    this._nextFlamegraphId = 0;
    this._activeFlamegraphId = null;
    this._instanceTabs.length = 0;
    this._nextInstanceId = 0;
    this._activeInstanceId = null;
    this._flamegraphPanelState = undefined;
  }

  get cachedOverview(): OverviewData | null {
    return this._overview;
  }

  get flamegraphPanelState(): FlamegraphState | undefined {
    return this._flamegraphPanelState;
  }

  readonly setFlamegraphPanelState = (s: FlamegraphState): void => {
    this._flamegraphPanelState = s;
  };

  // Open the flamegraph pivoted at `pathHash`. The metric is chosen to
  // match the tree the hash came from. The chip displays
  // `<label> (this instance)` since the raw hash regex is unreadable.
  readonly openFlamegraphPivotedAt = (
    pathHash: string,
    label: string,
    isDominator: boolean,
  ): void => {
    this._flamegraphPanelState = {
      selectedMetricName: isDominator
        ? METRIC_DOMINATED_OBJECT_SIZE
        : METRIC_OBJECT_SIZE,
      filters: [],
      view: {
        kind: 'PIVOT',
        pivot: `^${pathHash}$`,
        displayLabel: `${label} (this instance)`,
      },
    };
    this.navigate('flamegraph');
  };

  // Pins the dump at fetch start; if the user switches dumps before
  // the result arrives, the result is dropped instead of briefly
  // displaying the wrong dump's overview.
  async loadOverview(): Promise<void> {
    if (this._overview !== null) return;
    const dump = this._activeDump;
    if (dump === null) return;
    try {
      const data = await queries.getOverview(this.engine, dump);
      if (this._activeDump === dump) this._overview = data;
    } catch (err) {
      console.error('Failed to load overview:', err);
    } finally {
      m.redraw();
    }
  }
}
