import contextlib
import docker
import json
import os
import re
import shutil
import signal
import socket
import subprocess
import sys

from datetime import datetime, timezone
from tempfile import TemporaryDirectory

from app.utils import op_dev_service, OPLogFormatter, parse_log_msg


# There are two ways to run an op-dev service:
# 1. Using flask directly (specify the '--soft' flag)
#    - Much simpler
#    - Easier to test local library packages in this context (although also dangerous by the same token)
# 2. Wrapping in a docker container (default)
#    - More complex (dependency on docker)
#    - Can run exactly as it is run when deployed to a URL (no potential issues with requirements resolution, wsgi setup)
#    - Takes slightly longer

# Here are some useful commands if you run into issues with lingering containers:
# docker stop $(docker ps -a -q)
# docker rm $(docker ps -a -q)

# Here's a one-liner to kill any process running on port 5000:
# lsof -t -i tcp:5000 | xargs kill


RUN_SOFT_SCRIPT = '''
import asyncio
import json
import logging
import uvicorn

from op_service.logging_config import configure_logging
from op_service.op_version_metadata import op_version_metadata

async def main():
  configure_logging()
  from app import service
  logging.info('Initialized process: ' + json.dumps(op_version_metadata))
  return service.starlette

app = asyncio.run(main())


# TODO: I don't know what gives, maybe I have a local dependency installed that's out of date or some shit, but any
# communication via SSL inside the asyncio loop seems to fail without the following:
import ssl
ssl._create_default_https_context = ssl._create_unverified_context


# TODO: try this
# It's possible that the service started some async tasks when it was imported
# loop = asyncio.get_event_loop()
# loop.run_until_complete(asyncio.gather(*asyncio.all_tasks()))

# uvicorn app:starlette --host 0.0.0.0 --port 5000 --workers 1 --no-access-log

uvicorn.run(app, host='0.0.0.0', port=%(port)s, access_log=False)
'''


# This should NOT be kept separately - it is duplicated in the op-dev-manager-service package's build code for services
DEFAULT_DOCKERFILE = '''
FROM opdevio/op-dev-service

# Install the requirements first to take advantage of caching
COPY requirements.txt /var/www/
RUN pip install -r /var/www/requirements.txt

# Only copy over the 'app' directory
COPY app /var/www/app

COPY config.json /var/www/.op-dev/config.json
COPY metadata.json /var/www/.op-dev/metadata.json
'''


def _docker_build(build_context_dir, no_cache=False):
  build_output = ''
  try:
    docker_client = docker.from_env()
    stream = docker_client.api.build(path=build_context_dir, rm=True, nocache=no_cache)
    image_id = None
    for chunk in stream:
      for line in chunk.splitlines():
        status = json.loads(line)
        if 'error' in status:
          raise Exception('Error building docker image: %s' % status['error'])
        if 'stream' in status:
          msg = status['stream']
          build_output += msg

          # Note: this seems incredibly fragile, but it appears to be the best way to get the image_id...
          # https://github.com/docker/docker-py/blob/39bb78ac694d8d6e53882d3dbc9ebc3c92f5519d/docker/models/images.py#L176
          match = re.search(r'(^Successfully built |sha256:)([0-9a-f]+)$', msg)
          if match:
            image_id = match.group(2)
  except:
    # Only print the build output if we failed - should be so fast most times it's not worth flashing over the screen.
    # It also won't show up in production, so let's try to mimic that environment as much as possible.
    print(' \033[31mFAILED\033[0m')
    build_output and print(build_output)
    raise

  if not image_id:
    raise Exception('Error building docker image: could not determine image ID from build output')

  return image_id


def _shutdown(container):
  print('\033[36mShutting down...\033[0m')
  container.kill()
  container.remove(force=True)
  sys.exit()


def _check_port(port):
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    if s.connect_ex(('localhost', port)) == 0:
      # Port is already in use
      docker_client = docker.from_env()
      for container in docker_client.containers.list():
        for port_list in container.ports.values():
          if str(port) in [ x.get('HostPort') for x in (port_list or []) ]:
            cmd = 'docker kill %s' % container.id
            msg = 'A different docker container is already running on port %i. Try a different port using --port PORT, or kill that container using this command: %s' % (port, cmd)
            sys.exit(msg)
      sys.exit('Port %i is already in use. Try a different port using --port PORT.' % port)


def _get_repository_url(pkg_dir):
  # Returns None if the URL cannot be determined (this is not a git package or it has some weird configuration)
  cmd = [ 'git', 'config', '--local', '--get', 'remote.origin.url' ]
  repository_url = subprocess.run(cmd, capture_output=True, cwd=pkg_dir).stdout.decode('utf-8').strip()
  return repository_url or None


# Returns the config ID, or none if not applicable
# Postconditions: the file at 'dest_config_path' contains the config data as specified
def _create_config(config_id, config_path, config_namespace, pkg_dir, dest_config_path):
  if config_path:
    shutil.copyfile(config_path, dest_config_path)
    return None

  config = None
  if config_id:
    response = op_dev_service.call('get-config', { 'config_id': config_id })
    config = response['value']
  else:
    repository_url = _get_repository_url(pkg_dir)
    if repository_url:
      params = { 'repository_url': repository_url, 'config_namespace': config_namespace }
      # TODO: this is overly general - we should catch a specific exception (namespace not found) and throw on others
      with contextlib.suppress(Exception):
        response = op_dev_service.call('get-latest-config', params)
        config = response['config']['value']
        config_id = response['config_id']
  
  if not config:
    print("\rNo config found for namespace '%s' (using empty)" % config_namespace, file=sys.stderr)
    config = '{}'

  with open(dest_config_path, 'w') as file:
    file.write(config)

  return config_id


def _create_metadata(path, port, pkg_id, commit_id, config_id):
  metadata = {
    'domain': 'http://localhost:%i' % port,
    'pkg_id': pkg_id,
    'commit_id': commit_id,
    'config_id': config_id
  }
  content = json.dumps(metadata)
  with open(path, 'w') as metadata_file:
    metadata_file.write(content)


def _is_web_client(pkg_dir):
  package_json = os.path.join(pkg_dir, 'package.json')
  return os.path.exists(package_json)


def run(parser, args):
  # Make sure our desired port is not currently in use
  port = int(args.port) if args.port else (8080 if _is_web_client(args.pkg_dir) else 5000)
  _check_port(port)

  with TemporaryDirectory() as tmp_dir:

    print('\033[36mGetting configuration...\033[0m', end='', flush=True)
    config_path = os.path.join(tmp_dir, 'config.json')
    config_id = _create_config(args.config_id, args.config_path, args.config_namespace, args.pkg_dir, config_path)

    if _is_web_client(args.pkg_dir):
      print('\033[2K\r', end='', flush=True)

      if args.main:
        # TODO: you should probably be able to run this way too
        sys.exit("'--main' option is only for service packages")

      env = os.environ.copy()
      subprocess.check_call([ 'npm', 'install' ], cwd=args.pkg_dir, env=env)

      if config_id:
        env['OP_DEV_CONFIG_ID'] = config_id
      else:
        env['OP_DEV_CONFIG_PATH'] = config_path

      # This call hangs until explicitly terminated (for hot-reloading)
      # Note that by default, this command would find the next open port (from 8080), but "explicit is better than
      # implicit"...
      subprocess.check_call([ 'npm', 'run', 'serve', '--', '--port', str(port) ], cwd=args.pkg_dir, env=env)

    # You previously only used the --soft argument to run a single instance of the code as a script
    # if args.soft:
    #   metadata_file_path = os.path.join(tmp_dir, 'metadata.json')
    #   _create_metadata(metadata_file_path, port, None, None, config_id)

    #   env = os.environ.copy()
    #   env['OP_DEV_CONFIG'] = config_path
    #   env['OP_DEV_METADATA'] = metadata_file_path
    #   env['OP_DEV_SERVICE_TEST'] = '1'
    #   env['PYTHONPATH'] = args.pkg_dir

    #   cmd = [ 'python3', '-u', '-c', RUN_SOFT_SCRIPT % { 'port': port } ]
    #   # Capture all output and pass it through our op-dev log parser
    #   process = subprocess.Popen(cmd, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, env=env)
    #   stream = process.stdout

    else:
      build_context = os.path.join(tmp_dir, 'pkg')
      shutil.copytree(args.pkg_dir, build_context)
      shutil.move(config_path, build_context)

      metadata_file_path = os.path.join(build_context, 'metadata.json')
      _create_metadata(metadata_file_path, port, None, None, config_id)

      docker_file_path = os.path.join(build_context, 'Dockerfile')
      if not os.path.exists(docker_file_path):
        with open(docker_file_path, 'w') as file:
          file.write(DEFAULT_DOCKERFILE)

      volumes = {}
      if args.main is not None:
        volumes[ os.getcwd() ] = { 'bind': '/main', 'mode': 'rw' }
        with open(docker_file_path, 'a') as file:
          # When running the __main__ file, I don't want developers to have to configure the op_service logging, it
          # should just work. This is definitely some magic, and I don't like this pattern, but it does work...
          file.write('RUN echo "import op_service.logging_config\\nop_service.logging_config.configure_logging()\\n$(cat /var/www/app/__main__.py)" > /var/www/app/__main__.py\n')
          file.write('WORKDIR /main\n')

      print('\033[2K\r\033[36mBuilding...\033[0m', end='', flush=True)
      image_id = _docker_build(build_context, no_cache=args.no_cache)
      print('\033[2K\r', end='', flush=True)

      # Now we can run the container
      docker_client = docker.from_env()
      container = docker_client.containers.run(
        image_id,
        command=None if args.main is None else [ 'python', '-B', '-m', 'app', *args.main ],

        # TODO: we should really use the package name here
        name=os.path.basename(os.path.abspath(args.pkg_dir)),

        detach=True,
        remove=True,
        ports={ 5000: port },
        environment={ 'OP_DEV_SERVICE_TEST': '1' },
        volumes=volumes
      )
      stream = container.logs(stream=True)

      # Register signal handlers so we can clean up the container correctly
      signal.signal(signal.SIGINT, lambda *args: _shutdown(container))
      signal.signal(signal.SIGTERM, lambda *args: _shutdown(container))
      signal.signal(signal.SIGPIPE, lambda *args: _shutdown(container))

    # Apply our log formatter to the stream output
    formatter = OPLogFormatter(time_format='%-I:%M:%S %p')
    line = ''
    for encoded_chunk in stream:
      # The stream can deliver partial lines, but we do rely on the behavior that a raw newline character cannot be
      # contained within a chunk.
      chunk = encoded_chunk.decode('utf-8')

      # There is an issue here where access logs can get interspersed in the middle of large chunks. We rely on the fact
      # that these access log chunks are small, and thus likely to be contained within one chunk. This is pretty fragile -
      # a better solution would be to split up the logging to avoid writing to just one stream. We may have issues if
      # multiple workers are generating lots of large log messages at once as well, which this workaround will not solve.
      # Not too big an issue, as the only consequence is poorly-formatted log messages.
      # if chunk.startswith('[OP-ACCESS]'):
      #   print(formatter.format(datetime.now(timezone.utc), parse_log_msg(chunk[:-1])))
      #   continue

      line += chunk
      if line.endswith('\n'):
        print(formatter.format(datetime.now(timezone.utc), parse_log_msg(line[:-1])))
        line = ''
