// Copyright 2018 the V8 project authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

'use strict';

import {Isolate} from './model.js';

defineCustomElement('trace-file-reader', (templateText) =>
 class TraceFileReader extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.innerHTML = templateText;
    this.addEventListener('click', e => this.handleClick(e));
    this.addEventListener('dragover', e => this.handleDragOver(e));
    this.addEventListener('drop', e => this.handleChange(e));
    this.$('#file').addEventListener('change', e => this.handleChange(e));
    this.$('#fileReader').addEventListener('keydown', e => this.handleKeyEvent(e));
  }

  $(id) {
    return this.shadowRoot.querySelector(id);
  }

  get section() {
    return this.$('#fileReaderSection');
  }

  updateLabel(text) {
    this.$('#label').innerText = text;
  }

  handleKeyEvent(event) {
    if (event.key == "Enter") this.handleClick(event);
  }

  handleClick(event) {
    this.$('#file').click();
  }

  handleChange(event) {
    // Used for drop and file change.
    event.preventDefault();
    var host = event.dataTransfer ? event.dataTransfer : event.target;
    this.readFile(host.files[0]);
  }

  handleDragOver(event) {
    event.preventDefault();
  }

  connectedCallback() {
    this.$('#fileReader').focus();
  }

  readFile(file) {
    if (!file) {
      this.updateLabel('Failed to load file.');
      return;
    }
    this.$('#fileReader').blur();

    this.section.className = 'loading';
    const reader = new FileReader();

    if (['application/gzip', 'application/x-gzip'].includes(file.type)) {
      reader.onload = (e) => {
        try {
          const textResult = pako.inflate(e.target.result, {to: 'string'});
          this.processRawText(file, textResult);
          this.section.className = 'success';
          this.$('#fileReader').classList.add('done');
        } catch (err) {
          console.error(err);
          this.section.className = 'failure';
        }
      };
      // Delay the loading a bit to allow for CSS animations to happen.
      setTimeout(() => reader.readAsArrayBuffer(file), 0);
    } else if (file.type == 'text/html') {
      // try extracting the data from a results.html file
      reader.onload = (e) => {
        try {
          let html = document.createElement('html');
          html.innerHTML = e.target.result;
          for (let dataScript of html.querySelectorAll('#viewer-data')) {
            const base64 = dataScript.innerText.slice(1,-1);
            const binary = globalThis.atob(base64);
            const textResult = pako.inflate(binary, {to: 'string'});
            this.processRawText(file, textResult);
          }
          this.section.className = 'success';
          this.$('#fileReader').classList.add('done');
        } catch (err) {
          console.error(err);
          this.section.className = 'failure';
        }
      };
      // Delay the loading a bit to allow for CSS animations to happen.
      setTimeout(() => reader.readAsText(file), 0);
    } else {
      reader.onload = (e) => {
        try {
          this.processRawText(file, e.target.result);
          this.section.className = 'success';
          this.$('#fileReader').classList.add('done');
        } catch (err) {
          console.error(err);
          this.section.className = 'failure';
        }
      };
      // Delay the loading a bit to allow for CSS animations to happen.
      setTimeout(() => reader.readAsText(file), 0);
    }
  }

  processRawText(file, result) {
    let return_data;
    if (result.includes('V8.GC_Objects_Stats')) {
      return_data = this.createModelFromChromeTraceFile(result);
    } else {
      let contents = result.split('\n');
      return_data = this.createModelFromV8TraceFile(contents);
    }
    this.extendAndSanitizeModel(return_data);
    this.updateLabel('Finished loading \'' + file.name + '\'.');
    this.dispatchEvent(new CustomEvent(
        'change', {bubbles: true, composed: true, detail: return_data}));
  }

  createOrUpdateEntryIfNeeded(data, entry) {
    console.assert(entry.isolate, 'entry should have an isolate');
    if (!(entry.isolate in data)) {
      data[entry.isolate] = new Isolate(entry.isolate);
    }
    const data_object = data[entry.isolate];
    if (('id' in entry) && !(entry.id in data_object.gcs)) {
      data_object.gcs[entry.id] = {non_empty_instance_types: new Set()};
    }
    if ('time' in entry) {
      if (data_object.end === null || data_object.end < entry.time) {
        data_object.end = entry.time;
      }
      if (data_object.start === null || data_object.start > entry.time) {
        data_object.start = entry.time;
      }
    }
  }

  createDatasetIfNeeded(data, entry, data_set) {
    if (!(data_set in data[entry.isolate].gcs[entry.id])) {
      data[entry.isolate].gcs[entry.id][data_set] = {
        instance_type_data: {},
        non_empty_instance_types: new Set(),
        overall: 0
      };
      data[entry.isolate].data_sets.add(data_set);
    }
  }

  addFieldTypeData(data, isolate, gc_id, data_set, tagged_fields,
                   inobject_smi_fields, embedder_fields, unboxed_double_fields,
                   boxed_double_fields, string_data, other_raw_fields) {
    data[isolate].gcs[gc_id][data_set].field_data = {
      tagged_fields,
      inobject_smi_fields,
      embedder_fields,
      unboxed_double_fields,
      boxed_double_fields,
      string_data,
      other_raw_fields
    };
  }

  addInstanceTypeData(data, isolate, gc_id, data_set, instance_type, entry) {
    data[isolate].gcs[gc_id][data_set].instance_type_data[instance_type] = {
      overall: entry.overall,
      count: entry.count,
      histogram: entry.histogram,
      over_allocated: entry.over_allocated,
      over_allocated_histogram: entry.over_allocated_histogram
    };
    data[isolate].gcs[gc_id][data_set].overall += entry.overall;
    if (entry.overall !== 0) {
      data[isolate].gcs[gc_id][data_set].non_empty_instance_types.add(
          instance_type);
      data[isolate].gcs[gc_id].non_empty_instance_types.add(instance_type);
      data[isolate].non_empty_instance_types.add(instance_type);
    }
  }

  extendAndSanitizeModel(data) {
    const checkNonNegativeProperty = (obj, property) => {
      console.assert(obj[property] >= 0, 'negative property', obj, property);
    };

    Object.values(data).forEach(isolate => isolate.finalize());
  }

  createModelFromChromeTraceFile(contents) {
    const data = Object.create(null);  // Final data container.
    const parseOneGCEvent = (actual_data) => {
      Object.keys(actual_data).forEach(data_set => {
        const string_entry = actual_data[data_set];
        try {
          const entry = JSON.parse(string_entry);
          this.createOrUpdateEntryIfNeeded(data, entry);
          this.createDatasetIfNeeded(data, entry, data_set);
          const isolate = entry.isolate;
          const time = entry.time;
          const gc_id = entry.id;
          data[isolate].gcs[gc_id].time = time;

          const field_data = entry.field_data;
          this.addFieldTypeData(data, isolate, gc_id, data_set,
            field_data.tagged_fields,
            field_data.inobject_smi_fields,
            field_data.embedder_fields,
            field_data.unboxed_double_fields,
            field_data.boxed_double_fields,
            field_data.string_data,
            field_data.other_raw_fields);

          data[isolate].gcs[gc_id][data_set].bucket_sizes =
              entry.bucket_sizes;
          for (let [instance_type, value] of Object.entries(
                   entry.type_data)) {
            // Trace file format uses markers that do not have actual
            // properties.
            if (!('overall' in value)) continue;
            this.addInstanceTypeData(
                data, isolate, gc_id, data_set, instance_type, value);
          }
        } catch (e) {
          console.error('Unable to parse data set entry', e);
        }
      });
    };
    console.log(`Processing log as chrome trace file.`);
    try {
      let gc_events_filter = (event) => {
        if (event.name == 'V8.GC_Objects_Stats') {
          parseOneGCEvent(event.args);
        }
        return oboe.drop;
      };

      let oboe_stream = oboe();
      // Trace files support two formats.
      oboe_stream
          // 1) {traceEvents: [ data ]}
          .node('traceEvents.*', gc_events_filter)
          // 2) [ data ]
          .node('!.*', gc_events_filter)
          .fail(() => { throw new Error("Trace data parse failed!"); });
      oboe_stream.emit('data', contents);
    } catch (e) {
      console.error('Unable to parse chrome trace file.', e);
    }
    return data;
  }

  createModelFromV8TraceFile(contents) {
    console.log('Processing log as V8 trace file.');
    contents = contents.map(function(line) {
      try {
        // Strip away a potentially present adb logcat prefix.
        line = line.replace(/^I\/v8\s*\(\d+\):\s+/g, '');
        return JSON.parse(line);
      } catch (e) {
        console.log('Unable to parse line: \'' + line + '\' (' + e + ')');
      }
      return null;
    });

    const data = Object.create(null);  // Final data container.
    for (var entry of contents) {
      if (entry === null || entry.type === undefined) {
        continue;
      }
      if (entry.type === 'zone') {
        this.createOrUpdateEntryIfNeeded(data, entry);
        const stacktrace = ('stacktrace' in entry) ? entry.stacktrace : [];
        data[entry.isolate].samples.zone[entry.time] = {
          allocated: entry.allocated,
          pooled: entry.pooled,
          stacktrace: stacktrace
        };
      } else if (
          entry.type === 'zonecreation' || entry.type === 'zonedestruction') {
        this.createOrUpdateEntryIfNeeded(data, entry);
        data[entry.isolate].zonetags.push(
            Object.assign({opening: entry.type === 'zonecreation'}, entry));
      } else if (entry.type === 'gc_descriptor') {
        this.createOrUpdateEntryIfNeeded(data, entry);
        data[entry.isolate].gcs[entry.id].time = entry.time;
        if ('zone' in entry)
          data[entry.isolate].gcs[entry.id].malloced = entry.zone;
      } else if (entry.type === 'field_data') {
        this.createOrUpdateEntryIfNeeded(data, entry);
        this.createDatasetIfNeeded(data, entry, entry.key);
        this.addFieldTypeData(data, entry.isolate, entry.id, entry.key,
          entry.tagged_fields, entry.embedder_fields, entry.inobject_smi_fields,
          entry.unboxed_double_fields, entry.boxed_double_fields,
          entry.string_data, entry.other_raw_fields);
      } else if (entry.type === 'instance_type_data') {
        if (entry.id in data[entry.isolate].gcs) {
          this.createOrUpdateEntryIfNeeded(data, entry);
          this.createDatasetIfNeeded(data, entry, entry.key);
          this.addInstanceTypeData(
              data, entry.isolate, entry.id, entry.key,
              entry.instance_type_name, entry);
        }
      } else if (entry.type === 'bucket_sizes') {
        if (entry.id in data[entry.isolate].gcs) {
          this.createOrUpdateEntryIfNeeded(data, entry);
          this.createDatasetIfNeeded(data, entry, entry.key);
          data[entry.isolate].gcs[entry.id][entry.key].bucket_sizes =
              entry.sizes;
        }
      } else {
        console.log('Unknown entry type: ' + entry.type);
      }
    }
    return data;
  }
});
