import math
import random
import shutil
import string
import threading
import time
import tkinter

# Nice patterns
# 0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165, 180, 195, 210, 225, 240, 255, 270, 285, 300, 315, 330, 345
# 0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330
# 0, 15, 30, 45, 60, 180, 195, 210, 225, 240
# 0, 90, 180, 270
# ░▒▓

SCREEN_WIDTH = shutil.get_terminal_size()[1] - 6

DEFAULT_LINE = {
    'character': '#',
    'degree': 0,
    'degree_step': 1,
    'horizontal_offset': 0,
    'line_thickness': 1,
    'original_degree': 0,
    'damping': 3,
    'depth': 0,
}
variables = {
    'clock': 0,
    'frames':[],
    'delay': 0.02,
    'lines':[
    ]
}

class LineControl(tkinter.Frame):
    def __init__(self, master, line, *args, **kwargs):
        tkinter.Frame.__init__(self, master, *args, **kwargs)

        self.line = line
        for x in range(4):
            self.columnconfigure(x, minsize=40)
        row = 0
        tkinter.Button(self, text='\u21bb', command=self.refresh).grid(row=row, column=0, sticky='news')
        tkinter.Button(self, text='Clone', command=self.clone).grid(row=row, column=1, columnspan=2, sticky='news')
        tkinter.Button(self, text='X', command=self.suicide).grid(row=row, column=3, sticky='news')
        row += 1

        tkinter.Label(self, text='Degree:').grid(row=row, column=0, sticky='e')
        self.entry_degree = tkinter.Entry(self, width=3)
        self.entry_degree.insert(0, self.line['original_degree'])
        self.entry_degree.grid(row=row, column=1, sticky='news')
        tkinter.Button(self, text='Set', command=self.apply_degree).grid(row=row, column=2, sticky='news')
        row += 1

        def create_buttonpair(key, row):
            minus = tkinter.Button(self, text='-', command=lambda *a: self.step_variable(key, -1, self.value_labels[key]))
            minus.grid(row=row, column=1, sticky='news')
            plus = tkinter.Button(self, text='+', command=lambda *a: self.step_variable(key, +1, self.value_labels[key]))
            plus.grid(row=row, column=2, sticky='news')

        self.value_labels = {}
        tkinter.Label(self, text='Step:').grid(row=row, column=0, sticky='e')
        create_buttonpair('degree_step', row)
        self.value_labels['degree_step'] = tkinter.Label(self, text=str(self.line['degree_step']))
        self.value_labels['degree_step'].grid(row=row, column=3)
        row += 1

        tkinter.Label(self, text='Thick:').grid(row=row, column=0, sticky='e')
        create_buttonpair('line_thickness', row)
        self.value_labels['line_thickness'] = tkinter.Label(self, text=str(self.line['line_thickness']))
        self.value_labels['line_thickness'].grid(row=row, column=3)
        row += 1

        tkinter.Label(self, text='Offset:').grid(row=row, column=0, sticky='e')
        create_buttonpair('horizontal_offset', row)
        self.value_labels['horizontal_offset'] = tkinter.Label(self, text=str(self.line['horizontal_offset']))
        self.value_labels['horizontal_offset'].grid(row=row, column=3)
        row += 1

        tkinter.Label(self, text='Dampen:').grid(row=row, column=0, sticky='e')
        create_buttonpair('damping', row)
        self.value_labels['damping'] = tkinter.Label(self, text=str(self.line['damping']))
        self.value_labels['damping'].grid(row=row, column=3)
        row += 1

        tkinter.Label(self, text='Depth:').grid(row=row, column=0, sticky='e')
        create_buttonpair('depth', row)
        self.value_labels['depth'] = tkinter.Label(self, text=str(self.line['depth']))
        self.value_labels['depth'].grid(row=row, column=3)
        row += 1

        tkinter.Label(self, text='Char:').grid(row=row, column=0, sticky='e')
        self.entry_character = tkinter.Entry(self, width=2, font=('Consolas', 12))
        self.entry_character.grid(row=row, column=1, sticky='news')
        self.entry_character.insert(0, self.line['character'])
        tkinter.Button(self, text='Set', command=self.apply_character).grid(row=row, column=2, sticky='news')
        row += 1

    def apply_character(self, *trash):
        char = self.entry_character.get()
        try:
            assert len(char) == 1
        except:
            return
        self.line['character'] = char

    def apply_degree(self, *trash):
        value = self.entry_degree.get()
        try:
            value = int(value)
        except ValueError:
            return
        self.line['original_degree'] = value % 360
        self.refresh()
        regrid_frames()

    def clone(self, *trash):
        line = self.line.copy()
        create_line_frame(line=line)
        regrid_frames()

    def refresh(self, *trash):
        self.line['degree'] = self.line['original_degree'] + (self.line['degree_step'] * variables['clock'])

    def step_variable(self, key, direction, label):
        value = self.line[key]
        value += direction

        if key == 'line_thickness':
            value = max(value, 0)
        elif key == 'horizontal_offset':
            value = value % SCREEN_WIDTH
        elif key == 'delay':
            value = max(value, 0.001)
        elif key in ('damping', 'depth'):
            value = max(value, 0)
            value = min(value, int(SCREEN_WIDTH / 2))

        self.line[key] = value
        label.configure(text=str(value)[:5])

    def suicide(self, *trash):
        unregister_line(self.line)
        variables['frames'].remove(self)
        self.grid_forget()
        regrid_frames()

def create_line(degree_offset, **kwargs):
    line = DEFAULT_LINE.copy()
    line.update(kwargs)
    line['original_degree'] = degree_offset
    line['degree'] = (variables['clock'] + degree_offset) % 360
    # Line ID exists solely to make sure that even clones are unique.
    line['id'] = random.getrandbits(32)
    return line

def create_line_frame(offset=None, line=None):
    if line is None:
        if offset is None:
            offset = t.entry_add.get()
            offset = offset.replace(' ', '')
            offset = offset.split(',')
            try:
                offset = [int(o) for o in offset]
            except ValueError:
                return
            t.entry_add.delete(0, 'end')

        lines = []
        for x in offset:
            line = create_line(x)
            lines.append(line)
    else:
        line['id'] = random.getrandbits(32)
        lines = [line]

    for line in lines:
        register_line(line)
        frame = LineControl(t, line, relief='groove', borderwidth=2)
        variables['frames'].append(frame)
    regrid_frames()

def map_range(x, old_low, old_high, new_low, new_high):
    '''
    Given a number x in range [old_low, old_high], return corresponding
    number in range [new_low, new_high].
    '''
    if x > old_high or x < old_low:
        raise ValueError('%d not in range [%d..%d]' % (x, old_low, old_high))
    percentage = (x - old_low) / (old_high - old_low)
    y = (percentage * (new_high - new_low)) + new_low
    return y

def position_from_degree(d, damping_units):
    rad = math.radians(d)
    sin = math.sin(rad)
    sin = map_range(sin, -1, 1, 0, 1)
    position = sin * (SCREEN_WIDTH - 1)
    position = map_range(position, 0, SCREEN_WIDTH, damping_units, SCREEN_WIDTH-damping_units)
    #position = (position * (1- (2 * damping_percent))) + damping_units
    position = round(position)
    position = position % SCREEN_WIDTH
    return position

def print_loop():
    while True:
        next_frame = time.time() + variables['delay']
        variables['clock'] = (variables['clock'] + 1) % 360

        screen = ([' '] * SCREEN_WIDTH)
        z_indexes = {}

        for line_vars in variables['lines']:
            start_degree = line_vars['degree']
            end_degree = (start_degree + line_vars['degree_step']) % 360
            line_vars['degree'] = end_degree

            damping = line_vars['damping']
            start_pos = position_from_degree(start_degree, damping)
            end_pos = position_from_degree(end_degree, damping)
            (start_pos, end_pos) = sorted([start_pos, end_pos])

            start_pos -= line_vars['line_thickness']
            end_pos += line_vars['line_thickness']

            mini_step = line_vars['degree_step'] / max((end_pos - start_pos), 1)
            for (index, position) in enumerate(range(start_pos, end_pos+1)):
                position = (position + line_vars['horizontal_offset']) % SCREEN_WIDTH
                this_rads = math.radians(start_degree + (mini_step * index))
                z_index = math.cos(this_rads)
                z_index = map_range(z_index, -1, 1, line_vars['depth'], SCREEN_WIDTH-line_vars['depth'])
                #print(line_vars['character'], z_index)
                if z_index > z_indexes.get(position, -2):
                    screen[position] = line_vars['character']
                    z_indexes[position] = z_index

        print('%03d' % variables['clock'], ''.join(screen))
        delay = max(next_frame - time.time(), 0)
        time.sleep(delay)

def register_line(line):
    variables['lines'].append(line)

def regrid_frames():
    frames = variables['frames']
    #frames.sort(key=lambda x: x.line['original_degree'])
    for (index, frame) in enumerate(frames):
        (row, column) = divmod(index, 4)
        row += 1
        frame.grid(row=row, column=column)

def unregister_line(line):
    variables['lines'].remove(line)

def main():
    global t
    t = tkinter.Tk()

    frame_add = tkinter.Frame(t)
    entry_add = tkinter.Entry(frame_add)
    t.entry_add = entry_add
    entry_add.grid(row=0, column=0)
    tkinter.Button(frame_add, text='+', command=create_line_frame).grid(row=0, column=1)
    frame_add.grid(row=0, column=0)

    frame_delay = tkinter.Frame(t)
    tkinter.Label(frame_delay, text='Speed:')
    thread = threading.Thread(target=print_loop)
    thread.daemon=True
    thread.start()

    create_line_frame([0])
    t.mainloop()

if __name__ == '__main__':
    main()