import os
import io
import json
import hashlib
import re
import sqlite3
from datetime import datetime
from pathlib import Path
import tempfile
from enum import Enum
from typing import Optional, Type, Union, ByteString
from .common import ClientError


def generate_checksum(file, chunk_size=4096):
    """
    Generate checksum for file from chunks.

    :param file: file to calculate checksum
    :param chunk_size: size of chunk
    :return: sha1 checksum
    """
    checksum = hashlib.sha1()  # nosec B324 - usedforsecurity=False flag is compatible with python 3.9+
    with open(file, "rb") as f:
        while True:
            chunk = f.read(chunk_size)
            if not chunk:
                return checksum.hexdigest()
            checksum.update(chunk)


def save_to_file(stream, path):
    """
    Save readable object in file.
    :param stream: object implementing readable interface
    :param path: destination file path
    """
    directory = os.path.abspath(os.path.dirname(path))

    os.makedirs(directory, exist_ok=True)

    with open(path, "wb") as output:
        writer = io.BufferedWriter(output, buffer_size=32768)
        while True:
            part = stream.read(4096)
            if part:
                writer.write(part)
            else:
                writer.flush()
                break


def move_file(src, dest):
    dest_dir = os.path.dirname(dest)
    os.makedirs(dest_dir, exist_ok=True)
    os.rename(src, dest)


class DateTimeEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, datetime):
            return obj.isoformat()

        return super().default(obj)


def find(items, fn):
    for item in items:
        if fn(item):
            return item


def int_version(version):
    """Convert v<n> format of version to integer representation."""
    return int(version.lstrip("v")) if re.match(r"v\d", version) else None


def do_sqlite_checkpoint(path, log=None):
    """
    Function to do checkpoint over the geopackage file which was not able to do diff file.

    :param path: file's absolute path on disk
    :type path: str
    :param log: optional reference to a logger
    :type log: logging.Logger
    :returns: new size and checksum of file after checkpoint
    :rtype: int, str
    """
    new_size = None
    new_checksum = None
    if ".gpkg" in path and os.path.exists(f"{path}-wal"):
        if log:
            log.info("checkpoint - going to add it in " + path)
        conn = sqlite3.connect(path)
        cursor = conn.cursor()
        cursor.execute("PRAGMA wal_checkpoint=FULL")
        if log:
            log.info("checkpoint - return value: " + str(cursor.fetchone()))
        cursor.execute("VACUUM")
        conn.commit()
        conn.close()
        new_size = os.path.getsize(path)
        new_checksum = generate_checksum(path)
        if log:
            log.info("checkpoint - new size {} checksum {}".format(new_size, new_checksum))

    return new_size, new_checksum


def get_versions_with_file_changes(mc, project_path, file_path, version_from=None, version_to=None, file_history=None):
    """
    Get the project version tags where the file was added, modified or deleted.

    Args:
        mc (MerginClient): MerginClient instance
        project_path (str): project full name (<namespace>/<name>)
        file_path (str): relative path of file to download in the project directory
        version_from (str): optional minimum version to fetch, for example "v3"
        version_to (str): optional maximum version to fetch
        file_history (dict): optional file history info, result of project_file_history_info().

    Returns:
        list of version tags, for example ["v4", "v7", "v8"]
    """
    if file_history is None:
        file_history = mc.project_file_history_info(project_path, file_path)
    all_version_numbers = sorted([int(k[1:]) for k in file_history["history"].keys()])
    version_from = all_version_numbers[0] if version_from is None else int_version(version_from)
    version_to = all_version_numbers[-1] if version_to is None else int_version(version_to)
    if version_from is None or version_to is None:
        err = f"Wrong version parameters: {version_from}-{version_to} while getting diffs for {file_path}. "
        err += f"Version tags required in the form: 'v2', 'v11', etc."
        raise ClientError(err)
    if version_from >= version_to:
        err = f"Wrong version parameters: {version_from}-{version_to} while getting diffs for {file_path}. "
        err += f"version_from needs to be smaller than version_to."
        raise ClientError(err)
    if version_from not in all_version_numbers or version_to not in all_version_numbers:
        err = f"Wrong version parameters: {version_from}-{version_to} while getting diffs for {file_path}. "
        err += f"Available versions: {all_version_numbers}"
        raise ClientError(err)

    # Find versions to fetch between the 'from' and 'to' versions
    idx_from = idx_to = None
    for idx, version in enumerate(all_version_numbers):
        if version == version_from:
            idx_from = idx
        elif version == version_to:
            idx_to = idx
            break
    return [f"v{ver_nr}" for ver_nr in all_version_numbers[idx_from : idx_to + 1]]


def unique_path_name(path):
    """
    Generates an unique name for the given path. If the given path does
    not exist yet it will be returned unchanged, otherwise a sequntial
    number will be added to the path in format:
      - if path is a directory: "folder" -> "folder (1)"
      - if path is a file: "filename.txt" -> "filename (1).txt"

    :param path: path to file or directory
    :type path: str
    :returns: unique path
    :rtype: str
    """
    unique_path = str(path)

    is_dir = os.path.isdir(path)
    head, tail = os.path.split(os.path.normpath(path))
    ext = "".join(Path(tail).suffixes)
    file_name = tail.replace(ext, "")

    i = 0
    while os.path.exists(unique_path):
        i += 1

        if is_dir:
            unique_path = f"{path} ({i})"
        else:
            unique_path = os.path.join(head, file_name) + f" ({i}){ext}"

    return unique_path


def conflicted_copy_file_name(path, user, version):
    """
    Generates a file name for the conflict copy file in the following form
    <filename> (conflicted copy, <username> v<version>).gpkg. Example:
    * data (conflicted copy, martin v5).gpkg

    :param path: name of the file
    :type path: str
    :param user: name of the user
    :type user: str
    :param version: version of the Mergin Maps project
    :type version: str
    :returns: new file name
    :rtype: str
    """
    if not path:
        return ""

    head, tail = os.path.split(os.path.normpath(path))
    ext = "".join(Path(tail).suffixes)
    file_name = tail.replace(ext, "")
    # in case of QGIS project files we TemporaryDirectoryhave to add "~" (tilde) to suffix
    # to avoid having several QGIS project files inside Mergin Maps project.
    # See https://github.com/lutraconsulting/qgis-mergin-plugin/issues/382
    # for more details
    if is_qgis_file(path):
        ext += "~"
    return os.path.join(head, file_name) + f" (conflicted copy, {user} v{version}){ext}"


def edit_conflict_file_name(path, user, version):
    """
    Generates a file name for the edit conflict file in the following form
    <filename> (edit conflict, <username> v<version>).gpkg. Example:
    * data (edit conflict, martin v5).gpkg

    :param path: name of the file
    :type path: str
    :param user: name of the user
    :type user: str
    :param version: version of the Mergin Maps project
    :type version: str
    :returns: new file name
    :rtype: str
    """
    if not path:
        return ""

    head, tail = os.path.split(os.path.normpath(path))
    ext = "".join(Path(tail).suffixes)
    file_name = tail.replace(ext, "")
    return os.path.join(head, file_name) + f" (edit conflict, {user} v{version}).json"


def is_version_acceptable(version, min_version):
    """
    Checks whether given version is at least min_version or later (both given as strings).

    Versions are expected to be using syntax X.Y.Z

    Returns true if version >= min_version
    """
    m = re.search("(\\d+)[.](\\d+)", min_version)
    min_major, min_minor = m.group(1), m.group(2)

    if len(version) == 0:
        return False  # unknown version is assumed to be old

    m = re.search("(\\d+)[.](\\d+)", version)
    if m is None:
        return False

    major, minor = m.group(1), m.group(2)

    return major > min_major or (major == min_major and minor >= min_minor)


def is_versioned_file(path: str) -> bool:
    diff_extensions = [".gpkg", ".sqlite"]
    f_extension = os.path.splitext(path)[1]
    return f_extension.lower() in diff_extensions


def is_qgis_file(path: str) -> bool:
    """
    Check if file is a QGIS project file.
    """
    f_extension = os.path.splitext(path)[1]
    return f_extension.lower() in [".qgs", ".qgz"]


def is_mergin_config(path: str) -> bool:
    """Check if the given path is for file mergin-config.json"""
    filename = os.path.basename(path).lower()
    return filename == "mergin-config.json"


def bytes_to_human_size(bytes: int):
    """
    Convert bytes to human readable size
    example  :
    bytes_to_human_size(5600000) -> "5.3 MB"
    """
    precision = 1
    if bytes < 1e-5:
        return "0.0 MB"
    elif bytes < 1024.0 * 1024.0:
        return f"{round( bytes / 1024.0, precision )} KB"
    elif bytes < 1024.0 * 1024.0 * 1024.0:
        return f"{round( bytes / 1024.0 / 1024.0, precision)} MB"
    elif bytes < 1024.0 * 1024.0 * 1024.0 * 1024.0:
        return f"{round( bytes / 1024.0 / 1024.0 / 1024.0, precision )} GB"
    else:
        return f"{round( bytes / 1024.0 / 1024.0 / 1024.0 / 1024.0, precision )} TB"


def get_data_checksum(data: ByteString) -> str:
    """
    Generate sha1 checksum for given data.

    :param data: data to calculate checksum
    :return: sha1 checksum
    """
    checksum = hashlib.sha1()  # nosec B324 - usedforsecurity=False flag is compatible with python 3.9+
    checksum.update(data)
    return checksum.hexdigest()


def cleanup_tmp_dir(mp, tmp_dir: tempfile.TemporaryDirectory):
    """
    Remove temporary from tempfile.TemporaryDirectory instance
    This is needed beacause ignore_clanup_errors is not accepted under < Python 3.10
    """

    try:
        tmp_dir.cleanup()
    except PermissionError:
        mp.log.warning(f"Permission error during tmp dir cleanup: {tmp_dir.name}")
    except Exception as e:
        mp.log.error(f"Error during tmp dir cleanup: {tmp_dir.name}: {e}")


def normalize_role(role: Union[str, Enum], enum_cls: Type[Enum]) -> Optional[Enum]:
    """
    Takes a role as a string or an Enum member and returns the corresponding Enum member
    from the given enum class. Returns None if the input is invalid or no match is found.
    """
    if isinstance(role, enum_cls):
        return role

    if isinstance(role, str):
        try:
            return enum_cls(role.strip().lower())
        except ValueError:
            return None

    return None
