# Copyright 2020, castLabs GmbH

from .core.error import *
from .core.util import detect_bin, DEFAULT_HASH_BLOCK, hash_macho_wv0, hash_pe_wv0
from .core.failure import gather
from .auth import Auth

from sys import stdout
from io import BytesIO
from os import environ, path, rename, remove, makedirs
from time import monotonic
from math import ceil
import errno
from datetime import datetime, timedelta, timezone
from binascii import a2b_base64, b2a_base64
import json
from glob import glob
from zlib import compressobj, MAX_WBITS

from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.hashes import SHA1
from cryptography.x509 import load_der_x509_certificate, ObjectIdentifier
from cryptography.x509.oid import ExtensionOID, ExtendedKeyUsageOID
from cryptography.hazmat.primitives.asymmetric.padding import PSS, MGF1

import requests
import boto3
from boto3.s3.transfer import TransferConfig, create_transfer_manager
from botocore.config import Config

################################################################################

DEFAULT_API_URL = 'https://evs-api.castlabs.com'

################################################################################

ExtensionOID.VMP_DEVELOPMENT = ObjectIdentifier('1.3.6.1.4.1.11129.4.1.2')
ExtensionOID.VMP_PERSISTENT = ObjectIdentifier('1.3.6.1.4.1.11129.4.1.3')

STREAMING_SIGNATURE='streaming'
PERSISTENT_SIGNATURE='persistent'

SUBJECT_KEY_IDENTIFIERS = {
  STREAMING_SIGNATURE: [
    b'\x0f\x02\xae\xdf\xf7\xe5\xca\x19\nK\xc4$\xdb\xfat\xb5i\x97\x18\x8c',
    b'\xf8\xa3\xcbQZ\xe1\xc4\xe45\x07"n\x9eS\x13U\xe6\x85Y*'
  ],
  PERSISTENT_SIGNATURE: [
    b'\nmV\xb5\xae\x8e\xaf\x17\x16Y\x0e#\xd2lUS\xea\x7fW\xb5',
    b'\x0e\xb8ib\xf8\x9f\xaa\xc4h\xcbuP{Ih\x95B\x84\xbd]'
  ]
}
AUTHORITY_KEY_IDENTIFIERS = [ b'\xca=\xd8\x8e\x0ftW\x7f\xd0\x9a\xd9\xe1!\xbfB\xfb#U)\x86' ]

################################################################################

FLAGS_INTERMEDIATE = 0
FLAGS_FINAL = 1

################################################################################

class Signature:
  def __init__(self, fh):
    def decode_byte(fh):
      b = fh.read(1)
      return ord(b) if b else None
    def decode_leb128(f):
      shift = 0
      val = 0
      while True:
        b = decode_byte(f)
        if b is None:
          return None
        val |= (b & 0x7f) << shift
        if not (b & 0x80):
          break
        shift += 7
      return val
    def decode_bytes(fh):
      n = decode_leb128(fh)
      b = fh.read(n) if n is not None else None
      return b if b else None
    def decode_entry(f):
      return decode_byte(fh), decode_bytes(fh)
    self.version = decode_byte(fh)
    if self.version != 0:
      raise UnsupportedError('Unsupported signature file version')
    self.cert, self.data, self.flags = None, None, None
    while True:
      tag, entry = decode_entry(fh)
      if tag is None:
        break
      if 1 == tag:
        self.cert = entry
      elif 2 == tag:
        self.data = entry
      elif 3 == tag:
        self.flags = entry
    if None in (self.cert, self.data, self.flags):
      raise InvalidDataError('Invalid signature file')
    if ord(self.flags) not in (0, 1):
      raise InvalidDataError('Invalid signature file flags')

################################################################################

def hash_bin(bin):
  with open(bin, 'rb') as fh:
    fmt, p, a = detect_bin(fh)
    if fmt == 'macho':
      return hash_macho_wv0(fh), p, a
    elif fmt == 'pe':
      return hash_pe_wv0(fh), p, a
    raise UnsupportedError('Binary type or architecture not supported')

def load_sig(bin, sig, p):
  if 'win32' == p:
    dir, name = path.split(bin)
    if name != 'electron.exe':
      o = path.join(dir, 'electron.exe.sig')
      if path.isfile(o):
        if not path.isfile(sig):
          rename(o, sig)
        elif not path.samefile(o, sig):
          remove(o)

  try:
    with open(sig, 'rb') as fh:
      return fh.read()
  except Exception as e:
    raise InvalidDataError('Failed to read signature') from e

def verify_dgt(dgt, sdata, kind, intermediate, min_days, any_ski=False, block_size=DEFAULT_HASH_BLOCK):
  try:
    s = Signature(BytesIO(sdata))
  except Exception as e:
    raise InvalidDataError('Failed to decode signature') from e

  expected_flags = FLAGS_INTERMEDIATE if intermediate else FLAGS_FINAL
  if ord(s.flags) != expected_flags:
    raise ValidityError('Signature flags do not match expected value')

  try:
    cert = load_der_x509_certificate(s.cert, backend=default_backend())
  except Exception as e:
    raise InvalidDataError('Failed to load certificate') from e
  try:
    cert.public_key().verify(s.data, dgt + s.flags, PSS(mgf=MGF1(SHA1()), salt_length=20), SHA1())
  except Exception as e:
    raise InvalidDataError('Failed to verify signature') from e

  # Prefer the non-naive UTC attributes if they are available (in cryptography >= 42.0.0)
  if hasattr(cert, 'not_valid_before_utc') and hasattr(cert, 'not_valid_after_utc'):
    now = datetime.now(timezone.utc)
    not_valid_before = cert.not_valid_before_utc
    not_valid_after = cert.not_valid_after_utc
  else:
    now = datetime.now()
    not_valid_before = cert.not_valid_before
    not_valid_after = cert.not_valid_after

  if now < not_valid_before:
    raise ValidityError('Certificate not yet valid')
  if min_days is not None and now > (not_valid_after - timedelta(days=min_days)):
    raise ValidityError(f'Certificate expires in less than {min_days} days')
  days_left = (not_valid_after - now).days

  ski = aki = ku = eku = vp = vd = None
  for e in cert.extensions:
    if ExtensionOID.SUBJECT_KEY_IDENTIFIER == e.oid:
      ski = e.value
    elif ExtensionOID.AUTHORITY_KEY_IDENTIFIER == e.oid:
      aki = e.value
    elif ExtensionOID.KEY_USAGE == e.oid:
      ku = e.value
    elif ExtensionOID.EXTENDED_KEY_USAGE == e.oid:
      eku = e.value
    elif ExtensionOID.VMP_PERSISTENT == e.oid:
      vp = e.value
    elif ExtensionOID.VMP_DEVELOPMENT == e.oid:
      vd = e.value
  if vd:
    raise ValidityError('Certificate is valid for development only')
  if (vp and STREAMING_SIGNATURE == kind) or (not vp and PERSISTENT_SIGNATURE == kind):
    raise ValidityError(f'Certificate doesn\'t match \'{kind}\' signature kind')
  if not eku or ExtendedKeyUsageOID.CODE_SIGNING not in eku:
    raise ValidityError('Certificate doesn\'t allow code signing')
  if not ku or not ku.digital_signature:
    raise ValidityError('Certificate doesn\'t allow digital signatures')
  if not aki or aki.key_identifier not in AUTHORITY_KEY_IDENTIFIERS:
    raise ValidityError('Certificate has invalid AuthorityKeyIdentifier')
  if not any_ski:
    if not ski or ski.digest not in SUBJECT_KEY_IDENTIFIERS.get(kind, []):
      raise ValidityError('Certificate has invalid SubjectKeyIdentifier')
  return (kind, intermediate, days_left)

################################################################################

DEFAULT_CACHE_FILE = '~/.config/evs/cache.json'
DEFAULT_CACHE_MAX = 16

def _cache_key(dgt, p, a, kind, intermediate):
  return f'{p}-{a}:{kind}{"-intermediate" if intermediate else ""}:{b2a_base64(dgt, newline=False).decode("utf-8")}'

################################################################################

MB = 1024 * 1024

################################################################################

class TransferTracker:
  """Tracks bytes processed, elapsed time, retry count, speed, percentage, and outcome.

  success is None while in progress, True on completion, False on failure.
  on_update(tracker) is called after each update().
  on_start(tracker) is called by start() (defaults to on_update).
  on_done(tracker) is called by done() (defaults to on_update).

  input_size counts all bytes sent over the wire (monotonically increasing).
  output_size counts resulting bytes, this can decrease e.g. in case of retries.
  input_pct reflects the input position (capped at 100%).
  output_pct reflects the output position (capped at 100%).
  speed reflects total wire throughput.
  retries reflects the number of times a negative progress update was detected.
  """
  def __init__(self, size=0, on_update=None, on_start=None, on_done=None):
    self._size = size
    self._start = None
    self._input_size = 0
    self._retries = 0
    self._output_size = 0
    self.success = None
    self._on_update = on_update
    self._on_start = on_start if on_start is not None else on_update
    self._on_done = on_done if on_done is not None else on_update

  def __enter__(self):
    self.start()
    return self

  def __exit__(self, exc_type, exc_value, tb):
    self.done(exc_type is None)

  def start(self):
    assert self._start is None
    assert self.success is None
    self._start = monotonic()
    if self._on_start:
      self._on_start(self)

  def update(self, input_size, output_size):
    assert self.start is not None
    assert self.success is None
    if input_size is not None:
      self._input_size = input_size
    if output_size is not None:
      if output_size < self._output_size:
        self._retries += 1
      self._output_size = output_size
    if self._on_update:
      self._on_update(self)

  def update_input_n(self, n):
    assert n >= 0
    self.update(self._input_size + n, None)
    return n

  def update_output_n(self, n):
    self.update(None, self._output_size + n)
    return n

  def update_n(self, n):
    if n < 0:
      return self.update_output_n(n)
    self.update(self._input_size + n, self._output_size + n)
    return n

  def done(self, success=True):
    assert self.success is None
    self.success = success
    if self._on_done:
      self._on_done(self)

  @property
  def size(self):
    return self._size

  @property
  def elapsed(self):
    return (monotonic() - self._start) if self._start else 0

  @property
  def input_size(self):
    return self._input_size

  @property
  def output_size(self):
    return self._output_size

  @property
  def input_pct(self):
    return min(100.0, self.input_size / self._size * 100) if self._size else 0.0

  @property
  def output_pct(self):
    return min(100.0, self.output_size / self._size * 100) if self._size else 0.0

  @property
  def speed(self):
    e = self.elapsed
    return self._input_size / e / MB if e > 0 else 0.0

  @property
  def retries(self):
    return self._retries

################################################################################

class DataProgressTracker(TransferTracker):
  """Wraps file-like object for reading, and optionally another for writing, for progress tracking.

  compatible with the with statement for automatic management of start/done states.
  tracks bytes read/written and signals updates via callback.
  finalize() can be called to retreive the optional output buffer and size.
  """
  def __init__(self, in_size, in_data, out_data=None, on_update=None, on_start=None, on_done=None):
    super().__init__(in_size, on_update=on_update, on_start=on_start, on_done=on_done)
    self._in_data = in_data
    self._out_data = out_data

  def __len__(self):
    return self.size

  def read(self, size=-1):
    chunk = self._in_data.read(size)
    if chunk:
      n = len(chunk)
      self.update_n(n) if self._out_data is None else self.update_input_n(n)
    return chunk

  def write(self, data):
    return self.update_output_n(self._out_data.write(data))

  def finalize(self):
    out_size = self.output_size
    if self._out_data is None:
      return (None, out_size)
    out_data = self._out_data
    assert out_size == out_data.tell()
    self._out_data = None
    out_data.seek(0)
    return (out_data, out_size)

################################################################################

class UploadSubscriberTracker(TransferTracker):
  """s3transfer subscriber for accurate per-byte progress and retry tracking.

  on_progress() receives negative bytes_transferred when s3transfer seeks back to retry a part.
  update_n() accumulates those as output_size, dropping pct back to the confirmed position
  while input_size keeps growing for speed.
  """
  def __init__(self, size, on_update=None, on_start=None, on_done=None):
    super().__init__(size, on_update=on_update, on_start=on_start, on_done=on_done)

  def on_queued(self, future, **kwargs):
    pass

  def on_progress(self, future, bytes_transferred, **kwargs):
    self.update_n(bytes_transferred)

  def on_done(self, future, **kwargs):
    pass

################################################################################

class LogFormatter:
  """TTY-aware string formatter for log output."""

  def __init__(self, log_fn, tty=None):
    self._tty = tty if tty is not None else stdout.isatty()
    self._log_fn = log_fn
    if self._tty:
      self._br_open  = '\033[36m[ \033[0m'
      self._br_close = '\033[36m ]\033[0m'
      self._sep      = '\033[36m | \033[0m'
    else:
      self._br_open  = '[ '
      self._br_close = ' ]'
      self._sep      = ' | '

  def _bold(self, s):
    return f'\033[1m{s}\033[0m' if self._tty else s

  def log_bold(self, prefix, s):
    self._log_fn(f'{prefix} {self._bold(s)}')

  def _log_progress(self, t, prefix, output_pct=True):
    msg = f'\r{prefix} {self._br_open}{t.input_pct if not output_pct else t.output_pct:.1f}%'
    if t.retries:
      msg += f'{self._sep}{t.retries} retr{"y" if t.retries == 1 else "ies"}'
    msg += f'{self._sep}{t.input_size / MB:.1f} of {t.size / MB:.1f} MB'
    if t.output_size != t.input_size:
      msg += f' \u2192 {t.output_size / MB:.1f} MB'
    msg += f'{self._sep}{t.speed:.1f} MB/s{self._br_close}\033[K'
    self._log_fn(msg, end='')

  def log_transfer_start(self, t, prefix, output_pct=True):
    if self._tty:
      self._log_progress(t, prefix, output_pct)
    else:
      self._log_fn(f'{prefix} {t.size / MB:.1f} MB')

  def log_transfer_update(self, t, prefix, output_pct=True):
    if self._tty:
      self._log_progress(t, prefix, output_pct)

  def log_transfer_done(self, t, prefix_fmt, parts=None):
    if self._tty:
      self._log_fn('')
    msg = prefix_fmt.format('finished' if t.success else 'failed')
    if parts is not None:
      msg += f' {self._bold(f"{parts}")}'
      msg += f' part{"s" if parts != 1 else ""},'
    if t.retries:
      msg += f' {self._bold(f"{t.retries}")}'
      msg += f' retr{"y" if t.retries == 1 else "ies"},'
    if t.input_size != t.size:
      msg += f' {self._bold(f"{t.input_size / MB:.1f}")} of'
    msg += f' {self._bold(f"{t.size / MB:.1f}")} MB'
    if t.output_size not in (t.input_size, t.size):
      msg += f' \u2192 {self._bold(f"{t.output_size / MB:.1f}")} MB'
    msg += f', {self._bold(f"{t.elapsed:.1f}s")}, avg {self._bold(f"{t.speed:.1f}")} MB/s'
    self._log_fn(msg)

  def log_signature(self, prefix, kind, intermediate, days_left):
    msg = f'{prefix} {self._bold(kind)}'
    if intermediate:
      msg += f', {self._bold("intermediate")}'
    if days_left is not None:
      msg += f', {self._bold(f"{days_left}")} days left'
    self._log_fn(msg)

################################################################################

class VMP(Auth):
  DEFAULT_UPLOAD_URL_TIMEOUT = 60
  DEFAULT_UPLOAD_TIMEOUT = 300
  DEFAULT_SIGN_TIMEOUT = 120

  MULTIPART_MIN_SIZE = 20            # MB — minimum for threshold and part size
  DEFAULT_MULTIPART_THRESHOLD = 100  # MB
  DEFAULT_MULTIPART_PART_SIZE = 50   # MB
  DEFAULT_MULTIPART_RETRIES = 3
  DEFAULT_MULTIPART_MAX_CONCURRENCY = 2

  def __init__(self, no_ask=True, any_ski=False, api_url=None, cache_file=None, cache_max=None,
               connect_timeout=None, auth_timeout=None, upload_url_timeout=None, upload_timeout=None,
               sign_timeout=None, multipart_threshold=None, multipart_part_size=None,
               multipart_retries=None, multipart_max_concurrency=None, **_):
    self._any_ski = any_ski or 'EVS_ANY_SKI' in environ
    self._cache_file = path.expanduser(cache_file or environ.get('EVS_CACHE_FILE', DEFAULT_CACHE_FILE))
    self._cache_dir = path.dirname(self._cache_file)
    self._cache_max = int(cache_max or environ.get('EVS_CACHE_MAX', DEFAULT_CACHE_MAX))
    self._upload_url_timeout = float(upload_url_timeout or environ.get('EVS_UPLOAD_URL_TIMEOUT', self.DEFAULT_UPLOAD_URL_TIMEOUT))
    self._upload_timeout = float(upload_timeout or environ.get('EVS_UPLOAD_TIMEOUT', self.DEFAULT_UPLOAD_TIMEOUT))
    self._sign_timeout = float(sign_timeout or environ.get('EVS_SIGN_TIMEOUT', self.DEFAULT_SIGN_TIMEOUT))
    multipart_threshold = int(multipart_threshold or environ.get('EVS_MULTIPART_THRESHOLD', self.DEFAULT_MULTIPART_THRESHOLD))
    multipart_part_size = int(multipart_part_size or environ.get('EVS_MULTIPART_PART_SIZE', self.DEFAULT_MULTIPART_PART_SIZE))
    self._multipart_threshold = max(self.MULTIPART_MIN_SIZE, multipart_threshold) * MB
    self._multipart_part_size = max(self.MULTIPART_MIN_SIZE, multipart_part_size) * MB
    self._multipart_retries = int(multipart_retries or environ.get('EVS_MULTIPART_RETRIES', self.DEFAULT_MULTIPART_RETRIES))
    self._multipart_max_concurrency = int(multipart_max_concurrency or environ.get('EVS_MULTIPART_MAX_CONCURRENCY', self.DEFAULT_MULTIPART_MAX_CONCURRENCY))
    api_url = api_url or environ.get('EVS_API_URL', DEFAULT_API_URL)
    self.broker_url = f'{api_url}/broker'
    self.signer_url = f'{api_url}/signer'
    super().__init__(no_ask=no_ask, connect_timeout=connect_timeout, auth_timeout=auth_timeout)
    self._fmt = LogFormatter(self._log)
    self._agent = f'{__name__}-{self._version}'
    self._load_cache()

  def _load_cache(self):
    try:
      with open(self._cache_file) as fh:
        self.__cache = json.load(fh)
    except Exception:
      self.__cache = {}

  def _compress(self, data, size, chunk_size=1 << 20):
    p0, p1 = ' - Compressing binary:', ' - Compression {}:'
    with DataProgressTracker(size, data, BytesIO(),
        on_update=lambda t: self._fmt.log_transfer_update(t, p0, False),
        on_start=lambda t: self._fmt.log_transfer_start(t, p0, False),
        on_done=lambda t: self._fmt.log_transfer_done(t, p1)) as tracked_io_data:
      cobj = compressobj(wbits=MAX_WBITS | 16)
      while True:
        chunk = tracked_io_data.read(chunk_size)
        if not chunk:
          break
        tracked_io_data.write(cobj.compress(chunk))
      tracked_io_data.write(cobj.flush())
      return tracked_io_data.finalize()

  def _store_cache(self):
    if not self._cache_file:
      return False
    try:
      try:
        makedirs(self._cache_dir)
      except OSError as e:
        if errno.EEXIST != e.errno or not path.isdir(self._cache_dir):
          return False
      with open(self._cache_file, 'w') as fh:
        json.dump(self.__cache, fh, indent=2)
      return True
    except Exception:
      return False

  def _pop_cache_item(self, key):
    sdata = self.__cache.pop(key, None)
    if not sdata:
      return None
    return a2b_base64(sdata)

  def _add_cache_item(self, key, sdata):
    # Delete before re-inserting to refresh insertion order for LRU eviction
    if key in self.__cache:
      del self.__cache[key]
    self.__cache[key] = b2a_base64(sdata, newline=False).decode('utf-8')
    while len(self.__cache) > self._cache_max:
      del self.__cache[next(iter(self.__cache))]

  def _request_json(self, url, params, timeout, desc, account_name=None, passwd=None):
    try:
      while True:
        r = requests.get(url,
          params=params,
          headers={
            'Authorization': self._auth_id_token,
            'Accept': 'application/json',
          },
          timeout=(self._connect_timeout, timeout),
        )
        if r.status_code not in (401, 403) or not self._refresh_auth(account_name, passwd):
          break
    except Exception as e:
      raise RequestError(f'{desc} failed') from e
    try:
      j = r.json()
    except Exception as e:
      raise InvalidDataError(f'{desc} failed: Could not parse JSON response') from e
    if r.status_code != 200 or 'errorMessage' in j or 'message' in j:
      e = gather(j)
      raise HTTPError(f'{desc} failed: {r.status_code} {r.reason}') from e
    return j

  def _request_upload(self, gz=False, account_name=None, passwd=None):
    ps = {
      'agent': self._agent,
      **({'gz': ''} if gz else {})
    }
    j = self._request_json(self.broker_url, ps, self._upload_url_timeout,
      'Request for upload URL', account_name, passwd)
    if 'key' not in j:
      raise InvalidDataError('No \'key\' returned in upload URL response')
    if 'url' not in j:
      raise InvalidDataError('No \'url\' returned in upload URL response')
    return (j['key'], j['url'])

  def _upload_single(self, data, size, gz=False, account_name=None, passwd=None):
    self._fmt.log_bold(' - Acquiring upload path for mode:', 'single')
    key, url = self._request_upload(gz, account_name, passwd)
    if key.endswith('.gz') != gz:
      raise InvalidDataError('Unexpected gz status mismatch in upload key')
    self._fmt.log_bold(' - Acquired upload path:', key)
    p0, p1 = ' - Uploading binary:', ' - Upload {}:'
    with DataProgressTracker(size, data,
        on_update=lambda t: self._fmt.log_transfer_update(t, p0),
        on_start=lambda t: self._fmt.log_transfer_start(t, p0),
        on_done=lambda t: self._fmt.log_transfer_done(t, p1)) as tracked_data:
      try:
        r = requests.put(url,
          data=tracked_data,
          headers={
            'Content-Type': 'application/octet-stream' if not gz else 'application/gzip',
          },
          timeout=(self._connect_timeout, self._upload_timeout),
        )
      except Exception as e:
        raise RequestError('Upload request failed') from e
      if r.status_code != 200:
        raise HTTPError(f'Upload request failed: {r.status_code} {r.reason}')
      return key

  def _request_multipart_upload(self, gz=False, account_name=None, passwd=None):
    ps = {'agent': self._agent, 'multipart': '', **({'gz': ''} if gz else {})}
    j = self._request_json(self.broker_url, ps, self._upload_url_timeout,
      'Request for multipart upload credentials', account_name, passwd)
    for field in ('key', 'bucket', 'region', 'credentials'):
      if field not in j:
        raise InvalidDataError(f'No \'{field}\' returned in multipart upload response')
    return j['key'], j['bucket'], j['region'], j['credentials']

  def _upload_multipart(self, data, size, gz=False, account_name=None, passwd=None):
    self._fmt.log_bold(' - Acquiring upload path for mode:', 'multipart')
    key, bucket, region, creds = self._request_multipart_upload(gz, account_name, passwd)
    if key.endswith('.gz') != gz:
      raise InvalidDataError('Unexpected gz status mismatch in multipart upload key')
    self._fmt.log_bold(' - Acquired upload path:', key)
    parts = ceil(size / self._multipart_part_size)
    p0, p1 = ' - Uploading binary:', ' - Upload {}:'
    with UploadSubscriberTracker(size,
        on_update=lambda t: self._fmt.log_transfer_update(t, p0),
        on_start=lambda t: self._fmt.log_transfer_start(t, p0),
        on_done=lambda t: self._fmt.log_transfer_done(t, p1, parts)) as upload_tracker:
      s3 = boto3.client('s3',
        region_name=region,
        aws_access_key_id=creds['access_key_id'],
        aws_secret_access_key=creds['secret_access_key'],
        aws_session_token=creds['session_token'],
        config=Config(retries={'max_attempts': self._multipart_retries, 'mode': 'adaptive'}),
      )
      tc = TransferConfig(
        multipart_threshold=1,
        multipart_chunksize=self._multipart_part_size,
        max_concurrency=self._multipart_max_concurrency,
        # CRT client provides worse progress and error handling for our case, use classic client
        preferred_transfer_client='classic',
      )
      with create_transfer_manager(s3, tc) as manager:
        manager.upload(data, bucket, key,
          extra_args={'ContentType': 'application/gzip' if gz else 'application/octet-stream'},
          subscribers=[upload_tracker]
        ).result()
      return key

  def _upload_data(self, data, gz=False, account_name=None, passwd=None):
    data.seek(0, 2)
    size = data.tell()
    data.seek(0)
    if gz:
      data, size = self._compress(data, size)
    if size >= self._multipart_threshold:
      return self._upload_multipart(data, size, gz, account_name, passwd)
    return self._upload_single(data, size, gz, account_name, passwd)

  def _upload_file(self, bin, gz=False, account_name=None, passwd=None):
    with open(bin, 'rb') as fh:
      return self._upload_data(fh, gz, account_name, passwd)

  def _sign(self, key, kind, intermediate=False, account_name=None, passwd=None):
    ps = {
      'agent': self._agent,
      'key': key,
      'kind': kind,
      **({'intermediate': ''} if intermediate else {})
    }
    j = self._request_json(self.signer_url, ps, self._sign_timeout,
      'Signing request', account_name, passwd)
    if 'key' not in j:
      raise InvalidDataError('No \'key\' returned in signing response')
    if key != j['key']:
      raise InvalidDataError('Mismatching \'key\' returned in signing response')
    if 'sig' not in j:
      raise InvalidDataError('No \'sig\' returned in signing response')
    return a2b_base64(j['sig'])

  def _store(self, sig, sdata):
    with open(sig, 'wb') as fh:
      fh.write(sdata)

  def verify(self, bin, sig=None, kind=None, intermediate=False, min_days=None, app=None, **_):
    sig = sig or (bin + '.sig')
    kind = kind or environ.get('EVS_KIND', STREAMING_SIGNATURE)
    intermediate = intermediate or 'EVS_INTERMEDIATE' in environ
    self._fmt.log_bold('Verifying:', app or bin)
    dgt, p, _ = hash_bin(bin)
    msg = verify_dgt(dgt, load_sig(bin, sig, p), kind, intermediate, min_days, self._any_ski)
    self._fmt.log_signature(' - Signature is valid:', *msg)

  def sign(self, bin, sig=None, kind=None, intermediate=False, min_days=None, force=False, gz=False, account_name=None, passwd=None, app=None, **_):
    sig = sig or (bin + '.sig')
    key = environ.get('EVS_KEY', None)
    kind = kind or environ.get('EVS_KIND', STREAMING_SIGNATURE)
    intermediate = intermediate or 'EVS_INTERMEDIATE' in environ
    force = force or 'EVS_FORCE' in environ
    self._fmt.log_bold('Signing:', app or bin)
    dgt, p, a = hash_bin(bin)
    ckey = _cache_key(dgt, p, a, kind, intermediate)
    sdata = None
    if not force:
      self._log(' - Checking existing signature')
      try:
        sdata = load_sig(bin, sig, p)
        msg = verify_dgt(dgt, sdata, kind, intermediate, min_days, self._any_ski)
        self._fmt.log_signature(' - Retain existing signature:', *msg)
        return
      except Exception as e:
        self._log(f' - Existing signature invalid: {e}')
      sdata = self._pop_cache_item(ckey)
      if sdata:
        self._log(' - Checking cached signature')
        try:
          msg = verify_dgt(dgt, sdata, kind, intermediate, min_days, self._any_ski)
          self._fmt.log_signature(' - Using cached signature:', *msg)
        except Exception as e:
          self._log(f' - Cached signature invalid: {e}')
          sdata = None
      else:
        self._log(' - No cached signature found')
    if not sdata:
      self._check_auth(account_name, passwd)
      key = key or self._upload_file(bin, gz, account_name, passwd)
      self._fmt.log_signature(' - Requesting signature:', kind, intermediate, None)
      sdata = self._sign(key, kind, intermediate, account_name, passwd)
      try:
        msg = verify_dgt(dgt, sdata, kind, intermediate, min_days, self._any_ski)
        self._fmt.log_signature(' - Signature request successful:', *msg)
      except Exception as e:
        raise Exception(f'Signature verification failed after signing: {e}') from e
    self._add_cache_item(ckey, sdata)
    self._store_cache()
    self._store(sig, sdata)

  def _detect_pkgs(self, dirs, name_hint=None):
    def _to_pattern(n):
      return ''.join((f'[{c.upper()}{c.lower()}]' if c.isalpha() else c) for c in n)
    def _try_app(app):
      if path.isdir(app):
        fwdir = 'Contents/Frameworks/Electron Framework.framework/Versions/A'
        fwbin = path.join(app, fwdir, 'Electron Framework')
        if path.isfile(fwbin):
          fwsig = path.join(app, fwdir, 'Resources/Electron Framework.sig')
          return (app, fwbin, fwsig)
      return None
    def _try_exe(exe):
      if path.isfile(exe):
        sig = f'{exe}.sig'
        return (exe, exe, sig)
      return None
    def _try_dir(dir, patterns):
      for p in patterns:
        p = path.join(dir, p)
        for m in glob(f'{p}.app'):
          item = _try_app(m)
          if item: return item
        for m in glob(f'{p}.exe'):
          item = _try_exe(m)
          if item: return item
      raise FileNotFoundError(f'No matching executable found in: {dir}')

    patterns = []
    if not name_hint:
      patterns.append(_to_pattern('electron'))
      patterns.append('*')
    else:
      patterns.append(_to_pattern(name_hint))
    return [ _try_dir(dir, patterns) for dir in dirs ]

  def verify_pkg(self, pkg, name_hint=None, kind=None, min_days=None, **_):
    items = self._detect_pkgs(pkg if isinstance(pkg, list) else [ pkg ], name_hint)
    for item in items:
      self.verify(item[1], item[2], kind, False, min_days, app=item[0])

  def sign_pkg(self, pkg, name_hint=None, kind=None, min_days=None, force=False, gz=False, account_name=None, passwd=None, **_):
    items = self._detect_pkgs(pkg if isinstance(pkg, list) else [ pkg ], name_hint)
    for item in items:
      self.sign(item[1], item[2], kind, False, min_days, force, gz, account_name, passwd, app=item[0])

################################################################################

if __name__ == '__main__':
  from .cli.vmp import main
  main()

################################################################################
