from visidata import vd, Extensible, VisiData, EscapeException, MissingAttrFormatter


UNLOADED = tuple()  # sentinel for a sheet not yet loaded for the first time; should be iterable

vd.beforeExecHooks = [] # func(sheet, cmd, args, keystrokes) called before the exec()


class LazyChainMap:
    'provides a lazy mapping to obj attributes.  useful when some attributes are expensive properties.'
    def __init__(self, *objs, locals=None):
        self.locals = {} if locals is None else locals
        self.objs = {} # [k] -> obj
        for obj in objs:
            for k in dir(obj):
                if k not in self.objs:
                    self.objs[k] = obj

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

    def __contains__(self, k):
        return k in self.objs

    def keys(self):
        return list(self.objs.keys())  # sum(set(dir(obj)) for obj in self.objs))

    def get(self, key, default=None):
        if key in self.locals:
            return self.locals[key]
        return self.objs.get(key, default)

    def clear(self):
        self.locals.clear()

    def __getitem__(self, k):
        obj = self.objs.get(k, None)
        if obj:
            return getattr(obj, k)
        return self.locals[k]

    def __setitem__(self, k, v):
        obj = self.objs.get(k, None)
        if obj:
            return setattr(obj, k, v)
        self.locals[k] = v


class DrawablePane(Extensible):
    def __init__(self, **kwargs):
        self.__dict__.update(kwargs)

    'Base class for all interaction owners that can be drawn in a window.'
    def draw(self, scr):
        'Draw on the terminal window *scr*.  Should be overridden.'
        vd.error('no draw')

    @property
    def windowHeight(self):
        'Height of the current sheet window, in terminal lines.'
        return self._scr.getmaxyx()[0] if self._scr else 25

    @property
    def windowWidth(self):
        'Width of the current sheet window, in single-width characters.'
        return self._scr.getmaxyx()[1] if self._scr else 80

    @property
    def currow(self):
        return None

    def execCommand2(self, cmd, vdglobals=None):
        "Execute `cmd` with `vdglobals` as globals and this sheet's attributes as locals.  Return True if user cancelled."

        try:
            self.sheet = self
            if cmd.deprecated:
                vd.deprecated_warn(cmd.longname, cmd.deprecated, 'a different command')
            code = compile(cmd.execstr, cmd.longname, 'exec')
            exec(code, vdglobals, LazyChainMap(vd, self))
            return False
        except EscapeException as e:  # user aborted
            vd.warning(str(e))
            return True


class _dualproperty:
    'Return *obj_method* or *cls_method* depending on whether property is on instance or class.'
    def __init__(self, obj_method, cls_method):
        self._obj_method = obj_method
        self._cls_method = cls_method

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self._cls_method(objtype)
        else:
            return self._obj_method(obj)


class BaseSheet(DrawablePane):
    'Base class for all sheet types.'
    _rowtype = object    # callable (no parms) that returns new empty item
    _coltype = None      # callable (no parms) that returns new settable view into that item
    rowtype = 'objects'  # one word, plural, describing the items
    precious = True      # False for a few discardable metasheets
    defer = False        # False for not deferring changes until save
    guide = ''           # default to show in sidebar
    icon = '›'

    def _obj_options(self):
        return vd.OptionsObject(vd._options, obj=self)

    def _class_options(cls):
        return vd.OptionsObject(vd._options, obj=cls)

    class_options = options = _dualproperty(_obj_options, _class_options)

    def __init__(self, *names, rows=UNLOADED, **kwargs):
        self._name = None   # initial cache value necessary for self.options
        self._names = []
        self.loading = False
        self.names = list(names)
        self.source = None
        self.rows = rows      # list of opaque objects
        self._scr = None
        self.hasBeenModified = False

        super().__init__(**kwargs)

        self._sidebar = ''

    def setModified(self):
        if not self.hasBeenModified:
            vd.addUndo(setattr, self, 'hasBeenModified', self.hasBeenModified)
            self.hasBeenModified = True

    def __lt__(self, other):
        if self.name != other.name:
            return self.name < other.name
        else:
            return id(self) < id(other)

    def __copy__(self):
        'Return shallow copy of sheet.'
        cls = self.__class__
        ret = cls.__new__(cls)
        ret.__dict__.update(self.__dict__)
        ret.precious = True  # copy can be precious even if original is not
        ret.hasBeenModified = False  # copy is not modified even if original is
        return ret

    def __bool__(self):
        'an instantiated Sheet always tests true'
        return True

    def __len__(self):
        'Number of elements on this sheet.'
        return self.nRows

    def __str__(self):
        return self.name

    @property
    def rows(self):
        return self._rows

    @rows.setter
    def rows(self, rows):
        self._rows = rows

    @property
    def nRows(self):
        'Number of rows on this sheet.  Override in subclass.'
        return 0

    def __contains__(self, vs):
        if self.source is vs:
            return True
        if isinstance(self.source, BaseSheet):
            return vs in self.source
        return False

    @property
    def displaySource(self):
        if isinstance(self.source, BaseSheet):
            return f'the *{self.source}* sheet'

        if isinstance(self.source, (list, tuple)):
            if len(self.source) == 1:
                return f'the **{self.source[0]}** sheet'
            return f'{len(self.source)} sheets'

        return f'**{self.source}**'

    def execCommand(self, longname, vdglobals=None, keystrokes=None):
        if ' ' in longname:
            cmd, arg = longname.split(' ', maxsplit=1)
            vd.injectInput(arg)

        cmd = self.getCommand(longname or keystrokes)
        if not cmd:
            vd.fail('no command for %s' % (longname or keystrokes))
            return False

        escaped = False
        err = ''

        if vdglobals is None:
            vdglobals = vd.getGlobals()

        vd.cmdlog  # make sure cmdlog has been created for first command

        try:
            for hookfunc in vd.beforeExecHooks:
                hookfunc(self, cmd, '', keystrokes)
            escaped = self.execCommand2(cmd, vdglobals=vdglobals)
        except Exception as e:
            vd.debug(cmd.execstr)
            err = vd.exceptionCaught(e)
            escaped = True

        if vd.cmdlog:
            # sheet may have changed
            vd.callNoExceptions(vd.cmdlog.afterExecSheet, vd.activeSheet, escaped, err)

        vd.callNoExceptions(self.checkCursor)

        vd.clearCaches()

        for t in self.currentThreads:
            if not hasattr(t, 'lastCommand'):
                t.lastCommand = True

        return escaped

    @property
    def lastCommandThreads(self):
        return [t for t in self.currentThreads if getattr(t, 'lastCommand', None)]

    @property
    def names(self):
        return self._names

    @names.setter
    def names(self, names):
        if self._names:
            vd.addUndo(setattr, self, 'names', self._names)
        self._names = names
        self._name = self.options.name_joiner.join(self.maybeClean(str(x)) for x in self._names)

    @property
    def name(self):
        'Name of this sheet.'
        return self._name

    @name.setter
    def name(self, name):
        'Set name without spaces.'
        if self._names:
            vd.addUndo(setattr, self, 'names', self._names)
        self._name = self.maybeClean(str(name))
        self._names = [self._name]

    def maybeClean(self, s):
        'stub'
        return s

    def recalc(self):
        'Clear any calculated value caches.'
        pass

    def refresh(self):
        'Recalculate any internal state needed for `draw()`.  Overridable.'
        pass

    def ensureLoaded(self):
        'Call ``reload()`` if not already loaded.'
        if self.rows is UNLOADED:
            self.rows = []  # prevent auto-reload from running twice
            return self.reload()   # likely launches new thread

    def reload(self):
        'Load sheet from *self.source*.  Override in subclass.'
        vd.error('no reload')

    @property
    def cursorRow(self):
        'The row object at the row cursor.  Overridable.'
        return None

    def checkCursor(self):
        'Check cursor and fix if out-of-bounds.  Overridable.'
        pass

    def evalExpr(self, expr, **kwargs):
        'Evaluate Python expression *expr* in the context of *kwargs* (may vary by sheet type).'
        return eval(expr, vd.getGlobals(), dict(sheet=self))

    def formatString(self, fmt, **kwargs):
        'Return formatted string with *sheet* and *vd* accessible to expressions.  Missing expressions return empty strings instead of error.'
        return MissingAttrFormatter().format(fmt, sheet=self, vd=vd, **kwargs)


@VisiData.api
def redraw(vd):
    'Clear the terminal screen and let the next draw cycle recreate the windows and redraw everything.'
    for vs in vd.sheets:
        vs._scr = None
    if vd.win1: vd.win1.clear()
    if vd.win2: vd.win2.clear()
    if vd.scrFull:
        vd.scrFull.clear()
        vd.setWindows(vd.scrFull)


@VisiData.property
def sheet(self):
    return self.activeSheet


@VisiData.api
def isLongname(self, ks:str):
    'Return True if *ks* is a longname.'
    return ('-' in ks) and (ks[-1] != '-') or (len(ks) > 3 and ks.islower())


@VisiData.api
def getSheet(vd, sheetname):
    'Return Sheet from the sheet stack.  *sheetname* can be a sheet name or a sheet number indexing directly into ``vd.sheets``.'
    if isinstance(sheetname, BaseSheet):
        return sheetname

    matchingSheets = [x for x in vd.sheets if x.name == sheetname]
    if matchingSheets:
        if len(matchingSheets) > 1:
            vd.warning(f'more than one sheet named `{sheetname}`')
        return matchingSheets[0]

    try:
        sheetidx = int(sheetname)
        return vd.sheets[sheetidx]
    except (ValueError, IndexError):
        pass

    if sheetname == 'options':
        vs = vd.globalOptionsSheet
        vs.reload()
        vs.vd = vd
        return vs
