# Copyright 2015 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 re

from devil.utils import cmd_helper

logger = logging.getLogger(__name__)

_COULDNT_OPEN_ERROR_RE = re.compile(r'Couldn\'t open device.*')
_INDENTATION_RE = re.compile(r'^( *)')
_LSUSB_BUS_DEVICE_RE = re.compile(r'^Bus (\d{3}) Device (\d{3}): (.*)')
_LSUSB_ENTRY_RE = re.compile(r'^ *([^ ]+) +([^ ]+) *([^ ].*)?$')
_LSUSB_GROUP_RE = re.compile(r'^ *([^ ]+.*):$')


def _lsusbv_on_device(bus_id, dev_id):
  """Calls lsusb -v on device."""
  _, raw_output = cmd_helper.GetCmdStatusAndOutputWithTimeout(
      ['lsusb', '-v', '-s', '%s:%s' % (bus_id, dev_id)], timeout=10)

  device = {'bus': bus_id, 'device': dev_id}
  depth_stack = [device]

  # This builds a nested dict -- a tree, basically -- that corresponds
  # to the lsusb output. It looks first for a line containing
  #
  #   "Bus <bus number> Device <device number>: ..."
  #
  # and uses that to create the root node. It then parses all remaining
  # lines as a tree, with the indentation level determining the
  # depth of the new node.
  #
  # This expects two kinds of lines:
  #   - "groups", which take the form
  #       "<Group name>:"
  #     and typically have children, and
  #   - "entries", which take the form
  #       "<entry name>   <entry value>  <possible entry description>"
  #     and typically do not have children (but can).
  #
  # This maintains a stack containing all current ancestor nodes in
  # order to add new nodes to the proper place in the tree.
  # The stack is added to when a new node is parsed. Nodes are removed
  # from the stack when they are either at the same indentation level as
  # or a deeper indentation level than the current line.
  #
  # e.g. the following lsusb output:
  #
  # Bus 123 Device 456: School bus
  # Device Descriptor:
  #   bDeviceClass 5 Actual School Bus
  #   Configuration Descriptor:
  #     bLength 20 Rows
  #
  # would produce the following dict:
  #
  # {
  #   'bus': 123,
  #   'device': 456,
  #   'desc': 'School bus',
  #   'Device Descriptor': {
  #     'bDeviceClass': {
  #       '_value': '5',
  #       '_desc': 'Actual School Bus',
  #     },
  #     'Configuration Descriptor': {
  #       'bLength': {
  #         '_value': '20',
  #         '_desc': 'Rows',
  #       },
  #     },
  #   }
  # }
  for line in raw_output.splitlines():
    # Ignore blank lines.
    if not line:
      continue
    # Filter out error mesage about opening device.
    if _COULDNT_OPEN_ERROR_RE.match(line):
      continue
    # Find start of device information.
    m = _LSUSB_BUS_DEVICE_RE.match(line)
    if m:
      if m.group(1) != bus_id:
        logger.warning('Expected bus_id value: %r, seen %r', bus_id, m.group(1))
      if m.group(2) != dev_id:
        logger.warning('Expected dev_id value: %r, seen %r', dev_id, m.group(2))
      device['desc'] = m.group(3)
      continue

    # Skip any lines that aren't indented, as they're not part of the
    # device descriptor.
    indent_match = _INDENTATION_RE.match(line)
    if not indent_match:
      continue

    # Determine the indentation depth.
    depth = 1 + len(indent_match.group(1)) / 2
    if depth > len(depth_stack):
      logger.error('lsusb parsing error: unexpected indentation: "%s"', line)
      continue

    # Pop everything off the depth stack that isn't a parent of
    # this element.
    while depth < len(depth_stack):
      depth_stack.pop()

    cur = depth_stack[-1]

    m = _LSUSB_GROUP_RE.match(line)
    if m:
      new_group = {}
      cur[m.group(1)] = new_group
      depth_stack.append(new_group)
      continue

    m = _LSUSB_ENTRY_RE.match(line)
    if m:
      new_entry = {
          '_value': m.group(2),
          '_desc': m.group(3),
      }
      cur[m.group(1)] = new_entry
      depth_stack.append(new_entry)
      continue

    logger.error('lsusb parsing error: unrecognized line: "%s"', line)

  return device


def lsusb():
  """Call lsusb and return the parsed output."""
  _, lsusb_list_output = cmd_helper.GetCmdStatusAndOutputWithTimeout(['lsusb'],
                                                                     timeout=10)
  devices = []
  for line in lsusb_list_output.splitlines():
    m = _LSUSB_BUS_DEVICE_RE.match(line)
    if m:
      bus_num = m.group(1)
      dev_num = m.group(2)
      try:
        devices.append(_lsusbv_on_device(bus_num, dev_num))
      except cmd_helper.TimeoutError:
        # Will be denylisted if it is in expected device file, but times out.
        logger.info('lsusb -v %s:%s timed out.', bus_num, dev_num)
  return devices


def raw_lsusb():
  return cmd_helper.GetCmdOutput(['lsusb'])


def get_lsusb_serial(device):
  try:
    return device['Device Descriptor']['iSerial']['_desc']
  except KeyError:
    return None


def _is_android_device(device):
  try:
    # Hubs are not android devices.
    if device['Device Descriptor']['bDeviceClass']['_value'] == '9':
      return False
  except KeyError:
    pass
  return get_lsusb_serial(device) is not None


def get_android_devices():
  android_devices = (d for d in lsusb() if _is_android_device(d))
  return [get_lsusb_serial(d) for d in android_devices]
