// Copyright (C) 2023 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 {Registry} from '../base/registry';
import {Track, TrackDescriptor, TrackManager} from '../public/track';
import {AsyncLimiter} from '../base/async_limiter';
import {TrackRenderContext} from '../public/track';

export interface TrackRenderer {
  readonly track: Track;
  desc: TrackDescriptor;
  render(ctx: TrackRenderContext): void;
  getError(): Error | undefined;
}

/**
 * TrackManager is responsible for managing the registry of tracks and their
 * lifecycle of tracks over render cycles.
 *
 * Example usage:
 * function render() {
 *   const trackCache = new TrackCache();
 *   const foo = trackCache.getTrackRenderer('foo', 'exampleURI', {});
 *   const bar = trackCache.getTrackRenderer('bar', 'exampleURI', {});
 *   trackCache.flushOldTracks(); // <-- Destroys any unused cached tracks
 * }
 *
 * Example of how flushing works:
 * First cycle
 *   getTrackRenderer('foo', ...) <-- new track 'foo' created
 *   getTrackRenderer('bar', ...) <-- new track 'bar' created
 *   flushTracks()
 * Second cycle
 *   getTrackRenderer('foo', ...) <-- returns cached 'foo' track
 *   flushTracks() <-- 'bar' is destroyed, as it was not resolved this cycle
 * Third cycle
 *   flushTracks() <-- 'foo' is destroyed.
 */
export class TrackManagerImpl implements TrackManager {
  private tracks = new Registry<TrackFSM>((x) => x.desc.uri);

  // This property is written by scroll_helper.ts and read&cleared by the
  // track_panel.ts. This exist for the following use case: the user wants to
  // scroll to track X, but X is not visible because it's in a collapsed group.
  // So we want to stash this information in a place that track_panel.ts can
  // access when creating dom elements.
  //
  // Note: this is the node id of the track node to scroll to, not the track
  // uri, as this allows us to scroll to tracks that have no uri.
  scrollToTrackNodeId?: string;

  registerTrack(trackDesc: TrackDescriptor): Disposable {
    return this.tracks.register(new TrackFSM(trackDesc));
  }

  findTrack(
    predicate: (desc: TrackDescriptor) => boolean | undefined,
  ): TrackDescriptor | undefined {
    for (const t of this.tracks.values()) {
      if (predicate(t.desc)) return t.desc;
    }
    return undefined;
  }

  getAllTracks(): TrackDescriptor[] {
    return Array.from(this.tracks.valuesAsArray().map((t) => t.desc));
  }

  // Look up track into for a given track's URI.
  // Returns |undefined| if no track can be found.
  getTrack(uri: string): TrackDescriptor | undefined {
    return this.tracks.tryGet(uri)?.desc;
  }

  // This is only called by the viewer_page.ts.
  getTrackRenderer(uri: string): TrackRenderer | undefined {
    // Search for a cached version of this track,
    const trackFsm = this.tracks.tryGet(uri);
    trackFsm?.markUsed();
    return trackFsm;
  }

  // Destroys all tracks that didn't recently get a getTrackRenderer() call.
  flushOldTracks() {
    for (const trackFsm of this.tracks.values()) {
      trackFsm.tick();
    }
  }
}

const DESTROY_IF_NOT_SEEN_FOR_TICK_COUNT = 1;

/**
 * Owns all runtime information about a track and manages its lifecycle,
 * ensuring lifecycle hooks are called synchronously and in the correct order.
 *
 * There are quite some subtle properties that this class guarantees:
 * - It make sure that lifecycle methods don't overlap with each other.
 * - It prevents a chain of onCreate > onDestroy > onCreate if the first
 *   onCreate() is still oustanding. This is by virtue of using AsyncLimiter
 *   which under the hoods holds only the most recent task and skips the
 *   intermediate ones.
 * - Ensures that a track never sees two consecutive onCreate, or onDestroy or
 *   an onDestroy without an onCreate.
 * - Ensures that onUpdate never overlaps or follows with onDestroy. This is
 *   particularly important because tracks often drop tables/views onDestroy
 *   and they shouldn't try to fetch more data onUpdate past that point.
 */
class TrackFSM implements TrackRenderer {
  public readonly desc: TrackDescriptor;

  private readonly limiter = new AsyncLimiter();
  private error?: Error;
  private tickSinceLastUsed = 0;
  private created = false;

  constructor(desc: TrackDescriptor) {
    this.desc = desc;
  }

  markUsed(): void {
    this.tickSinceLastUsed = 0;
  }

  // Increment the lastUsed counter, and maybe call onDestroy().
  tick(): void {
    if (this.tickSinceLastUsed++ === DESTROY_IF_NOT_SEEN_FOR_TICK_COUNT) {
      // Schedule an onDestroy
      this.limiter.schedule(async () => {
        // Don't enter the track again once an error is has occurred
        if (this.error !== undefined) {
          return;
        }

        try {
          if (this.created) {
            await Promise.resolve(this.track.onDestroy?.());
            this.created = false;
          }
        } catch (e) {
          this.error = e;
        }
      });
    }
  }

  render(ctx: TrackRenderContext): void {
    this.limiter.schedule(async () => {
      // Don't enter the track again once an error has occurred
      if (this.error !== undefined) {
        return;
      }

      try {
        // Call onCreate() if this is our first call
        if (!this.created) {
          await this.track.onCreate?.(ctx);
          this.created = true;
        }
        await Promise.resolve(this.track.onUpdate?.(ctx));
      } catch (e) {
        this.error = e;
      }
    });
    this.track.render(ctx);
  }

  getError(): Error | undefined {
    return this.error;
  }

  get track(): Track {
    return this.desc.track;
  }
}
