import os
import sys
import curses
import curses.textpad
import textwrap
import time

from clichatty.db import DB
from clichatty.sms import SMS, SMSCreateError, SMSSendError, SMSDeleteError


class UI:
    def __init__(self):
        self._backup_db()
        self._setup_curses()
        self._run()


    def _run(self):
        pos = 0
        while True:
            try:
                thread = self._choose_thread(pos)
                if thread:
                    pos, (thread_id, thread_alias, thread_name) = thread 
                    if self._show_thread(
                        thread_id, thread_alias, thread_name) != 'threads':
                        break
                else:
                    break
            except (KeyboardInterrupt, curses.error) as e:
                curses.endwin()
                print(e, file=sys.stderr)
                break
    

    def _backup_db(self):
        with DB() as db:
            db.backup()


    def _setup_curses(self):
        self.screen = curses.initscr()
        self.screen.keypad(True)
        self.screen.timeout(100)
        curses.curs_set(0)
        curses.noecho()
        curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION)
        self._setup_colors()


    def _setup_colors(self):
        curses.start_color()
        curses.use_default_colors()

        curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_CYAN)
        curses.init_pair(2, 244, -1)
        curses.init_pair(3, curses.COLOR_CYAN, -1)

        self.cyan_highlight_text = curses.color_pair(1)
        self.normal_text = curses.A_NORMAL
        self.bold_text = curses.A_BOLD
        self.gray_text = curses.color_pair(2)
        self.cyan_text = curses.color_pair(3)

    
    def _title_box(self, title):
        '''
        Create a title box at the top of a window
        '''
        self._ref_term_size()

        title_h, title_w = 3, len(title) + 4
        title_y, title_x = 0, (self.termw - title_w) // 2
        title_win = curses.newwin(title_h, title_w, title_y, title_x)
        title_win.border(curses.ACS_RTEE, curses.ACS_LTEE, 
                         curses.ACS_HLINE, curses.ACS_HLINE, 
                         curses.ACS_ULCORNER, curses.ACS_URCORNER, 
                         curses.ACS_LLCORNER, curses.ACS_LRCORNER
        )
        title_win.addnstr(1, 2, title, title_w - 2, self.bold_text)
        title_win.refresh()


    def _show_help(self):
        self._ref_term_size()
        h, w = 28, 40
        y = (self.termh - h) // 2
        x = (self.termw - w) // 2

        win = curses.newwin(h, w, y, x)
        win.box()

        lines = ['Threads Selection Window', 
                 '',
                 '<enter>, l:  view thread',
                 w * '─',
                 'Thread View Window', 
                 '',
                 'h:        back to thread selection',
                 '<enter>:  compose message',
                 w * '─',
                 'Message Composition', 
                 '', 
                 '<esc>:  exit composition, saving draft',
                 '<c-g>:  send message',
                 '<c-u>:  delete to start of line',
                 w * '─',
                 'Everywhere',
                 '',
                 'q:  quit', 
                 'H:  help', 
                 'k:  move up', 
                 'j:  move down', 
                 'g:  go to top', 
                 'G:  go to bottom', 
                 '/:  search in window',
                 'n:  next search result',
                 'N:  previous search result']
        for i, l in enumerate(lines): 
            if i in [0, 4, 9, 15]:
                win.addnstr(i + 1, 1, l, w - 2, self.bold_text)
            else:
                win.addnstr(i + 1, 1, l, w - 2)

        win.refresh()
        win.getch()
        win.erase()
        self.screen.touchwin()


    def _compose_message(self, thread_id):
        min_h = 1
        max_h = self.view_winh - 4
        width = self.view_winw - 2
        self.cancelled = False
        curses.curs_set(1)

        lines = ['']
        cy, cx = 0, 0 # cursor pos

        draft_path = f'/tmp/clichatty_draft_{thread_id}.txt'
        try:
            with open(draft_path, 'r', encoding='utf-8') as f:
                content = f.read().splitlines()
            lines[:] = content if content else ['']
            cy = len(lines) - 1
            cx = len(lines[cy])
        except FileNotFoundError:
            pass

        def redraw(h):
            input_winh = h + 2
            input_win = curses.newwin(input_winh, self.view_winw, 
                                      self.view_winh - input_winh + 1, 1)
            input_win.erase()
            input_win.border(curses.ACS_VLINE, curses.ACS_VLINE, 
                             curses.ACS_HLINE, curses.ACS_HLINE, 
                             curses.ACS_LTEE, curses.ACS_RTEE, 
                             curses.ACS_LLCORNER, curses.ACS_LRCORNER)

            textbox_win = input_win.derwin(h, width, 1, 1)
            textbox_win.erase()

            for idx, ln in enumerate(lines[-h:]):
                try:
                    textbox_win.addnstr(idx, 0, ln, width)
                except curses.error:
                    pass

            input_win.refresh()
            textbox_win.refresh()

            return input_win, textbox_win

        def wrap_lines():
            new_lines = []
            for ln in lines:
                while len(ln) > width:
                    new_lines.append(ln[:width])
                    ln = ln[width:]
                new_lines.append(ln)
            return new_lines

        while True:
            wrapped = wrap_lines()

            lines[:] = wrapped
            if cy >= len(lines):
                cy = len(lines) - 1
                cx = min(cx, len(lines[cy]))
            h = max(min_h, min(len(lines), max_h))
            input_win, textbox_win = redraw(h)

            # move cursor to correct visible location
            top_index = max(0, len(lines) - h)
            vis_row = cy - top_index
            try:
                textbox_win.move(vis_row, cx)
            except curses.error:
                pass

            ch = input_win.getch()
            # send with ctrl-g
            if ch == 7:
                curses.curs_set(0)
                try:
                    os.remove(draft_path)
                except FileNotFoundError:
                    pass
                return '\n'.join(lines)

            # exit draft with escape
            if ch == 27:
                curses.curs_set(0)
                self.cancelled = True
                with open(draft_path, 'w') as f:
                    f.write('\n'.join(lines))
                return None

            # printable chars
            if 32 <= ch <= 126 or ch >= 128:
                ln = lines[cy]
                lines[cy] = ln[:cx] + chr(ch) + ln[cx:]
                cx += 1
                # automatic wrapping will be applied next loop
                continue

            # create new line after cursor with enter
            if ch in (10, 13):
                ln = lines[cy]
                new_ln = ln[cx:]
                lines[cy] = ln[:cx]
                lines.insert(cy + 1, new_ln)
                cy += 1
                cx = 0
                continue

            # backspace
            if ch in (curses.KEY_BACKSPACE, 127, 8):
                if cx > 0:
                    ln = lines[cy]
                    lines[cy] = ln[:cx - 1] + ln[cx:]
                    cx -= 1
                else:
                    # join with previous line if exists
                    if cy > 0:
                        prev_len = len(lines[cy - 1])
                        lines[cy - 1] += lines[cy]
                        del lines[cy]
                        cy -= 1
                        cx = prev_len

                        input_win.border(curses.ACS_VLINE, curses.ACS_VLINE, 
                                         ' ', ' ',
                                         curses.ACS_VLINE, curses.ACS_VLINE, 
                                         curses.ACS_LLCORNER, curses.ACS_LRCORNER)
                        input_win.refresh()

                continue

            # ctrl-u: delete to start of line
            if ch == 21:
                lines[cy] = lines[cy][cx:]
                cx = 0
                continue


    def _search_box(self, parent_win, parent_h, parent_w):
        '''
        Create a searchbox at the bottom of a parent window
        '''
        self.cancelled = False
        curses.curs_set(1)

        sb_y, sb_x = parent_h - 2, 1
        sb_w = parent_w - 2
        sb_win = parent_win.derwin(1, sb_w, sb_y, sb_x)
        sb_win.erase()
        sb_win.addstr(0, 0, '/')
        sb_win.refresh()

        def validator(ch):
            if ch == 27:
                curses.curs_set(0)
                self.cancelled = True
                return 7
            return ch

        searchbox = curses.textpad.Textbox(sb_win)
        search = searchbox.edit(
            validator).lstrip('/').rstrip('\n').strip().lower()
        curses.curs_set(0)
        return search

    
    def _ref_win_size(self, winh, winw, win):
        new_termh, new_termw = self.screen.getmaxyx()
        if (new_termh, new_termw) != (self.termh, self.termw):
            self.termh, self.termw, = new_termh, new_termw
            winh, winw = new_termh - 2, new_termw - 2
            curses.resize_term(self.termh, self.termw)
            self.screen.clear()
            self.screen.refresh()

            win.resize(winh, winw)
            win.mvwin(1, 1)

        win.erase()
        win.box()
        win.refresh()

        return winh, winw


    def _ref_term_size(self):
        self.termh, self.termw = self.screen.getmaxyx()


    def _choose_thread(self, pos):
        search_results = None
        self.screen.clear()
        self.screen.refresh()

        with DB() as db:
            threads = db.get_threads()
        threadslen = len(threads)

        self._ref_term_size()
        winh, winw = self.termh - 2, self.termw - 2

        win = curses.newwin(winh, winw, 1, 1)
        win.keypad(True)

        db_ref_interval = 2
        last_db_ref = 0
        while True:
            now = time.time()
            if now - last_db_ref >= db_ref_interval:
                with DB() as db:
                    threads = db.get_threads()
                threadslen = len(threads)

            winh, winw = self._ref_win_size(winh, winw, win)

            if threadslen == 0:
                win.addstr(2, 2, "No threads") 
                if win.getch():
                    curses.endwin()
                    return
            else:
                start = max(0, pos - winh // 2)
                for row_idx in range(0, winh - 3):
                    idx = start + row_idx

                    if idx >= threadslen:
                        break

                    thread_id, thread_alias, thread_name = threads[idx]
                    row = f'{thread_alias} - {thread_name}'
                    attr = self.cyan_highlight_text if idx == pos \
                        else self.normal_text

                    rowlen = len(row)
                    if winw < rowlen:
                        row = row[:winw-5] + '...'
                    win.addnstr(2 + row_idx, 1, row, winw - 2, attr)

            win.refresh()

            self._title_box('Choose a thread')

            ch = win.getch()
            if ch == ord('q'):
                curses.endwin()
                return
            elif ch == ord('k'):
                pos = max(0, pos - 1)
            elif ch == ord('j'):
                pos = min(threadslen - 1, pos + 1)
            elif ch == ord('g'):
                pos = 0
            elif ch == ord('G'):
                pos = threadslen - 1
            elif ch == curses.KEY_MOUSE:
                try:
                    _, mx, my, _, bstate = curses.getmouse()
                except curses.error:
                    bstate = 0
                if bstate & curses.BUTTON4_PRESSED:
                    pos = max(0, pos - 1)
                elif bstate & curses.BUTTON5_PRESSED:
                    pos = min(threadslen - 1, pos + 1)
            elif ch == ord('/'):
                search = self._search_box(win, winh, winw).lower()
                if search and not self.cancelled:
                    search_results = [idx for idx, (_, thread_alias, _) in
                        enumerate(threads) 
                        if thread_alias and search in thread_alias.lower()]
                    if search_results:
                        p_idx = 0
                        pos = min(max(0, search_results[p_idx]), threadslen - 1)
                    else:
                        search_results = None
                        p_idx = None
            elif ch == ord('n'):
                if search_results:
                    if p_idx is None or p_idx == len(search_results) - 1:
                        p_idx = 0
                    else:
                        p_idx = min(p_idx + 1, len(search_results) - 1)
                    pos = min(max(0, search_results[p_idx]), threadslen - 1)
            elif ch == ord('N'):
                if search_results:
                    if p_idx is None:
                        p_idx = 0
                    elif p_idx == 0:
                        p_idx = max(0, len(search_results) - 1)
                    else:
                        p_idx = max(p_idx - 1, 0)
                    pos = min(max(0, search_results[p_idx]), threadslen - 1)
            elif ch == ord('H'):
                self._show_help()
            elif ch in [curses.KEY_ENTER, ord('\n'), ord('l')]:
                curses.endwin()
                chosen = threads[pos]
                return pos, chosen


    def _show_thread(self, thread_id, thread_alias, thread_name):
        search_results = None
        self.screen.clear()
        self.screen.refresh()

        self._ref_term_size()
        self.view_winh, self.view_winw = self.termh - 2, self.termw - 2

        view_win = curses.newwin(self.view_winh, self.view_winw, 1, 1)

        with DB() as db:
            msgs = db.get_thread(thread_id)
        len_msgs = len(msgs)
        last_index = max(0, len_msgs - 1)

        db_ref_interval = 2
        last_db_ref = 0
        startmsg = self._index_for_startmsg_at_bottom(msgs, len_msgs)
        while True:
            now = time.time()
            if now - last_db_ref >= db_ref_interval:
                with DB() as db:
                    new_msgs = db.get_thread(thread_id)

                new_len_msgs = len(new_msgs)
                new_last_index = max(0, new_len_msgs - 1)

                old_bottom = self._index_for_startmsg_at_bottom(msgs, len_msgs)
                new_bottom = self._index_for_startmsg_at_bottom(
                    new_msgs, new_len_msgs)

                offset_from_bottom = len_msgs - startmsg
                if startmsg >= old_bottom:
                    startmsg = max(0, 
                           new_len_msgs - min(offset_from_bottom, new_len_msgs))
                else:
                    startmsg = min(startmsg, max(0, new_len_msgs - 1))

                msgs = new_msgs
                len_msgs = new_len_msgs
                last_index = new_last_index
                last_db_ref = now

            self.view_winh, self.view_winw = self._ref_win_size(
                self.view_winh, self.view_winw, view_win)

            if len_msgs == 0:
                view_win.addstr(2, 2, "No messages in this thread") 
            else:
                used_rows = 0
                for i in range(startmsg, len_msgs):
                    sender_id, sender_alias, msg_time, body = msgs[i]
                    
                    header = f'{sender_alias} at {msg_time}'

                    msg_height, wrapped_lines = \
                        self._compute_wrapped_msg(body)

                    max_body_width = min(self.view_winw - 2, max(
                        [len(line) for line in wrapped_lines] \
                            + [len(header) + 2], default=4) + 2)

                    if used_rows + msg_height > self.view_winh - 2:
                        break

                    if sender_id == 1:
                        mbox = view_win.derwin(
                            msg_height, max_body_width, used_rows + 1, 
                            self.view_winw - max_body_width - 1)
                        mbox.box()
                    else:
                        mbox = view_win.derwin(
                            msg_height, max_body_width, used_rows + 1, 1)
                        mbox.attron(self.cyan_text)
                        mbox.box()
                        mbox.attroff(self.cyan_text)

                    mboxh, mboxw = mbox.getmaxyx()
                    inner_mboxw = max(0, mboxw - 2)

                    mbox.addnstr(1, 1, header, inner_mboxw, self.gray_text)

                    for line_offset, line_text in enumerate(wrapped_lines):
                        row = 2 + line_offset

                        if row < mboxh - 1:
                            mbox.addnstr(row, 1, line_text, inner_mboxw)
                    
                    used_rows += msg_height

            view_win.refresh()

            self._title_box(thread_alias) if thread_alias \
                else self._title_box('Empty thread alias')

            ch = self.screen.getch()
            if ch == ord('q'):
                curses.endwin()
                return
            elif ch == ord('h'):
                curses.endwin()
                return 'threads'
            elif ch in [curses.KEY_ENTER, ord('\n')]:
                msg = self._compose_message(thread_id)
                if msg and not self.cancelled:
                    self._send_sms(msg, thread_id, thread_name)
            elif ch == ord('k'):
                startmsg = max(0, startmsg - 1)
            elif ch == ord('j'):
                startmsg = min(last_index, startmsg + 1)
            elif ch == ord('g'):
                startmsg = 0
            elif ch == ord('G'):
                startmsg = self._index_for_startmsg_at_bottom(msgs, len_msgs)
            elif ch == curses.KEY_MOUSE:
                try:
                    _, mx, my, _, bstate = curses.getmouse()
                except curses.error:
                    bstate = 0
                if bstate & curses.BUTTON4_PRESSED:
                    startmsg = max(0, startmsg - 1)
                elif bstate & curses.BUTTON5_PRESSED:
                    startmsg = min(len_msgs - 1, startmsg + 1)
            elif ch == ord('/'):
                search = self._search_box(
                    view_win, self.view_winh, self.view_winw).lower()
                if search and not self.cancelled:
                    search_results = [idx for idx, (_, _, _, body) in
                        enumerate(msgs) if body and search in body.lower()]
                    if search_results:
                        p_idx = 0
                        startmsg = min(
                            max(0, search_results[p_idx]), len_msgs - 1)
                    else:
                        search_results = None
            elif ch == ord('n'):
                if search_results:
                    if p_idx is None or p_idx == len(search_results) - 1:
                        p_idx = 0
                    else:
                        p_idx = min(p_idx + 1, len(search_results) - 1)
                    startmsg = min(max(0, search_results[p_idx]), len_msgs - 1)
            elif ch == ord('N'):
                if search_results:
                    if p_idx is None:
                        p_idx = 0
                    elif p_idx == 0:
                        p_idx = max(0, len(search_results) - 1)
                    else:
                        p_idx = max(p_idx - 1, 0)
                    startmsg = min(max(0, search_results[p_idx]), len_msgs - 1)
            elif ch == ord('H'):
                self._show_help()


    def _index_for_startmsg_at_bottom(self, msgs, len_msgs):
        '''
        Calculate the index necessary to put the startmsg at the bottom of the
        view window
        '''
        visible = 0
        used_rows = 0
        for i in range(len_msgs - 1, -1, -1):
            _, _, _, body = msgs[i]
            msg_height, _ = self._compute_wrapped_msg(body)

            if used_rows + msg_height > self.view_winh - 2:
                break

            used_rows += msg_height
            visible += 1

        startmsg = max(0, len_msgs - visible)
        return startmsg


    def _compute_wrapped_msg(self, body):
        wrap_width = max(1, self.view_winw // 5 * 3)
        wrapped_lines = []
        for line in body.split('\n'):
            if len(line) <= wrap_width:
                wrapped_lines.append(line)
            else:
                wrapped_lines.extend(
                        textwrap.wrap(line, wrap_width))

        msg_height = 3 + len(wrapped_lines) if wrapped_lines else 3
        return msg_height, wrapped_lines


    def _send_sms(self, msg, thread_id, thread_name):
        try:
            sms = SMS(thread_name, msg)
        except SMSCreateError as e:
            curses.endwin()
            print(e, file=sys.stderr)
            return

        try:
            sms.send()
            with DB() as db:
                db.insert_message(thread_id, msg)
                msgs = db.get_thread(thread_id)
            curses.curs_set(0)
        except (SMSSendError, SMSDeleteError) as e:
            curses.endwin()
            print(e, file=sys.stderr)
            return
