# Copyright 2017 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

import logging
import os
import subprocess
import threading
import time
import uuid

from devil.utils import reraiser_thread
from pylib import constants


_MINIUMUM_TIMEOUT = 3.0
_PER_LINE_TIMEOUT = .002  # Should be able to process 500 lines per second.
_PROCESS_START_TIMEOUT = 10.0
_MAX_RESTARTS = 10  # Should be plenty unless tool is crashing on start-up.


class Deobfuscator:
  def __init__(self, mapping_path):
    script_path = os.path.join(constants.DIR_SOURCE_ROOT, 'build', 'android',
                               'stacktrace', 'java_deobfuscate.py')
    cmd = [script_path, mapping_path]
    # Allow only one thread to call TransformLines() at a time.
    self._lock = threading.Lock()
    # Ensure that only one thread attempts to kill self._proc in Close().
    self._close_lock = threading.Lock()
    self._closed_called = False
    # Assign to None so that attribute exists if Popen() throws.
    self._proc = None
    # Start process eagerly to hide start-up latency.
    self._proc_start_time = time.time()
    self._proc = subprocess.Popen(cmd,
                                  bufsize=1,
                                  stdin=subprocess.PIPE,
                                  stdout=subprocess.PIPE,
                                  universal_newlines=True,
                                  close_fds=True)

  def IsClosed(self):
    return self._closed_called or self._proc.returncode is not None

  def IsBusy(self):
    return self._lock.locked()

  def IsReady(self):
    return not self.IsClosed() and not self.IsBusy()

  def TransformLines(self, lines):
    """Deobfuscates obfuscated names found in the given lines.

    If anything goes wrong (process crashes, timeout, etc), returns |lines|.

    Args:
      lines: A list of strings without trailing newlines.

    Returns:
      A list of strings without trailing newlines.
    """
    if not lines:
      return []

    # Deobfuscated stacks contain more frames than obfuscated ones when method
    # inlining occurs. To account for the extra output lines, keep reading until
    # this eof_line token is reached.
    eof_line = uuid.uuid4().hex
    out_lines = []

    def deobfuscate_reader():
      while True:
        line = self._proc.stdout.readline()
        # Return an empty string at EOF (when stdin is closed).
        if not line:
          break
        line = line[:-1]
        if line == eof_line:
          break
        out_lines.append(line)

    if self.IsBusy():
      logging.warning('deobfuscator: Having to wait for Java deobfuscation.')

    # Allow only one thread to operate at a time.
    with self._lock:
      if self.IsClosed():
        if not self._closed_called:
          logging.warning('deobfuscator: Process exited with code=%d.',
                          self._proc.returncode)
          self.Close()
        return lines

      # TODO(agrieve): Can probably speed this up by only sending lines through
      #     that might contain an obfuscated name.
      reader_thread = reraiser_thread.ReraiserThread(deobfuscate_reader)
      reader_thread.start()

      try:
        self._proc.stdin.write('\n'.join(lines))
        self._proc.stdin.write('\n{}\n'.format(eof_line))
        self._proc.stdin.flush()
        time_since_proc_start = time.time() - self._proc_start_time
        timeout = (max(0, _PROCESS_START_TIMEOUT - time_since_proc_start) +
                   max(_MINIUMUM_TIMEOUT, len(lines) * _PER_LINE_TIMEOUT))
        reader_thread.join(timeout)
        if self.IsClosed():
          logging.warning(
              'deobfuscator: Close() called by another thread during join().')
          return lines
        if reader_thread.is_alive():
          logging.error('deobfuscator: Timed out.')
          self.Close()
          return lines
        return out_lines
      except IOError:
        logging.exception('deobfuscator: Exception during java_deobfuscate')
        self.Close()
        return lines

  def Close(self):
    with self._close_lock:
      needs_closing = not self.IsClosed()
      self._closed_called = True

    if needs_closing:
      self._proc.stdin.close()
      self._proc.kill()
      self._proc.wait()

  def __del__(self):
    # self._proc is None when Popen() fails.
    if not self._closed_called and self._proc:
      logging.error('deobfuscator: Forgot to Close()')
      self.Close()


class DeobfuscatorPool:
  # As of Sep 2017, each instance requires about 500MB of RAM, as measured by:
  # /usr/bin/time -v build/android/stacktrace/java_deobfuscate.py \
  #     out/Release/apks/ChromePublic.apk.mapping
  def __init__(self, mapping_path, pool_size=4):
    self._mapping_path = mapping_path
    self._pool = [Deobfuscator(mapping_path) for _ in range(pool_size)]
    # Allow only one thread to select from the pool at a time.
    self._lock = threading.Lock()
    self._num_restarts = 0

  def TransformLines(self, lines):
    with self._lock:
      assert self._pool, 'TransformLines() called on a closed DeobfuscatorPool.'

      # De-obfuscation is broken.
      if self._num_restarts == _MAX_RESTARTS:
        raise Exception('Deobfuscation seems broken.')

      # Restart any closed Deobfuscators.
      for i, d in enumerate(self._pool):
        if d.IsClosed():
          logging.warning('deobfuscator: Restarting closed instance.')
          self._pool[i] = Deobfuscator(self._mapping_path)
          self._num_restarts += 1
          if self._num_restarts == _MAX_RESTARTS:
            logging.warning('deobfuscator: MAX_RESTARTS reached.')

      selected = next((x for x in self._pool if x.IsReady()), self._pool[0])
      # Rotate the order so that next caller will not choose the same one.
      self._pool.remove(selected)
      self._pool.append(selected)

    return selected.TransformLines(lines)

  def Close(self):
    with self._lock:
      for d in self._pool:
        d.Close()
      self._pool = None
