import operator

from .api import (
    NSArray,
    NSDictionary,
    NSMutableArray,
    NSMutableDictionary,
    NSString,
    ObjCInstance,
    for_objcclass,
    ns_from_py,
    py_from_ns,
)
from .runtime import objc_id, send_message
from .types import NSNotFound, NSRange, NSUInteger, unichar

# All NSComparisonResult values.
NSOrderedAscending = -1
NSOrderedSame = 0
NSOrderedDescending = 1


# Some useful NSStringCompareOptions.
NSLiteralSearch = 2
NSBackwardsSearch = 4


@for_objcclass(NSString)
class ObjCStrInstance(ObjCInstance):
    """Provides Pythonic operations on NSString objects that mimic those of Python's
    str.

    Note that str objects consist of Unicode code points, whereas NSString objects
    consist of UTF-16 code units. These are not equivalent for code points greater than
    U+FFFF. For performance and simplicity, ObjCStrInstance objects behave as sequences
    of UTF-16 code units, like NSString. (Individual UTF-16 code units are represented
    as Python str objects of length 1.) If you need to access or iterate over code
    points instead of UTF-16 code units, use str(nsstring) to convert the NSString to a
    Python str first.
    """

    def __str__(self):
        return self.UTF8String.decode("utf-8")

    def __fspath__(self):
        return self.__str__()

    def __eq__(self, other):
        if isinstance(other, str):
            return self.isEqualToString(ns_from_py(other))
        elif isinstance(other, NSString):
            return self.isEqualToString(other)
        else:
            return super().__eq__(other)

    def __ne__(self, other):
        return not self.__eq__(other)

    # Note: We cannot define a __hash__ for NSString objects; doing so would violate
    # the Python convention that mutable objects should not be hashable. Although we
    # could disallow hashing for NSMutableString objects, this would make some
    # immutable strings unhashable as well, because immutable strings can have a
    # runtime class that is a subclass of NSMutableString. This is not just a
    # theoretical possibility - for example, on OS X 10.11,
    # isinstance(NSString.string(), NSMutableString) is true.

    def _compare(self, other, want):
        """Helper method used to implement the comparison operators.

        If other is a str or NSString, it is compared to self, and True or False is
        returned depending on whether the result is one of the wanted values. If other
        is not a string, NotImplemented is returned.
        """

        if isinstance(other, str):
            ns_other = ns_from_py(other)
        elif isinstance(other, NSString):
            ns_other = other
        else:
            return NotImplemented

        return self.compare(ns_other, options=NSLiteralSearch) in want

    def __lt__(self, other):
        return self._compare(other, {NSOrderedAscending})

    def __le__(self, other):
        return self._compare(other, {NSOrderedAscending, NSOrderedSame})

    def __ge__(self, other):
        return self._compare(other, {NSOrderedSame, NSOrderedDescending})

    def __gt__(self, other):
        return self._compare(other, {NSOrderedDescending})

    def __contains__(self, value):
        if not isinstance(value, (str, NSString)):
            raise TypeError(
                "'in <NSString>' requires str or NSString as left operand, "
                f"not {type(value).__module__}.{type(value).__qualname__}"
            )

        return self.find(value) != -1

    def __len__(self):
        return self.length

    def __getitem__(self, key):
        if isinstance(key, slice):
            start, stop, step = key.indices(len(self))

            if step == 1:
                return self.substringWithRange(NSRange(start, stop - start))
            else:
                rng = range(start, stop, step)
                chars = (unichar * len(rng))()
                for chars_i, self_i in enumerate(rng):
                    chars[chars_i] = ord(self[self_i])
                return NSString.stringWithCharacters(chars, length=len(chars))
        else:
            index = (len(self) + key) if key < 0 else key

            if index not in range(len(self)):
                raise IndexError(f"{type(self).__name__} index out of range")

            return chr(self.characterAtIndex(index))

    def __add__(self, other):
        if isinstance(other, (str, NSString)):
            return self.stringByAppendingString(other)
        else:
            return NotImplemented

    def __radd__(self, other):
        if isinstance(other, (str, NSString)):
            return ns_from_py(other).stringByAppendingString(self)
        else:
            return NotImplemented

    def __mul__(self, other):
        try:
            count = operator.index(other)
        except AttributeError:
            return NotImplemented

        if count <= 0:
            return ns_from_py("")
        else:
            # https://stackoverflow.com/a/4608137
            return self.stringByPaddingToLength(
                count * len(self),
                withString=self,
                startingAtIndex=0,
            )

    def __rmul__(self, other):
        return self.__mul__(other)

    def _find(self, sub, start=None, end=None, *, reverse):
        if not isinstance(sub, (str, NSString)):
            raise TypeError(
                f"must be str or NSString, not "
                f"{type(sub).__module__}.{type(sub).__qualname__}"
            )

        start, end, _ = slice(start, end).indices(len(self))

        if not sub:
            # Special case: Python considers the empty string to be contained
            # in every string, at the earliest position searched. NSString
            # considers the empty string to *not* be contained in any string.
            # This difference is handled here.
            return end if reverse else start

        options = NSLiteralSearch
        if reverse:
            options |= NSBackwardsSearch
        found_range = self.rangeOfString(
            sub, options=options, range=NSRange(start, end - start)
        )
        if found_range.location == NSNotFound:
            return -1
        else:
            return found_range.location

    def _index(self, sub, start=None, end=None, *, reverse):
        found = self._find(sub, start, end, reverse=reverse)
        if found == -1:
            raise ValueError("substring not found")
        else:
            return found

    def find(self, sub, start=None, end=None):
        return self._find(sub, start=start, end=end, reverse=False)

    def index(self, sub, start=None, end=None):
        return self._index(sub, start=start, end=end, reverse=False)

    def rfind(self, sub, start=None, end=None):
        return self._find(sub, start=start, end=end, reverse=True)

    def rindex(self, sub, start=None, end=None):
        return self._index(sub, start=start, end=end, reverse=True)

    # A fallback method; get the locally defined attribute if it exists;
    # otherwise, get the attribute from the Python-converted version
    # of the string
    def __getattr__(self, attr):
        try:
            return super().__getattr__(attr)
        except AttributeError:
            return getattr(self.__str__(), attr)


@for_objcclass(NSArray)
class ObjCListInstance(ObjCInstance):
    def __getitem__(self, item):
        if isinstance(item, slice):
            start, stop, step = item.indices(len(self))
            if step == 1:
                return self.subarrayWithRange(NSRange(start, stop - start))
            else:
                return ns_from_py(
                    [self.objectAtIndex(x) for x in range(start, stop, step)]
                )
        else:
            index = (len(self) + item) if item < 0 else item

            if index not in range(len(self)):
                raise IndexError(f"{type(self).__name__} index out of range")

            return self.objectAtIndex(index)

    def __len__(self):
        return send_message(self.ptr, "count", restype=NSUInteger, argtypes=[])

    def __iter__(self):
        for i in range(len(self)):
            yield self.objectAtIndex(i)

    def __contains__(self, item):
        return self.containsObject_(item)

    def __eq__(self, other):
        return list(self) == other

    def __ne__(self, other):
        return not self.__eq__(other)

    def index(self, value):
        idx = self.indexOfObject_(value)
        if idx == NSNotFound:
            raise ValueError(f"{value!r} is not in list")
        return idx

    def count(self, value):
        return len([x for x in self if x == value])

    def copy(self):
        return ObjCInstance(send_message(self, "copy", restype=objc_id, argtypes=[]))


@for_objcclass(NSMutableArray)
class ObjCMutableListInstance(ObjCListInstance):
    def __setitem__(self, item, value):
        if isinstance(item, slice):
            arr = ns_from_py(value)
            if not isinstance(arr, NSArray):
                raise TypeError(
                    f"{type(value).__module__}.{type(value).__qualname__} "
                    "is not convertible to NSArray"
                )

            start, stop, step = item.indices(len(self))
            if step == 1:
                self.replaceObjectsInRange(
                    NSRange(start, stop - start), withObjectsFromArray=arr
                )
            else:
                indices = range(start, stop, step)
                if len(arr) != len(indices):
                    raise ValueError(
                        f"attempt to assign sequence of size {len(value)} "
                        f"to extended slice of size {len(indices)}"
                    )

                for idx, obj in zip(indices, arr):
                    self.replaceObjectAtIndex(idx, withObject=obj)
        else:
            index = (len(self) + item) if item < 0 else item

            if index not in range(len(self)):
                raise IndexError(f"{type(self).__name__} assignment index out of range")

            self.replaceObjectAtIndex(index, withObject=value)

    def __delitem__(self, item):
        if isinstance(item, slice):
            start, stop, step = item.indices(len(self))
            if step == 1:
                self.removeObjectsInRange(NSRange(start, stop - start))
            else:
                for idx in sorted(range(start, stop, step), reverse=True):
                    self.removeObjectAtIndex(idx)
        else:
            index = (len(self) + item) if item < 0 else item

            if index not in range(len(self)):
                raise IndexError(f"{type(self).__name__} assignment index out of range")

            self.removeObjectAtIndex_(index)

    def copy(self):
        return self.mutableCopy()

    def append(self, value):
        self.addObject_(value)

    def extend(self, values):
        for value in values:
            self.addObject_(value)

    def clear(self):
        self.removeAllObjects()

    def pop(self, item=-1):
        value = self[item]
        del self[item]
        return value

    def remove(self, value):
        del self[self.index(value)]

    def reverse(self):
        self.setArray(self.reverseObjectEnumerator().allObjects())

    def insert(self, idx, value):
        self.insertObject_atIndex_(value, idx)


@for_objcclass(NSDictionary)
class ObjCDictInstance(ObjCInstance):
    def __getitem__(self, item):
        v = self.objectForKey_(item)
        if v is None:
            raise KeyError(item)
        return v

    def __len__(self):
        return self.count

    def __iter__(self):
        yield from self.allKeys()

    def __contains__(self, item):
        return self.objectForKey_(item) is not None

    def __eq__(self, other):
        return py_from_ns(self) == other

    def __ne__(self, other):
        return not self.__eq__(other)

    def get(self, item, default=None):
        v = self.objectForKey_(item)
        if v is None:
            return default
        return v

    def keys(self):
        return self.allKeys()

    def values(self):
        return self.allValues()

    def items(self):
        for key in self.allKeys():
            yield key, self.objectForKey_(key)

    def copy(self):
        return ObjCInstance(send_message(self, "copy", restype=objc_id, argtypes=[]))


@for_objcclass(NSMutableDictionary)
class ObjCMutableDictInstance(ObjCDictInstance):
    no_pop_default = object()

    def __setitem__(self, item, value):
        self.setObject_forKey_(value, item)

    def __delitem__(self, item):
        if item not in self:
            raise KeyError(item)

        self.removeObjectForKey_(item)

    def copy(self):
        return self.mutableCopy()

    def clear(self):
        self.removeAllObjects()

    def pop(self, item, default=no_pop_default):
        if item not in self:
            if default is not self.no_pop_default:
                return default
            else:
                raise KeyError(item)

        value = self.objectForKey_(item)
        self.removeObjectForKey_(item)
        return value

    def popitem(self):
        if len(self) == 0:
            raise KeyError(f"popitem(): {type(self).__name__} is empty")

        key = self.allKeys().firstObject()
        value = self.objectForKey_(key)
        self.removeObjectForKey_(key)
        return key, value

    def setdefault(self, key, default=None):
        value = self.objectForKey_(key)
        if value is None:
            value = default
        if value is not None:
            self.setObject_forKey_(default, key)
        return value

    def update(self, new=None, **kwargs):
        if new is not None:
            kwargs.update(new)

        for k, v in kwargs.items():
            self.setObject_forKey_(v, k)
