"""
Required Notice: Copyright (C) Zoomer Analytics GmbH.

xlwings PRO is dual-licensed under one of the following licenses:

* PolyForm Noncommercial License 1.0.0 (for noncommercial use):
  https://polyformproject.org/licenses/noncommercial/1.0.0
* xlwings PRO License (for commercial use):
  https://github.com/xlwings/xlwings/blob/main/LICENSE_PRO.txt

Commercial licenses can be purchased at https://www.xlwings.org
"""

import datetime as dt
import numbers
from pathlib import Path

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

from xlwings import xlwingslib

from .. import NoSuchObjectError, base_classes, utils

MAX_ROWS = 1_048_576
MAX_COLUMNS = 16_384


def _clean_value_data_element(value, datetime_builder, empty_as, number_builder):
    if value is None:
        return empty_as
    elif isinstance(value, dt.datetime) and datetime_builder is not dt.datetime:
        value = datetime_builder(
            month=value.month,
            day=value.day,
            year=value.year,
            hour=value.hour,
            minute=value.minute,
            second=value.second,
            microsecond=value.microsecond,
            tzinfo=None,
        )
    elif number_builder is not None and type(value) == float:
        value = number_builder(value)
    return value


class Engine:
    def __init__(self):
        self.apps = Apps()

    @staticmethod
    def clean_value_data(data, datetime_builder, empty_as, number_builder, err_to_str):
        # err_to_str is handled in raw_values for efficiency
        if empty_as or number_builder or datetime_builder is not dt.datetime:
            return [
                [
                    _clean_value_data_element(
                        c, datetime_builder, empty_as, number_builder
                    )
                    for c in row
                ]
                for row in data
            ]
        else:
            return data

    @staticmethod
    def prepare_xl_data_element(x):
        return x

    @property
    def name(self):
        return "calamine"

    @property
    def type(self):
        return "reader"


class Apps(base_classes.Apps):
    def __init__(self):
        self._apps = [App(self)]

    def __iter__(self):
        return iter(self._apps)

    def __len__(self):
        return len(self._apps)

    def __getitem__(self, index):
        return self._apps[index]

    def add(self, **kwargs):
        self._apps.insert(0, App(self, **kwargs))
        return self._apps[0]


class App(base_classes.App):

    _next_pid = -1

    def __init__(self, apps, add_book=True, **kwargs):
        self.apps = apps
        self._pid = App._next_pid
        App._next_pid -= 1
        self._books = Books(self)
        if add_book:
            self._books.add()

    def kill(self):
        self.apps._apps.remove(self)
        self.apps = None

    @property
    def engine(self):
        return engine

    @property
    def books(self):
        return self._books

    @property
    def pid(self):
        return self._pid

    @property
    def visible(self):
        return True

    def activate(self, steal_focus=None):
        pass


class Books(base_classes.Books):
    def __init__(self, app):
        self.app = app
        self.books = []
        self._active = None

    @property
    def active(self):
        return self._active

    def open(self, filename):
        filename = str(Path(filename).resolve())
        sheet_names = xlwingslib.get_sheet_names(filename)
        names = []
        for name, ref in xlwingslib.get_defined_names(filename):
            if ref.split("!")[0].strip("'") in sheet_names:
                names.append(
                    {
                        "name": name,
                        "sheet_index": sheet_names.index(ref.split("!")[0].strip("'")),
                        "address": ref.split("!")[1],
                        "book_scope": True,  # TODO: not provided by calamine
                    }
                )
        book = Book(
            api={
                "sheet_names": sheet_names,
                "names": names,
            },
            books=self,
            path=filename,
        )
        self.books.append(book)
        self._active = book
        return book

    def add(self):
        book = Book(api={"sheet_names": ["Sheet1"]}, books=self, path="dummy")
        self.books.append(book)
        self._active = book
        return book

    def _try_find_book_by_name(self, name):
        for book in self.books:
            if book.name == name or book.fullname == name:
                return book
        return None

    def __len__(self):
        return len(self.books)

    def __iter__(self):
        for book in self.books:
            yield book

    def __call__(self, name_or_index):
        if isinstance(name_or_index, numbers.Number):
            return self.books[name_or_index - 1]
        else:
            book = self._try_find_book_by_name(name_or_index)
            if book is None:
                raise KeyError(name_or_index)
            return book


class Book(base_classes.Book):
    def __init__(self, api, books, path):
        self._api = api
        self.books = books
        self.path = path

    @property
    def api(self):
        return self._api

    @property
    def name(self):
        return Path(self.fullname).name

    @property
    def fullname(self):
        return self.path

    @property
    def names(self):
        return Names(parent=self, api=self.api["names"])

    @property
    def sheets(self):
        return Sheets(book=self)

    @property
    def app(self):
        return self.books.app

    def close(self):
        assert self.api is not None, "Seems this book was already closed."
        self.books.books.remove(self)
        self.books = None
        self._api = None

    def activate(self):
        pass


class Sheets(base_classes.Sheets):
    def __init__(self, book):
        self.book = book

    @property
    def api(self):
        return None

    def __call__(self, name_or_index):
        if isinstance(name_or_index, str):
            sheet_names = self.book.api["sheet_names"]
            if name_or_index not in sheet_names:
                raise NoSuchObjectError(f"Sheet {name_or_index} doesn't exist.")
            else:
                ix = self.book.api["sheet_names"].index(name_or_index) + 1
        else:
            ix = name_or_index

        return Sheet(book=self.book, sheet_index=ix)

    def __len__(self):
        return len(self.book.api["sheet_names"])

    def __iter__(self):
        for ix, sheet in enumerate(self.book.api["sheet_names"]):
            yield Sheet(book=self.book, sheet_index=ix + 1)


class Sheet(base_classes.Sheet):
    def __init__(self, book, sheet_index):
        self._api = {}  # used by e.g., Range.end()
        self._book = book
        self.sheet_index = sheet_index

    @property
    def api(self):
        return self._api

    @property
    def name(self):
        return self.book.api["sheet_names"][self.index - 1]

    @property
    def index(self):
        return self.sheet_index

    @property
    def book(self):
        return self._book

    def range(self, arg1, arg2=None):
        return Range(sheet=self, book=self.book, arg1=arg1, arg2=arg2)

    @property
    def cells(self):
        return Range(
            sheet=self,
            book=self.book,
            arg1=(1, 1),
            arg2=(MAX_ROWS, MAX_COLUMNS),
        )


class Range(base_classes.Range):
    def __init__(self, sheet, book, arg1, arg2=None):
        self.sheet = sheet
        self.book = book
        self.options = None  # Assigned by main.Range to keep API of sheet.range clean

        # Range
        if isinstance(arg1, Range) and isinstance(arg2, Range):
            cell1 = arg1.coords[1], arg1.coords[2]
            cell2 = arg2.coords[1], arg2.coords[2]
            arg1 = min(cell1[0], cell2[0]), min(cell1[1], cell2[1])
            arg2 = max(cell1[0], cell2[0]), max(cell1[1], cell2[1])
        # A1 notation
        if isinstance(arg1, str):
            # A1 notation
            tuple1, tuple2 = utils.a1_to_tuples(arg1)
            if not tuple1:
                # Named range
                for api_name in sheet.book.api["names"]:
                    if (
                        api_name["name"].split("!")[-1] == arg1
                        and api_name["sheet_index"] == sheet.index - 1
                    ):
                        tuple1, tuple2 = utils.a1_to_tuples(api_name["address"])
                if not tuple1:
                    raise NoSuchObjectError(
                        f"The address/named range '{arg1}' doesn't exist."
                    )
            arg1, arg2 = tuple1, tuple2

        # Coordinates
        if len(arg1) == 4:
            row, col, nrows, ncols = arg1
            arg1 = (row, col)
            if nrows > 1 or ncols > 1:
                arg2 = (row + nrows - 1, col + ncols - 1)

        self.arg1 = arg1  # 1-based tuple
        self.arg2 = arg2  # 1-based tuple

    @property
    def api(self):
        return self.raw_value

    @property
    def coords(self):
        return self.sheet.name, self.row, self.column, len(self.api), len(self.api[0])

    @property
    def row(self):
        return self.arg1[0]

    @property
    def column(self):
        return self.arg1[1]

    @property
    def shape(self):
        if self.arg2:
            return self.arg2[0] - self.arg1[0] + 1, self.arg2[1] - self.arg1[1] + 1
        else:
            return 1, 1

    @property
    def raw_value(self):
        err_to_str = self.options.get("err_to_str", False)
        if self.arg2 is None:
            self.arg2 = self.arg1
        if self.arg2[0] == MAX_ROWS and self.arg2[1] == MAX_COLUMNS:
            # Whole sheet via sheet.cells
            if not self.sheet.api.get(f"values_err_to_str_{err_to_str}"):
                values = xlwingslib.get_sheet_values(
                    self.book.fullname, self.sheet.index - 1, err_to_str
                )
                self.sheet._api[f"values_err_to_str_{err_to_str}"] = values
                return values
        else:
            # Specific range
            return xlwingslib.get_range_values(
                self.book.fullname,
                self.sheet.index - 1,
                (self.arg1[0] - 1, self.arg1[1] - 1),
                (self.arg2[0] - 1, self.arg2[1] - 1),
                err_to_str,
            )

    @property
    def address(self):
        nrows, ncols = self.shape
        address = f"${utils.col_name(self.column)}${self.row}"
        if nrows == 1 and ncols == 1:
            return address
        else:
            return (
                f"{address}"
                f":${utils.col_name(self.column + ncols - 1)}${self.row + nrows - 1}"
            )

    @property
    def has_array(self):
        # Not supported, but since this is only used for legacy CSE arrays, probably
        # not much of an issue. Here as there's currently a dependency in expansion.py.
        return None

    def end(self, direction):
        err_to_str = self.options.get("err_to_str", False)
        if not self.sheet.api.get(f"values_err_to_str_{err_to_str}"):
            values = xlwingslib.get_sheet_values(
                self.book.fullname, self.sheet.index - 1, err_to_str
            )
            self.sheet._api[f"values_err_to_str_{err_to_str}"] = values
        else:
            values = self.sheet.api[f"values_err_to_str_{err_to_str}"]
        if direction == "down":
            i = 1
            while True:
                try:
                    if values[self.row - 1 + i][self.column - 1]:
                        i += 1
                    else:
                        break
                except IndexError:
                    break  # outside used range
            nrows = i - 1
            return self.sheet.range((self.row + nrows, self.column))
        if direction == "up":
            i = -1
            while True:
                row_ix = self.row - 1 + i
                if row_ix >= 0 and values[row_ix][self.column - 1]:
                    i -= 1
                else:
                    break
            nrows = i + 1
            return self.sheet.range((self.row + nrows, self.column))
        if direction == "right":
            i = 1
            while True:
                try:
                    if values[self.row - 1][self.column - 1 + i]:
                        i += 1
                    else:
                        break
                except IndexError:
                    break  # outside used range
            ncols = i - 1
            return self.sheet.range((self.row, self.column + ncols))
        if direction == "left":
            i = -1
            while True:
                col_ix = self.column - 1 + i
                if col_ix >= 0 and values[self.row - 1][col_ix]:
                    i -= 1
                else:
                    break
            ncols = i + 1
            return self.sheet.range((self.row, self.column + ncols))

    def __len__(self):
        nrows, ncols = self.shape
        return nrows * ncols

    def __call__(self, arg1, arg2=None):
        if arg2 is None:
            col = (arg1 - 1) % self.shape[1]
            row = int((arg1 - 1 - col) / self.shape[1])
            return self(row + 1, col + 1)
        else:
            return Range(
                sheet=self.sheet,
                book=self.book,
                arg1=(self.row + arg1 - 1, self.column + arg2 - 1),
            )


class Name(base_classes.Name):
    def __init__(self, parent, api):
        self.parent = parent  # only implemented for Book, not Sheet
        self.api = api

    @property
    def name(self):
        return self.api["name"]

    @property
    def refers_to(self):
        sheet_name = self.parent.sheets(self.api["sheet_index"] + 1).name
        sheet_name = f"'{sheet_name}'" if " " in sheet_name else sheet_name
        return f"={sheet_name}!{self.api['address']}"

    @property
    def refers_to_range(self):
        return self.parent.sheets(self.api["sheet_index"] + 1).range(
            self.api["address"]
        )


class Names(base_classes.Names):
    def __init__(self, parent, api):
        self.parent = parent  # only implemented for Book, not Sheet
        self.api = api

    def __call__(self, name_or_index):
        if isinstance(name_or_index, numbers.Number):
            name_or_index -= 1
            if name_or_index > len(self):
                raise KeyError(name_or_index)
            else:
                return Name(self.parent, api=self.api[name_or_index])
        else:
            for ix, i in enumerate(self.api):
                if i["name"] == name_or_index:
                    return Name(self.parent, api=self.api[ix])
            raise KeyError(name_or_index)

    def contains(self, name_or_index):
        if isinstance(name_or_index, numbers.Number):
            return 1 <= name_or_index <= len(self)
        else:
            for i in self.api:
                if i["name"] == name_or_index:
                    return True
            return False

    def __len__(self):
        return len(self.api)


engine = Engine()
