# Apple's new arm64 architecture creates all sorts of Docker issues. There is support for emulation of amd64
# processors, but it can be a bit of a pain.

# First, you need to have the following environment variable set:
# export DOCKER_DEFAULT_PLATFORM=linux/amd64

# You'll also need the latest version of Docker Desktop: open Settings… > Features in development, then check the box
# to "Use Rosetta for x86/amd64 emulation on Apple Silicon"

# At this point, all your docker builds should be for linux/amd64 (what AWS ECS uses) - slightly slower, but more
# reliable.

# https://stackoverflow.com/questions/65612411/forcing-docker-to-use-linux-amd64-platform-by-default-on-macos

# This command allows you to inspect what architecture an image was built for:
# docker inspect --format='{{.Os}}/{{.Architecture}}' op-dev-service


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

from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
from op_service_client import OPServiceClient
from tempfile import TemporaryDirectory
from threading import Thread

from app.utils import op_dev_service, OPLogFormatter, parse_log_msg


os.environ['DOCKER_DEFAULT_PLATFORM'] = 'linux/amd64'


# 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
'''


# https://docker-py.readthedocs.io/en/stable/api.html#module-docker.api.build
def _docker_build(build_context_dir, no_cache=False):
  build_output = ''
  try:
    docker_client = docker.DockerClient()
    stream = docker_client.api.build(path=build_context_dir, platform='linux/amd64', 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:
    print(build_output)
    raise Exception('Error building docker image: could not determine image ID from build output')

  return image_id


def _shutdown(container):
  print('\r\033[36mShutting down...\033[0m', end='', flush=True)
  with contextlib.suppress(Exception):
    container.kill()
    container.remove(force=True)
  print('\r', end='', flush=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.DockerClient()
      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_next_open_port(from_port, max_n=10):
  for port in range(from_port, from_port + max_n):
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
      if s.connect_ex(('localhost', port)) != 0:
        return port
  sys.exit(f'No open port in range found (checked ports {from_port} - {from_port + max_n}). Try manually specifying a port using --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', 'remote.origin.url' ]
  repository_url = subprocess.run(cmd, capture_output=True, text=True, cwd=pkg_dir).stdout.strip()
  return repository_url or None


def _get_pkg_id(pkg_dir):
  # Returns None if the package ID cannot be determined (this is not a git package or it has some weird configuration)
  cmd = [ 'git', 'config', '--local', 'op-dev.pkg-id' ]
  pkg_id = subprocess.run(cmd, capture_output=True, text=True, cwd=pkg_dir).stdout.strip()
  return pkg_id or None


# Returns a tuple of the nullable config ID and the deserizlied config dictionary
def _create_config(config_id, config_path, config_namespace, repository_url):
  if config_path:
    with open(config_path, 'r') as file:
      return None, file.read()

  config = None
  if config_id:
    response = op_dev_service.call('get-config', { 'config_id': config_id })
    config = response['value']
  elif 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 = '{}'

  return config_id, json.loads(config)


def _create_metadata(port, pkg_id, commit_id, config_id):
  return {
    'domain': 'http://localhost:%i' % port,
    'pkg_id': pkg_id,
    'commit_id': commit_id,
    'config_id': config_id
  }


# Here is some code you were experimenting with that could bring up an HTTP server locally
# class RequestHandler(BaseHTTPRequestHandler):
#
#   def do_GET(self):
#     print(self.client_address)
#     print(self.path)
#     print(self.headers)
#     self.send_response(200, 'OK')
#     self.send_header('Content-type', 'html')
#     self.end_headers()
#
# print('Starting server...')
# HTTPServer(('', 5001), RequestHandler).serve_forever()


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):
  as_main = args.main is not None

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

  with TemporaryDirectory() as tmp_dir:

    as_main or print('\033[36mGetting configuration...\033[0m', end='', flush=True)
    pkg_id = _get_pkg_id(args.pkg_dir)
    repository_url = _get_repository_url(args.pkg_dir)
    pkg_name = repository_url.rsplit('/', 1)[1]
    config_id, config = _create_config(args.config_id, args.config_path, args.config_namespace, repository_url)

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

      if as_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()
      env['OP_DEV_CONFIG'] = json.dumps(config, separators=(',', ':'))
      subprocess.check_call([ 'npm', 'install' ], cwd=args.pkg_dir, env=env)

      # 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)

    else:
      metadata = _create_metadata(port, pkg_id, None, config_id)

      volumes = {}
      work_dir = None
      if as_main:
        volumes[ os.getcwd() ] = { 'bind': '/main', 'mode': 'rw' }
        work_dir = '/main'

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

      env = {
        'OP_DEV_SERVICE_TEST': '1',
        'OP_DEV_CONFIG': json.dumps(config, separators=(',', ':')),
        'OP_DEV_METADATA': json.dumps(metadata, separators=(',', ':'))
      }

      if as_main:
        env['OP_DEV_SERVICE_MAIN'] = json.dumps(args.main, separators=(',', ':'))

      # Now we can run the container
      docker_client = docker.DockerClient()
      container = docker_client.containers.run(
        image_id,
        # command=None if args.main is None else [ 'python', '-B', '-m', 'app', *args.main ],
        name=pkg_name,
        detach=True,
        remove=True,
        ports={ 5000: port },
        environment=env,
        volumes=volumes,
        working_dir=work_dir
      )
      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))


    # Note: if we wanted to print a single status message indicating the server was up and listening to requests, we
    # could do so here by starting a separate thread that waits for the health check to return before printing
    # def health_check():
    #   while True:
    #     try:
    #       requests.get('http://localhost:%s/' % port)
    #     except:
    #       time.sleep(.1)
    #     else:
    #       break
    #   print(f'\033[36mRunning on http://localhost:{port} (CTRL + C to quit)\033[0m')
    # Thread(target=health_check).start()

    # 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'):
        parsed = parse_log_msg(line[:-1])
        if as_main:
          if parsed.get('s') in (None, 'app/__main__.py'):
            # Only print messages that were called using print or using the logging module from the __main__ file
            print(parsed['m'])
        else:
          print(formatter.format(datetime.now(timezone.utc), parsed))
        line = ''
