import datetime as dt
import os
import re
import subprocess
import sys
import tempfile
import traceback
import uuid
from functools import lru_cache, total_ordering
from pathlib import Path

try:
    import numpy as np
except ImportError:
    np = None

try:
    import matplotlib as mpl
    import matplotlib.figure  # noqa: F401
    import matplotlib.pyplot as plt
except ImportError:
    mpl = None

try:
    import plotly.graph_objects as plotly_go
except ImportError:
    plotly_go = None

import xlwings

missing = object()


def int_to_rgb(number):
    """Given an integer, return the rgb"""
    number = int(number)
    r = number % 256
    g = (number // 256) % 256
    b = (number // (256 * 256)) % 256
    return r, g, b


def rgb_to_int(rgb):
    """Given an rgb, return an int"""
    return rgb[0] + (rgb[1] * 256) + (rgb[2] * 256 * 256)


def hex_to_rgb(color):
    color = color[1:] if color.startswith("#") else color
    return tuple(int(color[i : i + 2], 16) for i in (0, 2, 4))


def rgb_to_hex(r, g, b):
    return f"#{r:02x}{g:02x}{b:02x}"


def get_duplicates(seq):
    seen = set()
    duplicates = set(x for x in seq if x in seen or seen.add(x))
    return duplicates


def np_datetime_to_datetime(np_datetime):
    ts = (np_datetime - np.datetime64("1970-01-01T00:00:00Z")) / np.timedelta64(1, "s")
    dt_datetime = dt.datetime.utcfromtimestamp(ts)
    return dt_datetime


ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"


def col_name(i):
    i -= 1
    if i < 0:
        raise IndexError(i)
    elif i < 26:
        return ALPHABET[i]
    elif i < 702:
        i -= 26
        return ALPHABET[i // 26] + ALPHABET[i % 26]
    elif i < 16384:
        i -= 702
        return ALPHABET[i // 676] + ALPHABET[i // 26 % 26] + ALPHABET[i % 26]
    else:
        raise IndexError(i)


def address_to_index_tuple(address):
    """
    Based on a function from XlsxWriter, which is distributed under the following
    BSD 2-Clause License:

    Copyright (c) 2013-2021, John McNamara <jmcnamara@cpan.org>
    All rights reserved.

    Redistribution and use in source and binary forms, with or without
    modification, are permitted provided that the following conditions are met:

    1. Redistributions of source code must retain the above copyright notice, this
       list of conditions and the following disclaimer.

    2. Redistributions in binary form must reproduce the above copyright notice,
       this list of conditions and the following disclaimer in the documentation
       and/or other materials provided with the distribution.

    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
    AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
    IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
    DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
    FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
    DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
    SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
    CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
    OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
    OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
    """
    re_range_parts = re.compile(r"(\$?)([A-Z]{1,3})(\$?)(\d+)")
    match = re_range_parts.match(address)
    if match:
        col_str = match.group(2)
        row_str = match.group(4)

        # Convert base26 column string to number
        expn = 0
        col = 0
        for char in reversed(col_str):
            col += (ord(char) - ord("A") + 1) * (26**expn)
            expn += 1

        return int(row_str), col


def a1_to_tuples(address):
    if ":" in address:
        address1, address2 = address.split(":")
        tuple1 = address_to_index_tuple(address1.upper())
        tuple2 = address_to_index_tuple(address2.upper())
    else:
        tuple1 = address_to_index_tuple(address.upper())
        tuple2 = None
    return tuple1, tuple2


class VBAWriter:

    MAX_VBA_LINE_LENGTH = 1024
    VBA_LINE_SPLIT = " _\n"
    MAX_VBA_SPLITTED_LINE_LENGTH = MAX_VBA_LINE_LENGTH - len(VBA_LINE_SPLIT)

    class Block:
        def __init__(self, writer, start):
            self.writer = writer
            self.start = start

        def __enter__(self):
            self.writer.writeln(self.start)
            self.writer._indent += 1

        def __exit__(self, exc_type, exc_val, exc_tb):
            self.writer._indent -= 1

    def __init__(self, f):
        self.f = f
        self._indent = 0
        self._freshline = True

    def block(self, template, **kwargs):
        return VBAWriter.Block(self, template.format(**kwargs))

    def start_block(self, template, **kwargs):
        self.writeln(template, **kwargs)
        self._indent += 1

    def end_block(self, template, **kwargs):
        self.writeln(template, **kwargs)
        self._indent -= 1

    def write(self, template, **kwargs):
        if kwargs:
            template = template.format(**kwargs)
        if self._freshline:
            template = ("    " * self._indent) + template
            self._freshline = False
        self.write_vba_line(template)
        if template[-1] == "\n":
            self._freshline = True

    def write_label(self, label):
        self._indent -= 1
        self.write(label + ":\n")
        self._indent += 1

    def writeln(self, template, **kwargs):
        self.write(template + "\n", **kwargs)

    def write_vba_line(self, vba_line):
        if len(vba_line) > VBAWriter.MAX_VBA_LINE_LENGTH:
            separator_index = VBAWriter.get_separator_index(vba_line)
            self.f.write(vba_line[:separator_index] + VBAWriter.VBA_LINE_SPLIT)
            self.write_vba_line(vba_line[separator_index:])
        else:
            self.f.write(vba_line)

    @classmethod
    def get_separator_index(cls, vba_line):
        for index in range(cls.MAX_VBA_SPLITTED_LINE_LENGTH, 0, -1):
            if " " == vba_line[index]:
                return index
        return (
            cls.MAX_VBA_SPLITTED_LINE_LENGTH
        )  # Best effort: split string at the maximum possible length


def try_parse_int(x):
    try:
        return int(x)
    except ValueError:
        return x


@total_ordering
class VersionNumber:
    def __init__(self, s):
        self.value = tuple(map(try_parse_int, s.split(".")))

    @property
    def major(self):
        return self.value[0]

    @property
    def minor(self):
        return self.value[1] if len(self.value) > 1 else None

    def __str__(self):
        return ".".join(map(str, self.value))

    def __repr__(self):
        return "%s(%s)" % (self.__class__.__name__, repr(str(self)))

    def __eq__(self, other):
        if isinstance(other, VersionNumber):
            return self.value == other.value
        elif isinstance(other, str):
            return self.value == VersionNumber(other).value
        elif isinstance(other, tuple):
            return self.value[: len(other)] == other
        elif isinstance(other, int):
            return self.major == other
        else:
            return False

    def __lt__(self, other):
        if isinstance(other, VersionNumber):
            return self.value < other.value
        elif isinstance(other, str):
            return self.value < VersionNumber(other).value
        elif isinstance(other, tuple):
            return self.value[: len(other)] < other
        elif isinstance(other, int):
            return self.major < other
        else:
            raise TypeError("Cannot compare other object with version number")


def process_image(image, format, export_options):
    """Returns filename and is_temp_file"""
    image = fspath(image)
    if isinstance(image, str):
        return image, False
    elif mpl and isinstance(image, mpl.figure.Figure):
        image_type = "mpl"
    elif plotly_go and isinstance(image, plotly_go.Figure):
        image_type = "plotly"
    else:
        raise TypeError("Don't know what to do with that image object")

    if export_options is None:
        export_options = {"bbox_inches": "tight", "dpi": 200}

    if format == "vector":
        if sys.platform.startswith("darwin"):
            format = "pdf"
        else:
            format = "svg"

    temp_dir = os.path.realpath(tempfile.gettempdir())
    filename = os.path.join(temp_dir, str(uuid.uuid4()) + "." + format)

    if image_type == "mpl":
        canvas = mpl.backends.backend_agg.FigureCanvas(image)
        canvas.draw()
        image.savefig(filename, **export_options)
        plt.close(image)
    elif image_type == "plotly":
        image.write_image(filename)
    return filename, True


def fspath(path):
    """Convert path-like object to string.

    On python <= 3.5 the input argument is always returned unchanged (no support for
    path-like objects available). TODO: can be removed as 3.5 no longer supported.

    """
    if hasattr(os, "PathLike") and isinstance(path, os.PathLike):
        return os.fspath(path)
    else:
        return path


def read_config_sheet(book):
    try:
        return book.sheets["xlwings.conf"]["A1:B1"].options(dict, expand="down").value
    except:  # noqa: E722
        # A missing sheet currently produces different errors on mac and win
        return {}


def read_user_config():
    """Returns keys in lowercase of xlwings.conf in the user's home directory"""
    config = {}
    if Path(xlwings.USER_CONFIG_FILE).is_file():
        with open(xlwings.USER_CONFIG_FILE, "r") as f:
            for line in f:
                values = re.findall(r'"[^"]*"', line)
                if values:
                    config[values[0].strip('"').lower()] = os.path.expandvars(
                        values[1].strip('"')
                    )
    return config


@lru_cache(None)
def get_cached_user_config(key):
    return read_user_config().get(key.lower())


def exception(logger, msg, *args):
    if logger.hasHandlers():
        logger.exception(msg, *args)
    else:
        print(msg % args)
        traceback.print_exc()


def chunk(sequence, chunksize):
    for i in range(0, len(sequence), chunksize):
        yield sequence[i : i + chunksize]


def query_yes_no(question, default="yes"):
    """Ask a yes/no question via input() and return their answer.

    "question" is a string that is presented to the user.
    "default" is the presumed answer if the user just hits <Enter>.
            It must be "yes" (the default), "no" or None (meaning
            an answer is required of the user).

    The "answer" return value is True for "yes" or False for "no".

    Licensed under the MIT License
    Copyright by Trent Mick
    https://code.activestate.com/recipes/577058/
    """
    valid = {"yes": True, "y": True, "ye": True, "no": False, "n": False}
    if default is None:
        prompt = " [y/n] "
    elif default == "yes":
        prompt = " [Y/n] "
    elif default == "no":
        prompt = " [y/N] "
    else:
        raise ValueError("invalid default answer: '%s'" % default)

    while True:
        sys.stdout.write(question + prompt)
        choice = input().lower()
        if default is not None and choice == "":
            return valid[default]
        elif choice in valid:
            return valid[choice]
        else:
            sys.stdout.write("Please respond with 'yes' or 'no' " "(or 'y' or 'n').\n")


def prepare_sys_path(args_string):
    """Called from Excel to prepend the default paths and those from the PYTHONPATH
    setting to sys.path. While RunPython could use Book.caller(), the UDF server can't,
    as this runs before VBA can push the ActiveWorkbook over. UDFs also can't interact
    with the book object in general as Excel is busy during the function call and so
    won't allow you to read out the config sheet, for example. Before 0.24.9,
    these manipulations were handled in VBA, but couldn't handle SharePoint.
    """
    args = os.path.normcase(os.path.expandvars(args_string)).split(";")
    paths = []
    if args[0].lower() == "true":  # Add dir of Excel file to PYTHONPATH
        # Not sure if we really need normcase,
        # but on Windows it replaces "/" with "\", so let's revert that
        active_fullname = args[1].replace("\\", "/")
        this_fullname = args[2].replace("\\", "/")
        for fullname in [active_fullname, this_fullname]:
            if not fullname:
                continue
            elif "://" in fullname:
                fullname = Path(
                    fullname_url_to_local_path(
                        url=fullname,
                        sheet_onedrive_consumer_config=args[3],
                        sheet_onedrive_commercial_config=args[4],
                        sheet_sharepoint_config=args[5],
                    )
                )
            else:
                fullname = Path(fullname)
            paths += [str(fullname.parent), str(fullname.with_suffix(".zip"))]

    if args[6:]:
        paths += args[6:]

    if paths:
        sys.path[0:0] = list(set(paths))


@lru_cache(None)
def fullname_url_to_local_path(
    url,
    sheet_onedrive_consumer_config=None,
    sheet_onedrive_commercial_config=None,
    sheet_sharepoint_config=None,
):
    """
    When AutoSave is enabled in Excel with either OneDrive or SharePoint, VBA/COM's
    Workbook.FullName turns into a URL without any possibilities to get the local file
    path. While OneDrive and OneDrive for Business make it easy enough to derive the
    local path from the URL, SharePoint allows to define the "Site name" and "Site
    address" independently from each other with the former ending up in the local folder
    path and the latter in the FullName URL. Adding to the complexity: (1) When the site
    name contains spaces, they will be stripped out from the URL and (2) you can sync a
    subfolder directly (this, at least, works when you have a single folder at the
    SharePoint's Document root), which results in skipping a folder level locally when
    compared to the online/URL version. And (3) the OneDriveCommercial env var sometimes
    seems to actually point to the local SharePoint folder.

    Parameters
    ----------
    url : str
        URL as returned by VBA's FullName

    sheet_onedrive_consumer_config : str
        Optional Path to the local OneDrive (Personal) as defined in the Workbook's
        config sheet

    sheet_onedrive_commercial_config : str
        Optional Path to the local OneDrive for Business as defined in the Workbook's
        config sheet

    sheet_sharepoint_config : str
        Optional Path to the local SharePoint drive as defined in the Workbook's config
        sheet
    """
    # Directory config files can't be used
    # since the whole purpose of this exercise is to find out about a book's dir
    onedrive_consumer_config_name = (
        "ONEDRIVE_CONSUMER_WIN"
        if sys.platform.startswith("win")
        else "ONEDRIVE_CONSUMER_MAC"
    )
    onedrive_commercial_config_name = (
        "ONEDRIVE_COMMERCIAL_WIN"
        if sys.platform.startswith("win")
        else "ONEDRIVE_COMMERCIAL_MAC"
    )
    sharepoint_config_name = (
        "SHAREPOINT_WIN" if sys.platform.startswith("win") else "SHAREPOINT_MAC"
    )
    if sheet_onedrive_consumer_config is not None:
        sheet_onedrive_consumer_config = os.path.expandvars(
            sheet_onedrive_consumer_config
        )
    if sheet_onedrive_commercial_config is not None:
        sheet_onedrive_commercial_config = os.path.expandvars(
            sheet_onedrive_commercial_config
        )
    if sheet_sharepoint_config is not None:
        sheet_sharepoint_config = os.path.expandvars(sheet_sharepoint_config)
    onedrive_consumer_config = sheet_onedrive_consumer_config or read_user_config().get(
        onedrive_consumer_config_name.lower()
    )
    onedrive_commercial_config = (
        sheet_onedrive_commercial_config
        or read_user_config().get(onedrive_commercial_config_name.lower())
    )
    sharepoint_config = sheet_sharepoint_config or read_user_config().get(
        sharepoint_config_name.lower()
    )

    # OneDrive
    pattern = re.compile(r"https://d.docs.live.net/[^/]*/(.*)")
    match = pattern.match(url)
    if match:
        if sys.platform.startswith("darwin"):
            default_dir = Path.home() / "Library" / "CloudStorage" / "OneDrive-Personal"
        else:
            default_dir = Path.home() / "OneDrive"
        legacy_default_dir = Path.home() / "OneDrive"
        root = (
            onedrive_consumer_config
            or os.getenv("OneDriveConsumer")
            or os.getenv("OneDrive")
            or (str(default_dir) if default_dir.is_dir() else None)
            or (str(legacy_default_dir) if legacy_default_dir else None)
        )
        if not root:
            raise xlwings.XlwingsError(
                f"Couldn't find the local OneDrive folder. Please configure the "
                f"{onedrive_consumer_config_name} setting, see: xlwings.org/error."
            )
        local_path = Path(root) / match.group(1)
        if local_path.is_file():
            return str(local_path)
        else:
            raise xlwings.XlwingsError(
                "Couldn't find your local OneDrive file, see: xlwings.org/error"
            )

    # OneDrive for Business
    pattern = re.compile(r"https://[^-]*-my.sharepoint.[^/]*/[^/]*/[^/]*/[^/]*/(.*)")
    match = pattern.match(url)
    if match:
        root = (
            onedrive_commercial_config
            or os.getenv("OneDriveCommercial")
            or os.getenv("OneDrive")
        )
        if not root:
            raise xlwings.XlwingsError(
                f"Couldn't find the local OneDrive for Business folder. "
                f"Please configure the {onedrive_commercial_config_name} setting, "
                f"see: xlwings.org/error."
            )
        local_path = Path(root) / match.group(1)
        if local_path.is_file():
            return str(local_path)
        else:
            raise xlwings.XlwingsError(
                "Couldn't find your local OneDrive for Business file, "
                "see: xlwings.org/error"
            )
    # SharePoint Online & On-Premises (Windows registry)
    url_to_mount = get_url_to_mount()
    for url_namespace, mount_point in url_to_mount.items():
        if url.startswith(url_namespace):
            local_path = Path(mount_point) / url[len(url_namespace) :]
            if local_path.is_file():
                return str(local_path)
            else:
                return search_local_sharepoint_path(
                    url, mount_point, sharepoint_config, sharepoint_config_name
                )
    # SharePoint Online & On-Premises (default top level mapping)
    pattern = re.compile(r"https?://[^/]*/sites/([^/]*)/([^/]*)/(.*)")
    match = pattern.match(url)
    # We're trying to derive the SharePoint root path
    # from the OneDriveCommercial path, if it exists
    root = sharepoint_config or (
        os.getenv("OneDriveCommercial").replace("OneDrive - ", "")
        if os.getenv("OneDriveCommercial")
        else None
    )
    if not root:
        raise xlwings.XlwingsError(
            f"Couldn't find the local SharePoint folder. Please configure the "
            f"{sharepoint_config_name} setting, see: xlwings.org/error."
        )
    if match:
        local_path = Path(root) / f"{match.group(1)} - Documents" / match.group(3)
        if local_path.is_file():
            return str(local_path)
    # SharePoint Online & On-Premises (non-default mapping)
    return search_local_sharepoint_path(
        url, root, sharepoint_config, sharepoint_config_name
    )


def to_pdf(
    obj,
    path=None,
    include=None,
    exclude=None,
    layout=None,
    exclude_start_string=None,
    show=None,
    quality=None,
):
    report_path = fspath(path)
    layout_path = fspath(layout)
    if isinstance(obj, (xlwings.Book, xlwings.Sheet)):
        if report_path is None:
            filename, extension = os.path.splitext(obj.fullname)
            directory, _ = os.path.split(obj.fullname)
            if directory:
                report_path = os.path.join(directory, filename + ".pdf")
            else:
                report_path = filename + ".pdf"
        if (include is not None) and (exclude is not None):
            raise ValueError("You can only use either 'include' or 'exclude'")
        # Hide sheets to exclude them from printing
        if isinstance(include, (str, int)):
            include = [include]
        if isinstance(exclude, (str, int)):
            exclude = [exclude]
        exclude_by_name = [
            sheet.index
            for sheet in obj.sheets
            if sheet.name.startswith(exclude_start_string)
        ]
        visibility = {}
        if include or exclude or exclude_by_name:
            for sheet in obj.sheets:
                visibility[sheet] = sheet.visible
        try:
            if include:
                for sheet in obj.sheets:
                    if (sheet.name in include) or (sheet.index in include):
                        sheet.visible = True
                    else:
                        sheet.visible = False
            if exclude or exclude_by_name:
                exclude = [] if exclude is None else exclude
                for sheet in obj.sheets:
                    if (
                        (sheet.name in exclude)
                        or (sheet.index in exclude)
                        or (sheet.index in exclude_by_name)
                    ):
                        sheet.visible = False
            obj.impl.to_pdf(os.path.realpath(report_path), quality=quality)
        except Exception:
            raise
        finally:
            # Reset visibility
            if include or exclude or exclude_by_name:
                for sheet, tf in visibility.items():
                    sheet.visible = tf
    else:
        if report_path is None:
            if isinstance(obj, xlwings.Chart):
                directory, _ = os.path.split(obj.parent.book.fullname)
                filename = obj.name
            elif isinstance(obj, xlwings.Range):
                directory, _ = os.path.split(obj.sheet.book.fullname)
                filename = (
                    str(obj)
                    .replace("<", "")
                    .replace(">", "")
                    .replace(":", "_")
                    .replace(" ", "")
                )
            else:
                raise ValueError(f"Object of type {type(obj)} are not supported.")
            if directory:
                report_path = os.path.join(directory, filename + ".pdf")
            else:
                report_path = filename + ".pdf"
        obj.impl.to_pdf(os.path.realpath(report_path), quality=quality)

    if layout:
        from .pro.reports.pdf import print_on_layout

        print_on_layout(report_path=report_path, layout_path=layout_path)

    if show:
        if sys.platform.startswith("win"):
            os.startfile(report_path)
        else:
            subprocess.run(["open", report_path])
    return report_path


def get_url_to_mount():
    """Windows stores the mount points in the registry. This helps but still isn't
    foolproof.
    """
    if sys.platform.startswith("win"):
        import winreg
        from winreg import HKEY_CURRENT_USER, KEY_READ

        root = r"SOFTWARE\SyncEngines\Providers\OneDrive"
        url_to_mount = {}
        try:
            with winreg.OpenKey(HKEY_CURRENT_USER, root, 0, KEY_READ) as root_key:
                for i in range(0, winreg.QueryInfoKey(root_key)[0]):
                    subfolder = winreg.EnumKey(root_key, i)
                    with winreg.OpenKey(
                        HKEY_CURRENT_USER, f"{root}\\{subfolder}", 0, KEY_READ
                    ) as key:
                        try:
                            mount_point, _ = winreg.QueryValueEx(key, "MountPoint")
                            url_namespace, _ = winreg.QueryValueEx(key, "URLNamespace")
                            url_to_mount[url_namespace] = mount_point
                        except FileNotFoundError:
                            pass
        except FileNotFoundError:
            pass
        return url_to_mount
    else:
        return {}


def search_local_sharepoint_path(url, root, sharepoint_config, sharepoint_config_name):
    book_name = url.split("/")[-1]
    local_book_paths = []
    for path in Path(root).rglob("[!~$]*.xls*"):
        if path.name.lower() == book_name.lower():
            local_book_paths.append(path)
    if len(local_book_paths) == 1:
        return str(local_book_paths[0])
    elif len(local_book_paths) == 0:
        raise xlwings.XlwingsError(
            "Couldn't find your SharePoint file locally, see: xlwings.org/error"
        )
    else:
        raise xlwings.XlwingsError(
            f"Your SharePoint configuration either requires your workbook name to be "
            f"unique across all synced SharePoint folders or you need to "
            f"{'edit' if sharepoint_config else 'add'} the {sharepoint_config_name} "
            f"setting including one or more folder levels, see: xlwings.org/error."
        )


def excel_update_picture(picture_impl, filename):
    name = picture_impl.name
    left, top = picture_impl.left, picture_impl.top
    width, height = picture_impl.width, picture_impl.height

    picture_impl.delete()

    picture_impl = picture_impl.parent.pictures.add(
        filename,
        link_to_file=False,
        save_with_document=True,
        left=left,
        top=top,
        width=width,
        height=height,
        anchor=None,
    )

    picture_impl.name = name
    return picture_impl
