#!/usr/bin/env python3
# =============================================================================
# @file    run-server
# @brief   Wrapper around mod_wsgi-express to start DIBS server for testing
# @created 2021-01-12
# @license Please see the file named LICENSE in the project directory
# @website https://github.com/caltechlibrary/dibs
#
# Important note about the code that follows.  When Bottle is running in
# auto-reload mode (which is what we invoke in our debug mode), the main
# process spawns a new child process using the same command line arguments
# used to start the main process.  All module-level code is executed at least
# twice -- in other words, the code in this file is executed twice when
# running in Bottle's default server.  It's not an issue for efficiency
# because this is only used to start the process (so we really don't care too
# much if some things are done twice needlessly), but nevertheless, some care
# is needed to avoid doing things that would cause problems if performed
# twice.  It's possible to determine whether the current process is the
# parent or child process by testing for the environment variable BOTTLE_CHILD.
# =============================================================================

# Initial imports. More things are imported later below.

from   configparser import ConfigParser
import pkg_resources
import os
from   os.path import exists, dirname, join, basename, abspath
import sys


# Sanity-check the run-time environment before attempting anything else.
# .............................................................................

if sys.version_info.major < 3 or sys.version_info.minor < 8:
    print('‼️  DIBS requires Python version 3.8 or higher')
    sys.exit(1)

here = abspath(dirname(sys.argv[0]))
if exists(join(here, 'requirements.txt')):
    dibs_root = here
else:
    parent = abspath(join(here, os.path.pardir))
    if exists(join(parent, 'requirements.txt')):
        dibs_root = parent
    else:
        print(f'‼️  File "requirements.txt" not found in {here} or {parent}.')
        print(f'‼️  Unexpected directory organization -- unable to proceed.')
        sys.exit(1)

with open(join(dibs_root, 'requirements.txt'), 'r') as req_file:
    try:
        pkg_resources.require(req_file)
    except pkg_resources.DistributionNotFound:
        print('‼️  Not all Python dependencies have been installed.')
        print('‼️  Please run "pip3 install -r requirements.txt" or equivalent.')
        sys.exit(1)
    except pkg_resources.VersionConflict as ex:
        if hasattr(ex, 'dist'):
            name = ex.dist.project_name
            got  = ex.dist.version
            if len(ex.req.specs) == 1:  # Most common case.
                want = ''.join(ex.req.specs[0])
            else:
                want = '(' + ' and '.join([''.join(spec) for spec in ex.req.specs]) + ')'
            print(f'‼️  Encountered conflicting Python package requirements.')
            print(f'‼️  Need {name} version {want} but version {got} is installed.')
        else:
            print('‼️  Conflicting Python package requirement: ' + str(ex))
        print('‼️  Please run "pip3 install -r requirements.txt" or equivalent.')
        sys.exit(1)

settings_file = join(dibs_root, 'settings.ini')
if not exists(settings_file):
    print('‼️  File "settings.ini" not found in ' + dibs_root)
    print('‼️  Please create one starting with file "settings.ini-example".')
    sys.exit(1)

config = ConfigParser()
config.read(settings_file)
if not config.has_section('dibs'):
    print('‼️  File "settings.ini" is missing a section called [dibs].')
    print('‼️  Please see the example in file "settings.ini-example".')
    sys.exit(1)


# Global defaults.
# .............................................................................

_DEFAULT_BASE_URL = 'http://localhost:8080'


# Main entry point.
# .............................................................................
# Import 3rd-party dependencies here, after the tests above for their existence
# in the user's environment.  Importing them here and not at the top of the
# file is against Pythonic best practices, but IMHO, testing for the packages
# first (above) lets us give a more informative error if anything is missing.

import bottle
from   bottle import request
from   commonpy.data_utils import timestamp
from   commonpy.file_utils import readable
from   commonpy.string_utils import antiformat
from   mod_wsgi.server import cmd_start_server as mod_wsgi_start
import plac
from   rich.console import Console
from   rich.panel import Panel
from   shutil import get_terminal_size
from   sidetrack import set_debug, log
from   yurl import URL

sys.path.append(join(dirname(abspath(__file__)), '..'))

from   dibs import print_version
from   dibs.preflight import preflight_check
from   dibs.server import dibs
from   dibs.settings import config


@plac.annotations(
    adapter    = ('use "A" as the WSGI adapter (default: adapter.wsgi)',    'option', 'a'),
    base_url   = (f'set the server address (default: {_DEFAULT_BASE_URL})', 'option', 'b'),
    database   = ('use "D" as the database file',                           'option', 'd'),
    manifests  = ('look for manifest files in directory "F"',               'option', 'f'),
    iiif_url   = ('use "I" as the IIIF base URL',                           'option', 'i'),
    mode       = ('run mode: normal, verbose, or debug (default: normal)',  'option', 'm'),
    processing = ('use directory "P" for process files',                    'option', 'p'),
    thumbnails = ('look for thumbnail images in directory "T"',             'option', 't'),
    debug_user = ('identity of staff user in debug mode (default: none)',   'option', 'u'),
    version    = ('print version info and exit',                            'flag',   'V'),
)

def main(adapter = 'A', base_url = 'B', database = 'D', manifests = 'F',
         iiif_url = 'I', mode = 'M', processing = 'P', thumbnails = 'T',
         debug_user = 'U', version = False):
    '''Convenient interface for running the DIBS server during development.

This starts a local server running DIBS. It always looks for a file named
"settings.ini" in the current directory or the parent directory, and reads
it to set various DIBS configuration variables. The command-line options to
run-server can override some of the configuration values in "settings.ini".

Run modes
~~~~~~~~~

There are 3 run modes available. Two of the modes can be selected from the
settings.ini file, but are overriden via the option -m on the command line.
If no -m option is given, then this program uses the RUN_MODE value from
settings.ini. In addition, this program offers a third run mode only available
using the -m option. The possible run modes and their effects are as follows:

"normal": uses mod_wsgi-express without debugging options. The server
  will run multiple threads, will not reload if source files are changed,
  will not reload templates if they are changed, and will not stop for
  exceptions. It looks for a file named "adapter.wsgi" in the current
  directory and passes it to mod_wsgi. This mode is a close approximation
  to running DIBS in a basic Apache2 mod_wsgi environment, with adapter.wsgi.

"verbose": like normal mode, but will produce detailed logging to the terminal.
  This mode is useful for testing DIBS using adapter.wsgi in mod_wsgi.
  Verbose mode is invoked using the option -m verbose, or setting RUN_MODE
  to "verbose" in settings.ini. (Using -m verbose overrides settings.ini.)

"debug": this uses Bottle's development server instead of mod_wsgi-express
  and turns on maximum debugging options. This mode does NOT use
  adapter.wsgi. It will turn off template caching, will drop into pdb upon
  exceptions, and unlike mod_wsgi-express, the Bottle server will also
  automatically reload any changed source files. In addition, the reloan
  wait time and loan expirations are set to 1 minute (overriding values set
  on individual items), and finally, the statistics gathering will include
  loans by staff users. (Normally, staff users are not included in the
  statistics to avoid skewing the results.) Debug mode is invoked using the
  option -m debug. It has no corresponding RUN_MODE value in settings.ini.

Since debug mode uses Bottle's default server, the normal authentication
mechanism is nonfunctional and you will not be able to access most pages
unless you define a person using DIBS's people-manager script and give it
the role of "library", and then tell run-server the identity of that user.
To do this, use option -u with the name of the user you defined.  Here is
an example of running in debug mode:

  people-manager add uname="debuguser" role="library"
  run-server -m debug -u debuguser

Database file, manifests directory, and IIIF server URL
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

A number of additional command-line options allow you to override values set in
settings.ini. These are:

 -d: override the value of DATABASE_FILE
 -f: override the value of MANIFEST_DIR
 -i: override the value of IIIF_BASE_URL

Please consult the comments in settings.ini or the DIBS documentation for more
information about the purpose of these configuration variables.

Additional options
~~~~~~~~~~~~~~~~~~

In a live server, the default base URL for DIBS is normally set by the Apache2
configuration. For the local server started by this program, the default is
http://localhost:8080. The option -b can be used to change this. The value
given to -b should contain the protocol, hostname, port (if needed) and any
additional path elements of the root of DIBS on the server; e.g.,
"https://library.example.edu/dibs". (Note: the path component is ignored when
running in debug mode.)

By default, run-server looks for a WSGI adapter named "adapter.wsgi" in the
current directory. The option -a tells run-server to use a different file.

If given the -V option, this program will print the version and other
information, and exit without doing anything else.

Command-line arguments summary
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
'''
    # Do preliminary steps and handle early exits -----------------------------

    child = 'BOTTLE_CHILD' in os.environ

    if version:
        print_version()
        sys.exit(0)

    base_url   = _DEFAULT_BASE_URL if base_url == 'B' else base_url
    parsed_url = URL(base_url)
    scheme     = parsed_url.scheme
    host       = parsed_url.host
    port       = parsed_url.port or ('80' if scheme == 'http' else '443')
    extra_path = parsed_url.path
    if not host:
        print(f'‼️  Unable to parse -b value "{base_url}" -- quitting.')
        sys.exit(1)

    mode = config('RUN_MODE', default = 'normal') if mode == 'M' else mode
    if mode not in ['normal', 'verbose', 'debug']:
        print(f'‼️  Unknown mode "{mode}" -- quitting.')
        sys.exit(1)
    if mode in ['verbose', 'debug']:
        set_debug(True, '-', show_package = True)
    if mode == 'debug':
        import faulthandler
        faulthandler.enable()

    debug_user = None if debug_user == 'U' else debug_user

    adapter_file = join(dibs_root, 'adapter.wsgi') if adapter == 'A' else adapter
    if not exists(adapter_file) or not readable(adapter_file):
        print(f'‼️  File not found or not readable: {adapter_file}')
        sys.exit(1)

    verify_and_set('DATABASE_FILE', database, 'D', 'database file')
    verify_and_set('MANIFEST_DIR', manifests, 'F', 'manifests directory')
    verify_and_set('PROCESS_DIR', processing, 'P', 'process directory')
    verify_and_set('THUMBNAILS_DIR', thumbnails, 'T', 'thumbnails directory')

    if iiif_url != 'I':
        if iiif_url.startswith('http'):
            os.environ['IIIF_BASE_URL'] = iiif_url
        else:
            print(f'‼️  Value does not look like a URL: "{iiif_url}"')
            sys.exit(1)

    if not preflight_check():
        sys.exit(1)

    if mode != 'debug':
        print_safety_warning()
    elif not child:
        print_geeky_logo()

    # Do the real work --------------------------------------------------------

    if not child:
        log('='*8 + f' started {timestamp()} ' + '='*8)
        log(f'given base_url = {base_url}, mode = {mode}')

    try:
        if mode == 'debug':
            start_bottle_server(scheme, host, port, extra_path, debug_user)
        else:
            start_wsgi_server(scheme, host, port, extra_path, adapter_file, mode, debug_user)
    except Exception:
        exception = sys.exc_info()
        msg = antiformat(exception[1])
        if mode == 'debug':
            from traceback import format_exception
            details = ''.join(format_exception(*exception))
            print(f'Exception: {msg}\n{details}')
        else:
            print(f'‼️  Encountered error {exception[0].__name__}: {msg}')

    if not child:
        log('_'*8 + f' stopped {timestamp()} ' + '_'*8)


def start_wsgi_server(scheme, host, port, extra_path, adapter_file, mode, debug_user):
    # mod_wsgi-express doesn't have a direct way to set parameters; you have
    # to construct a command line parsed by mod_wsgi_start(...).  Oh well.
    args = [adapter_file, '--host', host, '--port', port,
            '--setenv', 'RUN_MODE', mode]
    if debug_user:
        args += ['--setenv', 'REMOTE_USER', debug_user]
    if extra_path:
        args += ['--mount-point', extra_path]
    if mode == 'verbose':
        args += ['--setenv', 'VERBOSE', 'True', '--server-status']
        args += ['--log-level', 'debug', '--log-to-terminal']
        args += ['--startup-log', '--access-log']
        args += ['--error-log-format', '[%{%H:%M:%S}t] %M']
        args += ['--access-log-format', '[%{%a %b %0e %H:%M:%S %Y}t] %h \"%r\" %>s']
        print_settings(mode, adapter_file)
    if debug_user:
        print_debug_user_warning()

    log(f'starting mod_wsgi with arguments: {" ".join(args)}')
    mod_wsgi_start(args)


def start_bottle_server(scheme, host, port, extra_path, debug_user):
    # We only ever run the Bottle server in debug and verbose mode, so no
    # need to test that here or change behaviors.  However, be mindful that
    # Bottle will spawn a child process and execute everything a second time.

    child = ('BOTTLE_CHILD' in os.environ)
    log(f'running {"child " if child else ""}server process')

    # Configure Bottle for debugging and also not to ignore exceptions.
    # Also set a property, dibs.debug, that we can reference in server code.
    bottle.debug(True)
    dibs.catchall = False
    dibs.debug_mode = True

    # Set our special "base_url" attribute on our dibs application object.
    # Note: Bottle doesn't have a way to pass extra_path, so it's ignored here.
    dibs.base_url = f'{scheme}://{host}'
    if port not in ['80', '443']:
        dibs.base_url = f'{dibs.base_url}:{port}'

    @dibs.hook('before_request')
    def add_remote_user():
        request.environ['REMOTE_USER'] = debug_user

    if not child:
        print_settings('debug')

    bottle.run(dibs, host = host, port = int(port), reloader = True)


# Miscellaneous utilities.
# .............................................................................

def verify_and_set(settings_var, file_path, default_letter, description):
    if file_path != default_letter:
        os.environ[settings_var] = file_path
    else:
        file_path = config(settings_var)
    if not exists(file_path) or not readable(file_path):
        what = description.capitalize()
        print(f'‼️  {what} not found or not readable: "{file_path}"')
        sys.exit(1)


def print_safety_warning():
    name = basename(sys.argv[0])
    text = ("⚠️  REMINDER: DON'T USE THIS AS A PUBLIC SERVER. ⚠️\n"
            f'"{name}" starts a server with weak security. \n'
            'It is intended for testing and debugging only.\n'
            'For production, set up Apache with adapter.wsgi.')
    terminal_width = get_terminal_size().columns
    panel_width = 55 if terminal_width > 55 else terminal_width
    padding = min(abs(panel_width - len(text) - 2) // 2, 2)
    Console().print(Panel(text, width = panel_width, padding = (0, padding),
                          style = 'orange3'))


def print_debug_user_warning():
    text = ("⚠️  [bold]DANGER: YOU HAVE DISABLED AUTHENTICATION.[/] ⚠️\n"
            'Running with --debug-user means that all visitors\n'
            'are considered pre-authenticated. Do [bold]not[/] run in\n'
            'this mode on a production or public server.')
    terminal_width = get_terminal_size().columns
    panel_width = 55 if terminal_width > 55 else terminal_width
    padding = min(abs(panel_width - len(text) - 2) // 2, 2)
    Console().print(Panel(text, width = panel_width, padding = (0, padding),
                          style = 'red'))


def print_settings(mode, adapter_file = None):
    server = 'Bottle' if mode == 'debug' else 'mod_wsgi-express'
    adapter = adapter_file or '(not applicable)'
    settings = ('\n  Current settings are:\n'
                f'    Run mode:       {mode} (using {server} server)\n'
                f'    WSGI adapter:   {adapter}\n'
                f'    DIBS root:      {dibs_root}\n'
                f'    DATABASE_FILE:  {config("DATABASE_FILE")}\n'
                f'    MANIFEST_DIR:   {config("MANIFEST_DIR")}\n'
                f'    PROCESS_DIR:    {config("PROCESS_DIR")}\n'
                f'    THUMBNAILS_DIR: {config("THUMBNAILS_DIR")}\n'
                f'    IIIF_BASE_URL:  {config("IIIF_BASE_URL")}')
    log(settings)


def print_geeky_logo():
    text = '''
 ______  _____ ______  _______
 |     \   |   |_____] |______
 |_____/ __|__ |_____] ______|
'''
    Console().print(text, style='dark_orange')


# Main invocation.
# .............................................................................

plac.call(main)
