#         _            _                                   _    _  _
#  __  __| |__      __(_) _ __    __ _  ___         _   _ | |_ (_)| | ___
#  \ \/ /| |\ \ /\ / /| || '_ \  / _` |/ __|       | | | || __|| || |/ __|
#   >  < | | \ V  V / | || | | || (_| |\__ \       | |_| || |_ | || |\__ \
#  /_/\_\|_|  \_/\_/  |_||_| |_| \__, ||___/ _____  \__,_| \__||_||_||___/
#                                |___/      |_____|

__version__ = "26.1.2"

from pathlib import Path
import sys
import math
import base64
import datetime
import functools
import argparse
import zipfile
from lxml import etree
import json
import io
from dataclasses import dataclass

Pythonista = sys.platform == "ios"

try:
    import xlwings
except ImportError:
    xlwings = False

missing = object()


class block:
    """
    block is 2 dimensional data structure with 1 as lowest index (like xlwings range)

    Parameters
    ----------
    number_of_rows : int
        number of rows (dedault 1)

    number_of_columns : int
        number of columns (default 1)

    Returns
    -------
    block
    """

    def __init__(self, number_of_rows=1, number_of_columns=1):
        self.dict = {}
        self.number_of_rows = number_of_rows
        self.number_of_columns = number_of_columns
        self._invalidate_highest_used_cache()

    def __eq__(self, other):
        if isinstance(other, block):
            return self.value == other.value
        return False

    @classmethod
    def from_value(cls, value, column_like=False):
        """
        makes a block from a given value

        Parameters
        ----------
        value : scalar, list of scalars, list of lists of scalars or block
            value to be used in block, possibly expanded to a list of lists of scalars

        column_like : boolean
            if value is a list of scalars, values is interpreted as a column if True, as a row otherwise

        Returns
        -------
        block : block
        """
        if isinstance(value, block):
            value = value.value
        if not isinstance(value, list):
            value = [[value]]
        if not isinstance(value[0], list):
            if column_like:
                value = [[item] for item in value]
            else:
                value = [value]
        bl = cls(len(value), 1)

        for row, row_contents in enumerate(value, 1):
            for column, item in enumerate(row_contents, 1):
                if (item is None) or (item == "") or (isinstance(item, float) and math.isnan(item)):
                    ...  # ignore this item
                else:
                    bl.dict[row, column] = item
                bl._number_of_columns = max(bl.number_of_columns, column)
        return bl

    @classmethod
    def from_range(cls, rng):
        """
        makes a block from a given range

        Parameters
        ----------
        rng : xlwings.Range
            range to be used be used in block

        Returns
        -------
        block : block
        """
        number_of_rows, number_of_columns = rng.shape
        return cls.from_value(rng.value, column_like=(number_of_columns == 1))

    @classmethod
    def from_xlrd_sheet(cls, sheet):
        """
        makes a block from a xlrd sheet

        Parameters
        ----------
        sheet : xlrd sheet
            sheet to be used be used in block

        Returns
        -------
        block : block
        """
        v = [sheet.row_values(row_idx)[0 : sheet.ncols] for row_idx in range(0, sheet.nrows)]
        return cls.from_value(v)

    @classmethod
    def from_openpyxl_sheet(cls, sheet):
        """
        makes a block from an openpyxl sheet

        Parameters
        ----------
        sheet : xlrd sheet
            sheet to be used be used in block

        Returns
        -------
        block : block
        """
        v = [[cell.value for cell in row] for row in sheet.iter_rows()]
        return cls.from_value(v)

    @classmethod
    def from_file(cls, filename):
        """
        makes a block from a file

        Parameters
        ----------
        filename : str
            file to be used be used in block

        Returns
        -------
        block : block
        """
        with open(filename, "r") as f:
            v = [[line if line else missing] for line in f.read().splitlines()]
        return cls.from_value(v)

    @classmethod
    def from_dataframe(cls, df):
        """
        makes a block from a given dataframe

        Parameters
        ----------
        df : pandas dataframe
            dataframe to be used be used in block

        Returns
        -------
        block : block
        """
        v = df.values.tolist()
        return cls.from_value(v)

    def to_openpyxl_sheet(self, sheet):
        """
        appends a block to a given openpyxl sheet

        Parameters
        ----------
        sheet: openpyxl sheet
            sheet to be used be used

        Returns
        -------
        block : block
        """
        for row in self.value:
            sheet.append(row)

    def reshape(self, number_of_rows=missing, number_of_columns=missing):
        """
        makes a new block with given dimensions

        Parameters
        ----------
        number_of_rows : int
            if given, expand or shrink to the given number of rows

        number_of_columns : int
            if given, expand or shrink to the given number of columns

        Returns
        -------
        block : block
        """
        if number_of_rows is missing:
            number_of_rows = self.number_of_rows
        if number_of_columns is missing:
            number_of_columns = self.number_of_columns
        bl = block(number_of_rows=number_of_rows, number_of_columns=number_of_columns)
        for (row, column), value in self.dict.items():
            if row <= number_of_rows and column <= number_of_columns:
                bl[row, column] = value
        return bl

    @property
    def value(self):
        return [[self.dict.get((row, column)) for column in range(1, self.number_of_columns + 1)] for row in range(1, self.number_of_rows + 1)]

    def _invalidate_highest_used_cache(self):
        self._highest_used_row_number = None
        self._highest_used_column_number = None

    def __setitem__(self, row_column, value):
        row, column = row_column
        if row < 1 or row > self.number_of_rows:
            raise IndexError(f"row must be between 1 and {self.number_of_rows}; not {row}")
        if column < 1 or column > self.number_of_columns:
            raise IndexError(f"column must be between 1 and {self.number_of_columns}; not {column}")
        if value is None:
            if (row, column) in self.dict:
                del self.dict[row, column]
                self._invalidate_highest_used_cache()

        else:
            self.dict[row, column] = value
            if self._highest_used_row_number:
                self._highest_used_row_number = max(self._highest_used_row_number, row)
            if self._highest_used_column_number:
                self._highest_used_column_number = max(self._highest_used_column_number, column)

    def __getitem__(self, row_column):
        row, column = row_column
        if row < 1 or row > self.number_of_rows:
            raise IndexError(f"row must be between 1 and {self.number_of_rows} not {row}")
        if column < 1 or column > self.number_of_columns:
            raise IndexError(f"column must be between 1 and {self.number_of_columns} not {column}")
        return self.dict.get((row, column))

    def minimized(self):
        """
        Returns
        -------
        minimized block : block
             uses highest_used_row_number and highest_used_column_number to minimize the block
        """
        return self.reshape(number_of_rows=self.highest_used_row_number, number_of_columns=self.highest_used_column_number)

    @property
    def number_of_rows(self):
        return self._number_of_rows

    @number_of_rows.setter
    def number_of_rows(self, value):
        if value < 1:
            raise ValueError(f"number_of_rows should be >=1; not {value}")
        self._invalidate_highest_used_cache()
        self._number_of_rows = value
        for row, column in list(self.dict):
            if row > self._number_of_rows:
                del self.dict[row, column]

    @property
    def number_of_columns(self):
        return self._number_of_columns

    @number_of_columns.setter
    def number_of_columns(self, value):
        if value < 1:
            raise ValueError(f"number_of_columns should be >=1; not {value}")
        self._invalidate_highest_used_cache()
        self._number_of_columns = value
        for row, column in list(self.dict):
            if column > self._number_of_columns:
                del self.dict[row, column]

    @property
    def highest_used_row_number(self):
        if not self._highest_used_row_number:
            if self.dict:
                self._highest_used_row_number = max(row for (row, column) in self.dict)
            else:
                self._highest_used_row_number = 1
        return self._highest_used_row_number

    @property
    def highest_used_column_number(self):
        if not self._highest_used_column_number:
            if self.dict:
                self._highest_used_column_number = max(column for (row, column) in self.dict)
            else:
                self._highest_used_column_number = 1

        return self._highest_used_column_number

    def __repr__(self):
        return f"block({self.value})"

    def _check_row(self, row, name):
        if row < 1:
            raise ValueError(f"{name}={row} < 1")
        if row > self.number_of_rows:
            raise ValueError(f"{name}={row} > number_of_rows={self.number_of_rows}")

    def _check_column(self, column, name):
        if column < 1:
            raise ValueError(f"{name}={column} < 1")
        if column > self.number_of_columns:
            raise ValueError(f"{name}={column} > number_of_columns={self.number_of_columns}")

    def transposed(self):
        """
        transpose block

        Returns
        -------
        transposed block : block
        """
        bl = block(number_of_rows=self.number_of_columns, number_of_columns=self.number_of_rows)
        for (row, column), value in self.dict.items():
            bl[column, row] = value
        return bl

    def vlookup(self, s, *, row_from=1, row_to=missing, column1=1, column2=missing, default=missing):
        """
        searches in column1 for row between row_from and row_to for s and returns the value found at (that row, column2)

        Parameters
        ----------
        s : any
            value to seach for

        row_from : int
             row to start search (default 1)

             should be between 1 and number_of_rows

        row_to : int
             row to end search (default number_of_rows)

             should be between 1 and number_of_rows

        column1 : int
             column to search in (default 1)

             should be between 1 and number_of_columns

        column2 : int
             column to return looked up value from (default column1 + 1)

             should be between 1 and number_of_columns

        default : any
             if s is not found, returns the default.

             if omitted, a ValueError exception will be raised in that case

        Returns
        -------
        block[found row number, column2] : any
        """
        if column2 is missing:
            column2 = column1 + 1
        self._check_column(column2, "column2")
        row = self.lookup_row(s, row_from=row_from, row_to=row_to, column1=column1, default=-1)
        if row == -1:
            if default is missing:
                raise ValueError(f"{s} not found]")
            else:
                return default
        else:
            return self[row, column2]

    def lookup_row(self, s, *, row_from=1, row_to=missing, column1=1, default=missing):
        """
        searches in column1 for row between row_from and row_to for s and returns that row number

        Parameters
        ----------
        s : any
            value to seach for

        row_from : int
             row to start search (default 1)

             should be between 1 and number_of_rows

        row_to : int
             row to end search (default number_of_rows)

             should be between 1 and number_of_rows

        column1 : int
             column to search in (default 1)

             should be between 1 and number_of_columns

        column2 : int
             column to return looked up value from (default column1 + 1)

        default : any
             if s is not found, returns the default.

             if omitted, a ValueError exception will be raised

        default : any
             if s is not found, returns the default.

             if omitted, a ValueError exception will be raised in that case


        Returns
        -------
        row number where block[row nunber, column1] == s : int
        """
        if row_to is missing:
            row_to = self.highest_used_row_number
        self._check_row(row_from, "row_from")
        self._check_row(row_to, "row_to")
        self._check_column(column1, "column1")

        for row in range(row_from, row_to + 1):
            if self[row, column1] == s:
                return row
        if default is missing:
            raise ValueError(f"{s} not found")
        else:
            return default

    def hlookup(self, s, *, column_from=1, column_to=missing, row1=1, row2=missing, default=missing):
        """
        searches in row1 for column between column_from and column_to for s and returns the value found at (that column, row2)

        Parameters
        ----------
        s : any
            value to seach for

        column_from : int
             column to start search (default 1)

             should be between 1 and number_of_columns

        column_to : int
             column to end search (default number_of_columns)

             should be between 1 and number_of_columns

        row1 : int
             row to search in (default 1)

             should be between 1 and number_of_rows

        row2 : int
             row to return looked up value from (default row1 + 1)

             should be between 1 and number_of_rows

        default : any
             if s is not found, returns the default.

             if omitted, a ValueError exception will be raised in that case

        Returns
        -------
        block[row, found column, row2] : any
        """
        if row2 is missing:
            row2 = row1 + 1
        self._check_row(row2, "row2")
        column = self.lookup_column(s, column_from=column_from, column_to=column_to, row1=row1, default=-1)
        if column == -1:
            if default is missing:
                raise ValueError(f"{s} not found")
            else:
                return default
        else:
            return self[row2, column]

    def lookup_column(self, s, *, column_from=1, column_to=missing, row1=1, default=missing):
        """
        searches in row1 for column between column_from and column_to for s and returns that column number

        Parameters
        ----------
        s : any
            value to seach for

        column_from : int
             column to start search (default 1)

             should be between 1 and number_of_columns

        column_to : int
             column to end search (default number_of_columns)

             should be between 1 and number_of_columns

        row1 : int
             row to search in (default 1)

             should be between 1 and number_of_rows

        row2 : int
             row to return looked up value from (default row1 + 1)

        default : any
             if s is not found, returns the default.

             if omitted, a ValueError exception will be raised in that case

        Returns
        -------
        column number where block[row1, column number] == s : int
        """
        if column_to is missing:
            column_to = self.highest_used_column_number
        self._check_column(column_from, "column_from")
        self._check_column(column_to, "column_to")
        self._check_row(row1, "row1")

        for column in range(column_from, column_to + 1):
            if self[row1, column] == s:
                return column
        if default is missing:
            raise ValueError(f"{s} not found")
        else:
            return default

    def lookup(self, s, *, row_from=1, row_to=missing, column1=1, column2=missing, default=missing):
        """
        searches in column1 for row between row_from and row_to for s and returns the value found at (that row, column2)

        Parameters
        ----------
        s : any
            value to seach for

        row_from : int
             row to start search (default 1)

             should be between 1 and number_of_rows

        row_to : int
             row to end search (default number_of_rows)

             should be between 1 and number_of_rows

        column1 : int
             column to search in (default 1)

             should be between 1 and number_of_columns

        column2 : int
             column to return looked up value from (default column1 + 1)

             should be between 1 and number_of_columns

        default : any
             if s is not found, returns the default.

             if omitted, a ValueError exception will be raised in that case

        Returns
        -------
        block[found row number, column2] : any

        Note
        ----
        This is exactly the same as vlookup.
        """
        return self.vlookup(s, row_from=row_from, row_to=row_to, column1=column1, column2=column2, default=default)

    def decode_to_files(self):
        """
        decode the block with encoded file(s) to individual pyoidide file(s)

        Returns
        -------
        count : int
            number of files decoded

        Note
        ----
        if the block does not contain an encode file, the method just returns 0
        """
        count = 0
        for column in range(1, self.number_of_columns + 1):
            row = 1
            bl = self.minimized()
            while row <= self.number_of_rows:
                if self[row, column] and self[row, column].startswith("<file=") and self[row, column].endswith(">"):
                    filename = self[row, column][6:-1]
                    collect = []
                    row += 1
                    while bl[row, column] != "</file>":
                        if bl[row, column]:
                            collect.append(bl[row, column])
                        row += 1
                    decoded = base64.b64decode("".join(collect))
                    open(filename, "wb").write(decoded)
                    count += 1
                row += 1
        return count

    @classmethod
    def encode_file(cls, file):
        """
        make a block with the given pyodide file encoded

        Parameters
        ----------
        file : file name (str)
            file to be encoded

        Returns
        -------
        block with encoded file : block (minimized)
        """

        bl = cls(number_of_rows=100000, number_of_columns=1)

        n = 5000  # block size
        row = 1
        bl[row, 1] = f"<file={file}>"
        row += 1
        b64 = base64.b64encode(open(file, "rb").read()).decode("utf-8")
        while b64:
            b64_n = b64[:n]
            bl[row, 1] = b64_n
            row += 1
            b64 = b64[n:]
        bl[row, 1] = f"</file>"
        row += 1
        return bl.minimized()


class Capture:
    """
    specifies how to capture stdout

    Parameters
    ----------
    enabled : bool
        if True (default), all stdout output is captured

        if False, stdout output is printed

    include_print : bool
        if False (default), nothing will be printed if enabled is True

        if True, output will be printed (and captured if enabled is True)

    Note
    ----
    Use this like ::

        capture = xwu.Capture()
    """

    _instance = None

    def __new__(cls, *args, **kwargs):
        # singleton
        if cls._instance is None:
            cls._instance = super(Capture, cls).__new__(cls)
        return cls._instance

    def __init__(self, enabled=missing, include_print=missing):
        if hasattr(self, "stdout"):
            if enabled is not missing:
                self.enabled = enabled
            if include_print is not missing:
                self.include_print = include_print
            return
        self.stdout = sys.stdout
        self._buffer = []
        self.enabled = True if enabled is missing else enabled
        self.include_print = False if include_print is missing else include_print

    def __call__(self, enabled=missing, include_print=missing):
        return self.__class__(enabled, include_print)

    def __enter__(self):
        self.enabled = True

    def __exit__(self, exc_type, exc_value, tb):
        self.enabled = False

    def write(self, data):
        self._buffer.append(data)
        if self._include_print:
            self.stdout.write(data)

    def flush(self):
        if self._include_print:
            self.stdout.flush()
        self._buffer.append("\n")

    @property
    def enabled(self):
        return sys.out == self

    @enabled.setter
    def enabled(self, value):
        if value:
            sys.stdout = self
        else:
            sys.stdout = self.stdout

    @property
    def value(self):
        result = self.value_keep
        self.clear()
        return result

    @property
    def value_keep(self):
        result = [[line] for line in self.str_keep.splitlines()]
        return result

    @property
    def str(self):
        result = self.str_keep
        self._buffer.clear()
        return result

    @property
    def str_keep(self):
        result = "".join(self._buffer)
        return result

    def clear(self):
        self._buffer.clear()

    @property
    def include_print(self):
        return self._include_print

    @include_print.setter
    def include_print(self, value):
        self._include_print = value


def trigger_macro(sheet):
    """
    triggers the macro on sheet

    Parameters
    ----------
    sheet : sheet
        sheet to use

    """

    sheet["A1"].value = "=NOW()"


def timer(func):
    """
    this decorator should be placed after the @xw.script decorator

    it will show the name, entry time, exit time and the duration, like
    Done MyScript  11:51:13.24 - 11:51:20.28 (7.04s)

    Note: Use async_timer for async function
    """

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        now0 = datetime.datetime.now()
        result = func(*args, **kwargs)
        now1 = datetime.datetime.now()
        diff = (now1 - now0).total_seconds()
        print(f"Done {func.__name__}  {now0:%H:%M:%S.}{int(now0.microsecond / 10000):02d} - {now1:%H:%M:%S.}{int(now1.microsecond / 10000):02d} ({diff:.2f}s)")

        return result

    return wrapper


def async_timer(func):
    """
    this decorator should be placed after the @xw.script decorator

    it will show the name, entry time, exit time and the duration, like
    Done MyScript  11:51:13.24 - 11:51:20.28 (7.04s)

    this decorator should be used instead of timer if the to be decorated function is async,
    most likely when await book.sync() is used to flush output.
    """

    @functools.wraps(func)
    async def wrapper(*args, **kwargs):
        now0 = datetime.datetime.now()
        result = await func(*args, **kwargs)
        now1 = datetime.datetime.now()
        diff = (now1 - now0).total_seconds()
        print(f"Done {func.__name__}  {now0:%H:%M:%S.}{int(now0.microsecond / 10000):02d} - {now1:%H:%M:%S.}{int(now1.microsecond / 10000):02d} ({diff:.2f}s)")
        return result

    return wrapper


def undecorated(func, max_number=1000000):
    """
    returns a function, with all decorators removed.
    This is very handy when calling a @xw.script decorated function from another function

    Parameters
    ----------
    func : callable
         function to undecorate

    max_number : int
         maximum number of decorators to remove

         default : 1000000

    Returns
    -------
    undecorated function : callable

    Note
    ----
    Only undecorates decoratos which use a __wrapped__ attribute, most likely via @functools.wraps, which is indeed the case for xw.script, and xwu.timer.
    """
    for _ in range(max_number):
        if hasattr(func, "__wrapped__"):
            func = func.__wrapped__
        else:
            break

    return func


@dataclass
class Option:
    full_name: str
    short_name: str
    help_text: str
    prop_name: str


options = [
    Option("mainfile", "m", "Path to main file to use", "main.py"),
    Option("requirementsfile", "r", "Path to requirements file to use", "requirements.txt"),
    Option("pyprojecttomlfile", "p", "Path to pyproject file to use", "pyproject.toml"),
    Option("settingsfile", "s", "Path to settings file to use,", "xlwingsSettingsWorkbook"),
]


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="xlwings_utils.py")
    subparsers = parser.add_subparsers(dest="command", required=True)
    # extract
    p_extract = subparsers.add_parser("extract", help="Extract data from an excel file")
    for option in options:
        p_extract.add_argument(f"--{option.full_name}", f"-{option.short_name}", help=option.help_text, default=None)
    p_extract.add_argument("excel_in", help="Input excel file")

    # replace
    p_replace = subparsers.add_parser("replace", help="Replace data into an excel file")
    for option in options:
        p_replace.add_argument(f"--{option.full_name}", f"-{option.short_name}", help=option.help_text, default=None)
    p_replace.add_argument("excel_in", help="Input excel file")
    p_replace.add_argument("excel_out", help="Output excel file")

    # info
    p_info = subparsers.add_parser("info", help="Show info about an excel file")
    p_info.add_argument("excel_in", help="Input excel file")

    return parser


def process(args):
    for option in options:
        option.filename = getattr(args, option.full_name, None)

    if args.command in ("replace", "extract"):
        if all(option.filename is None for option in options):
            full_names = ", ".join(option.full_name for option in options)
            print(f"no {full_names} specified")
            sys.exit(0)
    if args.command == "replace" and args.excel_out == "*":
        args.excel_out = args.excel_in

    found = set()
    if args.command == "replace":
        zip_buffer = io.BytesIO()
        zf = zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED)
    xlwings_embedded = False
    with zipfile.ZipFile(args.excel_in, "r") as zin:
        for item in zin.infolist():
            item_contents = zin.read(item.filename)
            if item.filename == "xl/webextensions/webextension1.xml":
                root = etree.fromstring(item_contents)
                ns_map = "http://schemas.microsoft.com/office/webextensions/webextension/2010/11"
                ns = {"we": ns_map}

                for prop in root.findall(".//we:property", ns):
                    xlwings_embedded = True
                    prop_name = prop.get("name")
                    found.add(prop_name)
                    value = prop.get("value", "")
                    contents = json.loads(value).replace("\r", "")

                    match args.command:
                        case "info":
                            print(prop_name)
                            for line in contents.splitlines():
                                print(f"   {line}")
                        case "extract":
                            for option in options:
                                if option.prop_name == prop_name:
                                    if option.filename is not None:
                                        with open(option.filename, "w") as f:
                                            f.write(contents)
                                        print(f"written {option.filename}")
                                        option.filename = None  # indicates that it has been extracted
                        case "replace":
                            for option in options:
                                if option.prop_name == prop_name:
                                    if option.filename is not None:
                                        with open(option.filename, "r") as f:
                                            lines = f.read().splitlines()
                                            new_contents = json.dumps("\r\n".join(lines))
                                            prop.set("value", new_contents)
                                        option.filename = None  # indicates that it has been replaced

                if args.command == "extract":
                    for option in options:
                        if option.filename is not None:
                            print(f"{option.filename} not written, because {option.prop_name} is not in {args.excel_in}")

                if args.command == "replace":
                    for option in options:
                        if option.filename is not None:
                            with open(option.filename, "r") as f:
                                lines = f.read().splitlines()
                                contents = json.dumps("\r\n".join(lines))
                            props = root.find(".//we:properties", ns)
                            etree.SubElement(props, f"{{{ns['we']}}}property", name=option.prop_name, value=contents)

                    item_contents = etree.tostring(root, encoding="UTF-8", xml_declaration=True, standalone=True, pretty_print=False)

            if args.command == "replace":
                zf.writestr(item, item_contents)

    if not xlwings_embedded:
        print(f"no action, because xlwings is not embedded in {args.excel_in}")
        return

    if args.command == "replace":
        zf.close()
        zip_buffer.seek(0)
        with open(args.excel_out, "wb") as f:
            f.write(zip_buffer.read())
        print(f"written {args.excel_out}")


def main(argv=None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    process(args)
    print("done")


if __name__ == "__main__":
    main()
