#!/usr/bin/env python

# This file is part of DarkFi (https://dark.fi)
#
# Copyright (C) 2020-2026 Dyne.org foundation
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

import asyncio, sys, base58
import urwid as u
import networkx as nx
from collections import Counter
from datetime import datetime
import src.rpc

NULL_HASH = '0' * 64

def format_timestamp(timestamp):
    ts = int(timestamp)
    dt = datetime.fromtimestamp(ts) if ts < 1e10 else datetime.fromtimestamp(int(ts/1000))
    return str(dt)

# Rows are rendered newest-first so parents always sit below their children.
# Every active branch owns a fixed 3-character lane. Lanes are assigned in a
# first pass: a node consumes its lane and hands it to one parent, extra
# parents open lanes to the right with a heavy 'M━┑' bend, and when a second
# child shares an already-promised parent an S-bend taps that lane on the
# child's own row: 'o──┘' into a lane on the right, '┌──o' into a lane on the
# left. A shared parent (a fork) is kept aligned with its leftmost child's
# lane, and every parent below it follows that same lane, so a fork never
# indents to the right merely because its right branch was processed first.
# A second pass then draws every row using those resolved lanes.
def render_graph_glyphs(events):
    parents_of = {e['hash']: list(dict.fromkeys(
                     p for p in e['parents'] if p != NULL_HASH)) for e in events}
    kids = Counter(p for ps in parents_of.values() for p in ps)
    fork = {p for p, c in kids.items() if c > 1}

    # First pass: resolve the lane every node will be drawn on. A fork parent
    # collects the lanes of all its children and takes the leftmost one.
    promised = {}
    fork_lanes = {}
    fresh = set()
    n_lanes = 0
    final_lane = {}
    for event in events:
        node = event['hash']
        if node in promised:
            lane = promised.pop(node)
        elif node in fork_lanes:
            lane = min(fork_lanes[node])
        else:
            lane = n_lanes
            n_lanes += 1
        final_lane[node] = lane

        own = True
        for parent in parents_of[node]:
            if parent in promised:
                continue
            if parent in fork:
                fork_lanes.setdefault(parent, []).append(lane)
                own = False
            elif own:
                promised[parent] = lane
                own = False
            else:
                promised[parent] = n_lanes
                fresh.add((node, parent))
                n_lanes += 1

    # Second pass: draw the rows using the resolved lanes.
    promised = {}
    glyphs = []
    for event in events:
        node = event['hash']
        lane = final_lane[node]
        promised.pop(node, None)

        edges = []
        for parent in parents_of[node]:
            parent_lane = final_lane[parent]
            promised.setdefault(parent, parent_lane)
            if parent_lane != lane:
                edges.append((parent_lane, (node, parent) in fresh))

        grid = [' '] * (3 * n_lanes)
        for target in promised.values():
            if target != lane:
                grid[3 * target + 1] = '│'

        for target, is_fresh in edges:
            lo, hi = sorted((lane, target))
            if is_fresh:
                grid[3 * target + 1] = '┑'
                run = '━'
            elif target > lane:
                grid[3 * target + 1] = '┘'
                run = '─'
            else:
                grid[3 * target + 1] = '┌'
                run = '─'
            for col in range(3 * lo + 2, 3 * hi + 1):
                grid[col] = run

        grid[3 * lane + 1] = 'M' if len(parents_of[node]) > 1 else 'o'
        glyphs.append(''.join(grid).rstrip())

    return glyphs

# because tab character is broken in urwid texts
def indent(num):
    return " " * (8 - len(str(num)))

class ListItem(u.WidgetWrap):
    
    def __init__ (self, event):
        g = event['glyph']
        self.content = event
        layer_num = int(event["layer"])
        layer = "layer " + str(layer_num) + indent(layer_num) if layer_num != 0 else "genesis       "
        dt = event['hash'][:10] + " │ " + format_timestamp(event['timestamp'])
        t = u.AttrMap(u.Text([('word', dt),
                              ('layer-num', " " + layer),
                              ('word', g),
                              ('cont', ' ' + event['content'])], wrap="ellipsis"),
                              {'word':'datetime', 'layer-num': 'reporter', 'cont': 'content'},
                              {'word':'event_selected', 'layer-num': 'event_selected', 'cont': 'event_selected'})
        u.WidgetWrap.__init__(self, t)

    def selectable (self):
        return True
    
    def keypress(self, size, key):
        return key

# Not event header
class HeaderItem(u.WidgetWrap):

    def __init__ (self, info):
        dt = format_timestamp(info['timestamp'])
        title = f" DAG genesis {info['hash'][:10]} @ {dt}, {info['count']} events "
        t = u.AttrMap(u.Text('──' + title + '─' * max(4, 60 - len(title))), 'header')
        u.WidgetWrap.__init__(self, t)

    def selectable(self):
        return False

class ListView(u.WidgetWrap):

    def __init__(self):
        u.register_signal(self.__class__, ['show_details'])
        self.walker = u.SimpleFocusListWalker([])
        lb = u.ListBox(self.walker)
        u.WidgetWrap.__init__(self, lb)

    def modified(self):
        focus_w, _ = self.walker.get_focus()
        if focus_w is not None:
            u.emit_signal(self, 'show_details', focus_w.content)
        else:
            print("DAG is empty!")
            exit(-1)

    def set_data(self, items):
        events_widgets = [HeaderItem(i) if i.get('type') == 'header' else ListItem(i) for i in items]
        u.disconnect_signal(self.walker, 'modified', self.modified)

        while len(self.walker) > 0:
            self.walker.pop()
        
        self.walker.extend(events_widgets)
        u.connect_signal(self.walker, "modified", self.modified)
        for idx, widget in enumerate(events_widgets):
            if widget.selectable():
                self.walker.set_focus(idx)
                break

class DetailView(u.WidgetWrap):
    
    def __init__ (self):
        t = u.Text("")
        u.WidgetWrap.__init__(self, t)
        
    def set_event(self, c):
        s = f'Hash: {c["hash"]}\nChildren: {c["children"]}\nParents: {c["parents"]}\nContent: {c["content"]}\nLayer: {c["layer"]}\nTimestamp: {c["timestamp"]}'
        self._w.set_text(s)

class App(object):
    
    def unhandled_input(self, key):
        if key in ('q', 'Q'):
            raise u.ExitMainLoop()
        # if key == 'r':
        #     await self.update_data(self.config)
        if key == 'enter':
            self.current_view = self.frame2
            self.loop.widget = self.frame2
        if key in ('b', 'B'):
            self.current_view = self.frame1
            self.loop.widget = self.frame1

    def show_details(self, event):
        self.view_two.set_event(event)
        
    def __init__(self):
        self.view_one = ListView()
        u.connect_signal(self.view_one, 'show_details', self.show_details)
        footer = u.AttrWrap(u.Text(" Q to exit"), "footer")
        col_rows = u.raw_display.Screen().get_cols_rows()
        h = col_rows[0] - 2
        f1 = u.Filler(self.view_one, valign='top', height=h)
        c_list = u.LineBox(f1, title="Events")
        columns = u.Columns([('weight', 100, c_list)])
        frame1 = u.AttrMap(u.Frame(body=columns, footer=footer), 'bg')
        self.frame1 = frame1
        ############
        self.view_two = DetailView()
        f2 = u.Filler(self.view_two, valign='top')
        c_details = u.LineBox(f2, title="Details")
        footer = u.AttrWrap(u.Text(" Q to exit, B to main view"), "footer")
        columns = u.Columns([('weight', 100, c_details)])
        frame2 = u.AttrMap(u.Frame(body=columns, footer=footer), 'bg')
        self.frame2 = frame2
        ##########
        self.current_view = self.frame1  # Start with View One
        self.palette = {
            ("bg",               "white",           "black"),
            ("event",            "white",           "black"),
            ("event_selected",   "white",           "light green"),
            ('datetime',         "light blue",      "black"),
            ('reporter',         "dark green",      "black"),
            ('content',          "",                "black"),
            ("header",           "dark cyan",       "black"),
            ("footer",           "white, bold",     "dark red")
        }
    
    async def update_data(self, config, replay_mode):
        self.config = config
        dag_dict = await recreate_dag(config, replay_mode)
        if not dag_dict:
            print("DAG is empty!")
            exit(-1)

        parent_child_pairs = []
        for node, details in dag_dict.items():
            for parent in details['parents']:
                if parent == NULL_HASH:
                    continue
                parent_child_pairs.append((parent, node))

        # Create a directed graph (genesis events are added explicitly so
        # they are never dropped even when nothing references them)
        dag = nx.DiGraph()
        dag.add_nodes_from(dag_dict.keys())
        dag.add_edges_from(parent_child_pairs)

        # Partition events into one section per DAG
        genesis_nodes = sorted(
            (n for n, details in dag_dict.items() if int(details['layer']) == 0),
            key=lambda n: int(dag_dict[n]['timestamp']), reverse=True)

        sections = []
        grouped = set()
        for genesis in genesis_nodes:
            members = [genesis] + sorted(
                n for n in nx.descendants(dag, genesis) if n not in grouped)
            grouped.update(members)
            sections.append((genesis, members))

        # defensive: events unreachable from any known genesis
        leftovers = [n for n in dag_dict if n not in grouped]
        if leftovers:
            root = min(leftovers, key=lambda n: int(dag_dict[n]['layer']))
            sections.append((root, leftovers))

        items = []
        for root, members in sections:
            sub = dag.subgraph(members)
            order = list(reversed(list(nx.topological_sort(sub))))

            l = []
            for node in order:
                event_details = dag_dict[node] # details
                layer = int(event_details['layer'])
                content = event_details['content'] # event content
                timestamp = event_details['timestamp']
                children = list(sub.successors(node))
                parents = list(sub.predecessors(node))
                try:
                    raw = base58.b58decode(content)
                except ValueError:
                    raw = content.encode('utf-8', 'replace')
                matches = ''.join(ch if ch.isprintable() else ' ' for ch in raw.decode('utf-8', 'replace')).strip()

                l.append({"layer":f"{layer}", "hash":f"{node}", "children":children, "parents":parents, "content":f"{matches}", "timestamp": f"{timestamp}"})

            glyphs = render_graph_glyphs(l)
            for event, glyph in zip(l, glyphs):
                event['glyph'] = glyph

            items.append({"type":"header", "hash":root, "timestamp":dag_dict[root]['timestamp'], "count":len(order)})
            items.extend(l)

        self.view_one.set_data(items)

    async def start(self, config, replay_mode):
        await self.update_data(config, replay_mode)
        self.loop = u.MainLoop(self.current_view, self.palette, unhandled_input=self.unhandled_input)
        self.loop.run()

async def recreate_dag(config, replay_mode):
    host = config['host']
    port = config['port']
    rpc = src.rpc.JsonRpc()
    while True:
        try:
            await rpc.start(host, port)
            break
        except OSError:
            print(f"Error: Connection Refused to '{host}:{port}', Either because the daemon is down, is currently syncing or wrong url.")
            sys.exit(-1)

    await rpc.deg_switch(True)
    await rpc.deg_switch(False)

    if replay_mode:
        json_result = await rpc._make_request("eventgraph.replay", [])
    else:
        json_result = await rpc._make_request("eventgraph.get_info", [])

    if json_result['result']['eventgraph_info']:
        return json_result['result']['eventgraph_info']['dag']
        

async def main(argv):
    val = str('127.0.0.1:9605')
    for i in range(1, len(sys.argv)):
        if sys.argv[i] == "-e":
            try:
                val = sys.argv[i+1]
            except IndexError:
                print("Please provide a value for \'-e\'")
                exit(-1)
            break
    config = {}
    try:
        config['host'], config['port'] = val.split(':')
    except ValueError:
        print("Please provide a port as in: 127.0.0.1:9605")
        exit(-1)

    replay_mode = False
    if len(argv) > 1:
        if '-r' in argv:
            replay_mode = True
        if set(argv) & set(['darkirc', 'irc']):
            config['port'] = 9605
        elif set(argv) & set(['taud', 'tau']):
            config['port'] = 9805

    app = App()
    await app.start(config, replay_mode)
    

asyncio.run(main(sys.argv))
