from flask import Flask, request, jsonify, send_file
from flask_cors import CORS
from typing import Union, List
import argparse
import json
import os


class Database(object):
    _instance = None

    @staticmethod
    def instance():
        if Database._instance is None:
            Database._instance = Database()
        return Database._instance

    def __init__(self):
        with open(config().classifier_results_json, "r") as f:
            self._data = json.load(f)
        self._has_pending_changes = False

    def _follow_path(self, path: List[str]):
        value = self._data
        for i in range(len(path)):
            if isinstance(value, dict):
                if path[i] not in value:
                    return {}
                value = value[path[i]]
            elif isinstance(value, list):
                if path[i].startswith("@"):
                    id = path[i][1:]
                    for x in value:
                        if isinstance(x, dict) and "id" in x and str(x["id"]) == id:
                            value = x
                            break
                else:
                    idx = int(path[i])
                    if idx < 0 or idx >= len(value):
                        return {}
                    value = value[idx]
            else:
                return {}
        return value

    def on_read(self, path: List[str]):
        log("Database.on_read(path='" + "/".join(path) + "')")
        return self._follow_path(path)

    def on_write(self, path: List[str], value: dict) -> Union[str, None]:
        log("Database.on_write(path='" + "/".join(path) + "', value='" + json.dumps(value) + "')")
        if len(path) == 0:
            return "The passed path is empty. A path must end with a key in a dictionary whose value will be set."
        parent = self._follow_path(path[:-1])
        if not isinstance(parent, dict):
            return "Only values in dictionaries can be written."
        parent[path[-1]] = value
        self._has_pending_changes = True
        return None

    def on_commit(self):
        log("Database.on_commit()")
        if self._has_pending_changes:
            log("Saving pending changes...")
            with open(config().classifier_results_json, "w") as f:
                json.dump(self._data, f, indent=2)
            self._has_pending_changes = False

    def has_pending_changes(self):
        return self._has_pending_changes

    def on_shutdown(self):
        log("Database.on_shutdown()")
        if self._has_pending_changes:
            print("WARNING: Database has not committed changes. They will be lost!")


app = Flask(__name__)
CORS(app)


def split_path(path: str) -> List[str]:
    return [x.strip() for x in path.split("/") if len(x.strip()) > 0]


@app.route("/", methods=["GET"])
def welcome():
    return "Welcome to Antarstick database!"


@app.route("/db/<path:path_string>", methods=["GET"])
def get_data(path_string):
    path = split_path(path_string)
    data = Database.instance().on_read(path)
    return jsonify(data)


@app.route("/db/<path:path_string>", methods=["POST"])
def set_data(path_string):
    path = split_path(path_string)
    data = request.get_json()
    if data is None:
        print("FAILURE: set_data: A JSON body is expected.")
        return "A JSON body is expected for POST requests.", 415
    error_msg = Database.instance().on_write(path, data)
    if error_msg is not None:
        print("FAILURE: set_data: in write to database: " + error_msg + "; path: " + path_string)
        return error_msg, 406
    return "Data has been written to database"


@app.route("/multipost", methods=["POST"])
def set_multiple_data():
    data = request.get_json()
    if data is None:
        print("FAILURE: set_multiple_data: A JSON body is expected.")
        return "A JSON body is expected for POST requests.", 415
    if not isinstance(data, list) or len(data) == 0 or not all(isinstance(x, list) and len(x) == 2 for x in data):
        print("FAILURE: set_data: multipost: The passed value is not a non-empty list of pairs (path, value).")
        return "In multipost the passed data must be a non-empty list of pairs (path, value).", 400
    for path_and_value in data:
        path = split_path(path_and_value[0])
        if len(path) < 2 or path[0] != "db":
            print("FAILURE: set_multiple_data: wrong path: " + path_and_value[0])
            return "Wrong path to database: " + path_and_value[0] + "A path must be in form '/db/*/<dict-key>' ", 400
        path = path[1:]
        error_msg = Database.instance().on_write(path, path_and_value[1])
        if error_msg is not None:
            print("FAILURE: set_data: in write to database: " + error_msg + "; path: " + path_and_value[0])
            return error_msg, 406
    return "Data has been written to database"


@app.route("/probe/pending_changes", methods=["GET"])
def has_pending_changes():
    state = Database.instance().has_pending_changes()
    return jsonify(state)


@app.route("/commit", methods=["POST"])
def commit():
    Database.instance().on_commit()
    return "Data saved to disk."


@app.route("/image/<path:path_string>", methods=["GET"])
def get_image(path_string):
    path = split_path(path_string)
    if len(path) == 0:
        print("FAILURE: get_image: unsupported image format: " + path_string)
        return "A JPG image must be specified.", 415
    if not path[-1].lower().endswith(".jpg"):
        print("FAILURE: get_image: unsupported image format: " + path_string)
        return "Only JPG images are supported.", 415
    image_path = os.path.join(
        config().root_data_dir,
        config().location,
        config().year,
        "camera_images",
        *path
    )
    if not os.path.isfile(image_path):
        print("FAILURE: get_image: Image not found: " + path_string)
        return "Image not found: " + path_string + "\nPath to an image is '/<camera-name>/<image-file-name>.JPG'", 404
    return send_file(image_path, mimetype="image/jpg")


class Config:
    _config = None

    @staticmethod
    def instance():
        if Config._config is None:
            Config._config = Config._build_script_config()
        return Config._config

    @staticmethod
    def _build_script_config():
        parser = argparse.ArgumentParser(
            formatter_class=argparse.RawDescriptionHelpFormatter,
            description="Antarstick web server. Provides read and write access to JSON database and " +
                "provides also images. For accessing the database use 'db' as the first element in your " +
                "URL and then follow keys to dictionaries and indices to lists in the JSON file. There is an " +
                "alternative way for accessing a dictionary element of a list - by specifying the value X " +
                "of the 'id' key of the dictionary as '@X'. For obtaining image your URL must be in the form " +
                "'/<camera-name>/<image-file-name>.JPG'.",
            epilog="Client usage examples:\n\n" +
                "* Read from database:\n" +
                "    $ curl http://localhost:3000/db/data/@K1/IMAG0001.JPG/sticks/7/snowHeight\n" +
                "* Write to database:\n" +
                "    $ curl -d '123' -H 'Content-Type: application/json' 'http://localhost:3000/db/data/@K1/IMAG0001.JPG/sticks/7/snowHeight'\n" +
                "* Multipost to database (i.e. multiple writes in single POST):\n" +
                "    $ curl -d '{ \"path1\": \"value1\", \"path2\": \"value2\", ... }' -H 'Content-Type: application/json' 'http://localhost:3000/multipost'\n" +
                "* Commit changed to disk:\n" +
                "    $ curl http://localhost:3000/commit\n" +
                "* Read image:\n" +
                "    $ curl -H 'Content-Type: image/jpg' http://localhost:3000/image/K1/IMAG0001.JPG\n" +
                "* Check for pending changes (to be committed):\n" +
                "    $ curl http://localhost:3000/probe/pending_changes\n"
                )
        parser.add_argument(
            "location", type=str, default=None,
            help="A location where the data were collected, e.g. \"Monolith Lake\", \"Keller Stream\"."
            )
        parser.add_argument(
            "year", type=str, default=None,
            help="A year when the data were collected at the given location, e.g. 2011."
            )
        parser.add_argument(
            "--url", type=str, default="localhost",
            help="A URL the server should listens on."
            )
        parser.add_argument(
            "--port", type=int, default=3000,
            help="A port the server should listens on."
            )
        parser.add_argument(
            "--root-data-dir", type=str, default=None,
            help="A root data directory. If not specified, then the script searches for the 'antarstic-data' "
                "directory along the path of the current working directory."
            )
        parser.add_argument(
            "--debug", action='store_true',
            help="Whether to print debug messages or not."
            )
        config = parser.parse_args()

        if config.root_data_dir is None:
            start_path = os.path.abspath(os.getcwd())
            config.root_data_dir = start_path
            while len(config.root_data_dir) > 0 and not os.path.isdir(os.path.join(config.root_data_dir, "antarstick-data")):
                config.root_data_dir = os.path.dirname(config.root_data_dir)
            config.root_data_dir = os.path.join(config.root_data_dir, "antarstick-data")
            if not os.path.isdir(config.root_data_dir):
                raise Exception("Cannot find the data root directory 'antarstick-data' along the directory: " + start_path)
        else:
            config.root_data_dir = os.path.abspath(config.root_data_dir)
            if os.path.dirname(config.root_data_dir) != "antarstick-data" or not os.path.isdir(config.root_data_dir):
                raise Exception("The passed data root directory '" + config.root_data_dir + "' is not valid data root.")

        config.data_dir = os.path.join(config.root_data_dir, config.location, config.year)
        if not os.path.isdir(config.data_dir):
            raise Exception("The data directory '" + config.data_dir + "' is not valid.")

        config.classifier_results_json = os.path.join(config.data_dir, "classifier_results.json")
        if not os.path.isfile(config.classifier_results_json):
            raise Exception("The data file does not exist: " + config.classifier_results_json)

        config.camera_images_root_dir = os.path.join(config.data_dir, "camera_images")
        if not os.path.isdir(config.camera_images_root_dir):
            raise Exception("The camera images directory '" + config.camera_images_root_dir + "' is not valid.")

        return config


def config():
    return Config.instance()


def log(msg: str) -> None:
    if config().debug:
        print(msg)


if __name__ == '__main__':
    print("Starting server at " + Config.instance().url + ":" + str(Config.instance().port))
    app.run(host=config().url, port=config().port, debug=config().debug)
