import 'dart:io';
import 'dart:convert';
import 'dart:math';
import 'package:bip340/bip340.dart';
import 'package:intl/intl.dart';
import 'package:nostr_console/tree_ds.dart';
import 'package:translator/translator.dart';
import 'package:crypto/crypto.dart';
import 'package:nostr_console/settings.dart';
import "dart:typed_data";
import 'dart:convert' as convert;
import "package:pointycastle/export.dart";
import 'package:kepler/kepler.dart';


String getStrInColor(String s, String commentColor) => stdout.supportsAnsiEscapes ?"$commentColor$s$gColorEndMarker":s;
void   printInColor(String s, String commentColor) => stdout.supportsAnsiEscapes ?stdout.write("$commentColor$s$gColorEndMarker"):stdout.write(s);
void   printWarning(String s) => stdout.supportsAnsiEscapes ?stdout.write("$gWarningColor$s$gColorEndMarker\n"):stdout.write("$s\n");


// translate 
GoogleTranslator? translator; // initialized in main when argument given
 
const int gNumTranslateDays = 2;// translate for this number of days
bool gTranslate = false; // translate flag

// Structure to store kind 0 event meta data, and kind 3 meta data for each user. Will have info from latest
// kind 0 event and/or kind 3 event, both with their own time stamps.
class UserNameInfo {
  int? createdAt;
  String? name, about, picture;
  int? createdAtKind3;
  Event ?latestContactEvent;
  UserNameInfo(this.createdAt, this.name, this.about, this.picture, this.latestContactEvent, [this.createdAtKind3 = null]);
}

/* 
 * global user names from kind 0 events, mapped from public key to a 3 element array of [name, about, picture]
 *  JSON object {name: <username>, about: <string>, picture: <url, string>}
 *  only has info from latest kind 0 event
 */
Map<String, UserNameInfo> gKindONames = {}; 

// global reactions entry. Map of form <if of event reacted to, List of Reactors>
// reach Reactor is a list of 2-elements ( first is public id of reactor event, second is comment)
Map< String, List<List<String>> > gReactions = {};

// global contact list of each user, including of the logged in user.
// maps from pubkey of a user, to the latest contact list of that user, which is the latest kind 3 message
// is updated as kind 3 events are received 
Map< String, List<Contact>> gContactLists = {};

class EventData {
  String             id;
  String             pubkey;
  int                createdAt;
  int                kind;
  String             content;
  List<String>       eTags;// e tags
  List<String>       pTags;// list of p tags for kind:1
  List<List<String>> tags;
  bool               isNotification; // whether its to be highlighted using highlight color
  String             evaluatedContent; // content which has mentions expanded, and which has been translated
  Set<String>        newLikes;    // used for notifications, are colored as notifications and then reset  

  List<Contact> contactList = []; // used for kind:3 events, which is contact list event

  bool               isHidden; // hidden by sending a reaction kind 7 event to this event, by the logged in user
  bool               isDeleted; // deleted by kind 5 event
  
  // returns the immediate kind 1 parent
  String getParent(Map<String, Tree> allEventsMap) {
    if( eTags.isNotEmpty) {
      for( int i = eTags.length - 1; i >= 0; i--) {
        String eventId = eTags[i];
        if( allEventsMap[eventId]?.event.eventData.kind == 1) {
          String? parentId = allEventsMap[eventId]?.event.eventData.id;
          if( parentId != null) {
            return parentId;
          }
        } else {
          // if first e tag ( from end, which is the immediate parent) does not exist in the store, then return that eventID still. 
          // Child comment would get a dummy parent, and called could then fetch that event
          return eventId;
        }
      }
    }
    return "";
  }

  EventData(this.id,          this.pubkey,   this.createdAt,  this.kind,  this.content,   
            this.eTags,   this.pTags,        this.contactList,this.tags,  this.newLikes,   
            {
              this.isNotification = false, this.evaluatedContent = "", this.isHidden = false, this.isDeleted = false
            });
   
  factory EventData.fromJson(dynamic json) {
    
    List<Contact> contactList = [];

    List<String>       eTagsRead = [];
    List<String>       pTagsRead = [];
    List<List<String>> tagsRead = [];

    var jsonTags = json['tags'];      
    var numTags = jsonTags.length;

    // NIP 02: if the event is a contact list type, then populate contactList
    if(json['kind'] == 3) {
      for( int i = 0; i < numTags; i++) {
        var tag = jsonTags[i];
        if( tag.length < 2) {
          if( gDebug > 0) print("In event fromjson: invalid p tag of size 1");
          continue;
        }

        String server = defaultServerUrl;
        if( tag.length >=3 ) {
          server = tag[2].toString();
          if( server == 'wss://nostr.rocks' || server == "wss://nostr.bitcoiner.social") {
            server = defaultServerUrl;
          }
        }

        if( tag[0] == "p" && tag[1].length == 64) {
          Contact c = Contact(tag[1] as String, server);
          contactList.add(c); 
        }
      }
    } else {
      int eKind = json['kind'];
      if ( eKind == 1 || eKind == 7 || eKind == 42  || eKind == 5 || eKind == 4 || eKind == 140 || eKind == 141 || eKind == 142) {
        for( int i = 0; i < numTags; i++) {
          var tag = jsonTags[i];

          if( tag.isEmpty) {
            continue;
          }
          if( tag[0] == "e") {
            eTagsRead.add(tag[1]);
          } else {
            if( tag[0] == "p") {
              pTagsRead.add(tag[1]);
            }
          }
          List<String> t = [];
          t.add(tag[0]);
          t.add(tag[1]);
          tagsRead.add(t);

          // TODO add other tags
        }
      }
    }

    if( gDebug > 0 && json['id'] == gCheckEventId) {
      print("\n----------------------------------------Creating EventData with content: ${json['content']}");
      print("In Event fromJson: got message: $gCheckEventId");
    }

    return EventData(json['id'] as String,      json['pubkey'] as String, 
                     json['created_at'] as int, json['kind'] as int,
                     json['content'].trim() as String, 
                     eTagsRead,                 pTagsRead,
                     contactList,               tagsRead, 
                     {});
  }

  String expandMentions(String content) {
    if( id == gCheckEventId) {
      printInColor("in expandMentions: decoding ee810ea73072af056cceaa6d051b4fcce60739247f7bcc752e72fa5defb64f09\n", redColor);
    }

    if( tags.isEmpty) {
      return content;
    }

    // just check if there is any square bracket in comment, if not we return
    String squareBracketStart = "[", squareBracketEnd = "]";
    if( !content.contains(squareBracketStart) || !content.contains(squareBracketEnd) ) {
      return content;
    }

    // replace the patterns
    List<String> placeHolders = ["#[0]", "#[1]", "#[2]", "#[3]", "#[4]", "#[5]", "#[6]", "#[7]" ];
    for(int i = 0; i < placeHolders.length && i < tags.length; i++) {
      int     index = -1;
      Pattern p     = placeHolders[i];
      if( (index = content.indexOf(p)) != -1 ) {
        if( tags[i].length >= 2) {
          String author = getAuthorName(tags[i][1]);
          content = "${content.substring(0, index)}@$author${content.substring(index + 4)}";
        }
      }
    }
    return content;
  }

  // is called only once for each event received ( or read from file)
  void translateAndExpandMentions(List<DirectMessageRoom> directRooms, Map<String, Tree> tempChildEventsMap) {
    if( id == gCheckEventId) {
      printInColor("in translateAndExpandMensitons: decoding ee810ea73072af056cceaa6d051b4fcce60739247f7bcc752e72fa5defb64f09\n", redColor);
    }

    if (content == "" ||  evaluatedContent != "") {
      if( id == gCheckEventId) {
        printInColor("in translateAndExpandMensitons: returning \n", redColor);
      }
      return;
    }


    switch(kind) {
    case 1:
    case 42:
      evaluatedContent = expandMentions(content);
      if( translator != null && gTranslate && !evaluatedContent.isEnglish()) {
        if( gDebug > 0) print("found that this comment is non-English: $evaluatedContent");

        // only translate for latest events
        if( DateTime.fromMillisecondsSinceEpoch(createdAt *1000).compareTo( DateTime.now().subtract(Duration(days:gNumTranslateDays)) ) > 0 ) {
          if( gDebug > 0) print("Sending google request: translating $content");
          if( translator != null) {
            try {
            translator?.translate(content, to: 'en')
                       .then( (result) => { evaluatedContent =   "$evaluatedContent\n\nTranslation: ${result.toString()}" , if( gDebug > 0)  print("Google translate returned successfully for one call.")} )
                       .onError((error, stackTrace)  { 
                            if( gDebug > 0) print("Translate error = $error\n for  content = $content\n");
                            return {} ;
                          }
                        );
            } on Exception catch(err) {
              if( gDebug >= 0) print("Info: Error in trying to use google translate: $err");
            }
          }
        }
      }
    break;

    case 4: 
      if( userPrivateKey == ""){ // cant process if private key not given
        break;
      }
      //if( pubkey == userPublicKey )  break; // crashes right now otherwise 
      if(!isValidDirectMessage(this)) {
        break;
      }

      if( id == gCheckEventId) {
        printInColor("in translateAndExpandMensitons: gonna decrypt \n", redColor);
      }

      String? decrypted = decryptDirectMessage();
      if( decrypted != null) {
        evaluatedContent = decrypted;
        evaluatedContent = expandMentions(evaluatedContent);
      }
      break;


    } // end switch
  } // end translateAndExpandMentions


  // is called only once for each event received ( or read from file)
  void translateAndExpand14x(List<DirectMessageRoom> directRooms, List<Channel> encryptedChannels, Map<String, Tree> tempChildEventsMap) {
    if( id == gCheckEventId) {
      printInColor("in translateAndExpandMensitons: decoding ee810ea73072af056cceaa6d051b4fcce60739247f7bcc752e72fa5defb64f09\n", redColor);
    }

    if (content == "" ||  evaluatedContent != "") {
      if( id == gCheckEventId) {
        printInColor("in translateAndExpandMensitons: returning \n", redColor);
      }
      return;
    }

    switch(kind) {
    case 142:
      String? decrypted = decryptEncryptedChannelMessage(directRooms, encryptedChannels, tempChildEventsMap);
      if( decrypted != null) {
        evaluatedContent = decrypted;
        evaluatedContent = expandMentions(evaluatedContent);
      }
      break;
    default:
      break;

    } // end switch
  } // end translateAndExpand14x


  String? decryptDirectMessage() {
    int ivIndex = content.indexOf("?iv=");
    var iv = content.substring( ivIndex + 4, content.length);
    var enc_str = content.substring(0, ivIndex);

    String userKey = userPrivateKey ;
    String otherUserPubKey = "02" + pubkey;
    if( pubkey == userPublicKey) { // if user themselve is the sender change public key used to decrypt
      userKey =  userPrivateKey;
      int numPtags = 0;
      tags.forEach((tag) {
        if(tag[0] == "p" ) {
          otherUserPubKey = "02" + tag[1];
          numPtags++;
        }
      }); 
      // if there are more than one p tags, we don't know who its for
      if( numPtags != 1) {
        if( gDebug >= 0) printInColor(" in translateAndExpand: got event $id with number of p tags != one : $numPtags . not decrypting", redColor);
          return null;
      }
    } 

    var decrypted = myPrivateDecrypt( userKey, otherUserPubKey, enc_str, iv); // use bob's privatekey and alic's publickey means bob can read message from alic
    return decrypted;
  }

  Channel? getChannelForMessage(List<Channel>? listChannel, String messageId) {
    if( listChannel == null) {
      return null;
    }
     for(int i = 0; i < listChannel.length; i++) {
      if( listChannel[i].messageIds.contains(messageId)) {
        return listChannel[i];
      }
     }
     return null;
  }
  

  String? decryptEncryptedChannelMessage(List<DirectMessageRoom> directRooms, List<Channel> encryptedChannels,Map<String, Tree> tempChildEventsMap) {
    Channel? channel = getChannelForMessage( encryptedChannels, id);
    if( channel == null) {
      print("could not find channel");
      return null;
    }

    if(!channel.participants.contains(userPublicKey)) {
      return null;
    }

    if(!channel.participants.contains(pubkey)) {
      return null;
    }

    if( id == "865c9352de11a3959c06fce5350c5a1b9fa0475d3234078a1bb45d152b370f0b") {  // known issue
      
      //print("\n\ngoing to decrypt b1ab66ac50f00f3c3bbc91e5b9e03fc8e79e3fdb9f6d5c9ae9777aa6ca3020a2");
      //print(channel.participants);
      return "";
    }

    //print("Going to decrypt event id: $id");

    //print("In decryptEncryptedChannelMessage: for event of kind 142 with event id = $id");
    int ivIndex = content.indexOf("?iv=");
    var iv = content.substring( ivIndex + 4, content.length);
    var enc_str = content.substring(0, ivIndex);
        
    String channelId = getChannelIdForMessage();
    //print("In decryptEncryptedChannelMessage: got channel id $channelId");
    List<String> keys = [];
    keys = getEncryptedChannelKeys(directRooms, tempChildEventsMap, channelId);

    if( keys.length != 2) {
      printInColor("Could not get keys for event id: $id and channelId: $channelId", redColor);
      return "";
    }

    //print("\nevent id: $id");
    //print(keys);

    String priKey = keys[0];
    String pubKey = "02" + keys[1];

    var decrypted = myPrivateDecrypt( priKey, pubKey, enc_str, iv); // use bob's privatekey and alic's publickey means bob can read message from alic
    return decrypted;
  }

  // only applicable for kind 42/142 event; returns the channel 40/140 id of which the event is part of
  String getChannelIdForMessage() {
    if( kind != 42 && kind != 142 && kind!=141) {
      return "";
    }
    //print("in getChannelIdForMessage tag length = ${tags.length}");
    // get first e tag, which should be the channel of which this is part of
    for( int i = 0; i < tags.length; i++) {
      List tag = tags[i];
      if( tag[0] == 'e') {
        return tag[1];
      }
    }
    return '';
  }

  // prints event data in the format that allows it to be shown in tree form by the Tree class
  void printEventData(int depth, bool topPost) {
    if( !(kind == 1 || kind == 4 || kind == 42)) {
      return; // only print kind 1 and 42 and 4
    }

    int n = 4;
    String maxN(String v)       => v.length > n? v.substring(0,n) : v.substring(0, v.length);

    String name = getAuthorName(pubkey);    
    String strDate = getPrintableDate(createdAt);
    String tempEvaluatedContent = evaluatedContent;
    String tempContent = content;

    if( isHidden) {
      name = "<hidden>";
      strDate = "<hidden>";
      tempEvaluatedContent = tempContent = "<You have hidden this post>";
    }

    // delete supercedes hidden
    if( isDeleted) {
      name = "<deleted>";
      strDate = "<deleted>";
      tempEvaluatedContent = tempContent = content; // content would be changed so show that 
    }

    if( createdAt == 0) {
      print("debug: createdAt == 0 for event $id $content");
    }

    String commentColor = "";
    if( isNotification) {
      commentColor = gNotificationColor;
      isNotification = false;
    } else {
      commentColor = gCommentColor;
    }

    int tempEffectiveLen = name.length < gNameLengthInPost? name.length: gNameLengthInPost;
    name = name.substring(0,tempEffectiveLen);

    int effectiveNameFieldLen = gNameLengthInPost + 3;  // get this before name is mangled by color
    String nameColor = getNameColor(pubkey);

    // pad name to left 
    name = name.padLeft(gNameLengthInPost);
    name = name.substring(0, gNameLengthInPost);
    name = getStrInColor(name, nameColor);
    
    String strToPrint = "";
    if(!topPost) {
      strToPrint += "\n";
      strToPrint += getDepthSpaces(depth);
      strToPrint += " "; // in place of block for top posts
    } else {
      strToPrint += getDepthSpaces(depth);
      strToPrint += "█";
    }

    strToPrint += "${name}: ";
    const int typicalxLen = "|id: 82b5 , 12:04 AM Sep 19".length + 5; // not sure where 5 comes from 
    String idDateLikes = "    |id: ${maxN(id)} , $strDate ${getReactionStr(depth)}" ;
    idDateLikes = idDateLikes.padRight(typicalxLen);

    String temp = tempEvaluatedContent==""?tempContent: tempEvaluatedContent;
    String contentShifted = makeParagraphAtDepth( temp,  gSpacesPerDepth * depth + effectiveNameFieldLen);

    int maxLineLen =  gTextWidth - gSpacesPerDepth * depth -  effectiveNameFieldLen ;
    int lastLineLen = contentShifted.length;
    int i = 0;

    contentShifted = contentShifted.trim();

    // find the effective length of the last line of the content
    for(i = contentShifted.length - 1; i >= 0; i-- ) {
      if( contentShifted[i] == "\n") {
        break;
      }
    }

    if( i >= 0 && contentShifted[i] == "\n") {
      lastLineLen = contentShifted.length - i;
    }

    // effective len of last line is used to calcluate where the idDateLikes str is affixed at the end 
    int effectiveLastLineLen = lastLineLen - gSpacesPerDepth * depth - effectiveNameFieldLen - gNumLeftMarginSpaces;
    if( contentShifted.length <= maxLineLen )
      effectiveLastLineLen = contentShifted.length;

    // now actually find where the likesDates string goes
    if( (gSpacesPerDepth * depth + effectiveNameFieldLen + effectiveLastLineLen + idDateLikes.length ) <= gTextWidth) {
      idDateLikes =  idDateLikes.padLeft((gTextWidth ) - (gSpacesPerDepth * depth + effectiveNameFieldLen + effectiveLastLineLen));
    } else {
      idDateLikes =   "\n" + idDateLikes.padLeft(gNumLeftMarginSpaces + gTextWidth);
    }

    // print content and the dateslikes string
    strToPrint += getStrInColor(contentShifted + idDateLikes + "\n", commentColor);
    stdout.write(strToPrint);
  }

  String getAsLine({int len = 20}) {
    String contentToPrint = evaluatedContent.isEmpty? content: evaluatedContent;
    if( len == 0 || len > contentToPrint.length) {
      len = contentToPrint.length;
    }
    
    contentToPrint = contentToPrint.padLeft(len);
    contentToPrint = contentToPrint.replaceAll("\n", " ");
    contentToPrint = contentToPrint.replaceAll("\r", " ");
    String strToPrint = '${contentToPrint.substring(0, len)}... - ${getAuthorName(pubkey)}';

    int strWidth = 40;
    String paddedStrToPrint = strToPrint.padLeft(strWidth);
    paddedStrToPrint = paddedStrToPrint.substring(0, strWidth);

    if( isNotification) {
      paddedStrToPrint = "$gNotificationColor$paddedStrToPrint$gColorEndMarker";
      isNotification = false;
    }
    return paddedStrToPrint;
  }

  String getStrForChannel(int depth) {
    String strToPrint = "";
    String name = getAuthorName(pubkey);    
    String strDate = getPrintableDate(createdAt);
    String tempEvaluatedContent = evaluatedContent;
    String tempContent = evaluatedContent.isEmpty? content: evaluatedContent;
    
    if( isHidden) {
      name = strDate = "<hidden>";
      tempEvaluatedContent = tempContent = "<You have hidden this post>";
    }

    // delete supercedes hidden
    if( isDeleted) {
      name = strDate = "<deleted>";
      tempEvaluatedContent = tempContent = content; // content would be changed so show that 
    }

    if( tempEvaluatedContent=="") 
      tempEvaluatedContent = tempContent;

    const int nameWidthDepth = 16~/gSpacesPerDepth; // how wide name will be in depth spaces
    const int timeWidthDepth = 18~/gSpacesPerDepth;
    int nameWidth = gSpacesPerDepth * nameWidthDepth;
    
    // get name in color and pad it too
    String nameToPrint = name.padLeft(nameWidth).substring(0, nameWidth);
    nameToPrint = getStrInColor(nameToPrint, getNameColor(pubkey));

    String dateToPrint = strDate.padLeft(gSpacesPerDepth * timeWidthDepth).substring(0, gSpacesPerDepth * timeWidthDepth);
    
    // depth above + ( depth numberof spaces = 1) + (depth of time = 2) + (depth of name = 3)
    int contentDepth = depth + 1 + timeWidthDepth + nameWidthDepth;
    int magicNumberDepth6 = 2; // magic number for gSpacesPerDepth == 6
    int finalContentDepthInSpaces = gSpacesPerDepth * contentDepth + magicNumberDepth6;
    int contentPlacementColumn = finalContentDepthInSpaces + gNumLeftMarginSpaces;

    String contentShifted = makeParagraphAtDepth(tempEvaluatedContent, finalContentDepthInSpaces);

    Event? replyToEvent = getReplyToEvent();
    String strReplyTo = "";
    if( replyToEvent != null) {
      //print("in getStrForChannel: got replyTo id = ${replyToEvent.eventData.id}");
      if( replyToEvent.eventData.kind == 1 || replyToEvent.eventData.kind == 42) { // make sure its a kind 1 or 40 message
        if( replyToEvent.eventData.id != id) { // basic self test
          strReplyTo = 'In reply to:"${replyToEvent.eventData.content}"';
          strReplyTo = makeParagraphAtDepth(strReplyTo, finalContentDepthInSpaces + 6); // one extra for content
          
          // add reply to string to end of the content. How it will show:
          contentShifted += ( "\n" + getNumSpaces( contentPlacementColumn + gSpacesPerDepth) +  strReplyTo); 
        }
      }
    }
   
    String msgId = id.substring(0, 3).padLeft(gSpacesPerDepth~/2).padRight(gSpacesPerDepth) ;

    if( isNotification) {
      strToPrint = "$gNotificationColor${getDepthSpaces(depth-1)}$msgId  $dateToPrint    $nameToPrint: $gNotificationColor" +  contentShifted + gColorEndMarker;
      isNotification = false;
    } else {
      strToPrint = "${getDepthSpaces(depth-1)}$msgId  $dateToPrint    $nameToPrint: " +  contentShifted;
    }
    return strToPrint;
  }

  // looks up global map of reactions, if this event has any reactions, and then prints the reactions
  // in appropriate color( in case one is a notification, which is stored in member variable)
  String getReactionStr(int depth) {
    String reactorNames = "";

    if( isHidden  ||  isDeleted) {
      return "";
    }

    if( gReactions.containsKey(id)) {
      reactorNames = "Likes: ";
      int numReactions = gReactions[id]?.length??0;
      List<List<String>> reactors = gReactions[id]??[];
      bool firstEntry = true;
      for( int i = 0; i <numReactions; i++) {
        
        String comma = (firstEntry)?"":", ";

        String reactorId = reactors[i][0];
        if( newLikes.contains(reactorId) && reactors[i][1] == "+") {
          // this is a notifications, print it and then later empty newLikes
          reactorNames += comma + gNotificationColor + getAuthorName(reactorId) + gColorEndMarker;
          firstEntry = false;
        } else {
          // this is normal printing of the reaction. only print for + for now
          if( reactors[i][1] == "+")
            reactorNames += comma + getAuthorName(reactorId);
            firstEntry = false;
        }
      } // end for
      newLikes.clear();
      reactorNames += "";
    }
    return reactorNames;
  }

  // returns the last e tag as reply to event for kind 42 events
  Event? getReplyToEvent() {
    for(int i = tags.length - 1; i >= 0; i--) {
      List tag = tags[i];
      if( tag[0] == 'e') {
        String replyToEventId = tag[1];
        Event? eventInReplyTo = (gStore?.allChildEventsMap[replyToEventId]?.event)??null;
        if( eventInReplyTo != null) {
          if ( [1,42].contains( eventInReplyTo.eventData.kind)) {
            return eventInReplyTo;
          }
        }
      }
    }
    return null;
  } // end getReplyToEvent() 
}

// This is mostly a placeholder for EventData. TODO combine both?
class Event {
  String event;
  String id;
  EventData eventData;
  String originalJson;
  List<String> seenOnRelays;
  bool readFromFile;

  Event(this.event, this.id, this.eventData, this.seenOnRelays, this.originalJson, [this.readFromFile = false]);

  @override
  bool operator ==( other) {
     return (other is Event) && eventData.id == other.eventData.id;
  }

  factory Event.fromJson(String d, String relay, [bool fromFile = false]) {
    try {
      dynamic json = jsonDecode(d);
      if( json.length < 3) {
        String e = "";
        e = json.length > 1? json[0]: "";
        if( gDebug> 0) {
          print("Could not create event. returning dummy event. json.length = ${json.length} string d= $d $e");
        }
        return Event(e,"",EventData("non","", 0, 0, "", [], [], [], [[]], {}), [relay], "[json]", fromFile);
      }
      EventData newEventData = EventData.fromJson(json[2]);
      if( !fromFile) 
        newEventData.isNotification = true;
      return Event(json[0] as String, json[1] as String, newEventData, [relay], d, fromFile );
    } on Exception catch(e) {
      if( gDebug> 0) {
        print("Could not create event. returning dummy event. $e");
        print("problem str: $d\n");
      }
      return Event("","",EventData("non","", 0, 0, "", [], [], [], [[]], {}), [relay], "[json]", fromFile);
    }
  }

  void printEvent(int depth, bool topPost) {
    eventData.printEventData(depth, topPost);
    //stdout.write("\n$originalJson --------------------------------\n\n");
  }

  @override 
  String toString() {
    return '$eventData     Seen on: ${seenOnRelays[0]}\n';
  }
}

void addToHistogram(Map<String, int> histogram, List<String> pTags) {
  Set tempPtags = {};
  pTags.retainWhere((x) =>  tempPtags.add(x));

  for(int i = 0; i < pTags.length; i++ ) {
    String pTag = pTags[i];
    if( histogram.containsKey(pTag)) {
      int? val = histogram[pTag];
      if( val != null) {
        histogram[pTag] = ++val;
      } else {
      }
    } else {
      histogram[pTag] = 1;
    }
  }
  //return histogram;
}

class HistogramEntry {
  String str;
  int    count;
  HistogramEntry(this.str, this.count);
  static int histogramSorter(HistogramEntry a, HistogramEntry b) {
    if( a.count < b.count ) {
      return 1;
    } if( a.count == b.count ) {
      return 0;
    } else {
      return -1;
    }
  }
}

// return the numMostFrequent number of most frequent p tags ( user pubkeys) in the given events
List<String> getpTags(Set<Event> events, int numMostFrequent) {
  List<HistogramEntry> listHistogram = [];
  Map<String, int>   histogramMap = {};
  for(var event in events) {
    addToHistogram(histogramMap, event.eventData.pTags);
  }

  histogramMap.forEach((key, value) {listHistogram.add(HistogramEntry(key, value));/* print("added to list of histogramEntry $key $value"); */});
  listHistogram.sort(HistogramEntry.histogramSorter);
  List<String> ptags = [];
  for( int i = 0; i < listHistogram.length && i < numMostFrequent; i++ ) {
    ptags.add(listHistogram[i].str);
  }

  return ptags;
}

// From the list of events provided, lookup the lastst contact information for the given user/pubkey
Event? getContactEvent(String pubkey) {

    // get the latest kind 3 event for the user, which lists his 'follows' list
    if( gKindONames.containsKey(pubkey)) {
      Event? e = (gKindONames[pubkey]?.latestContactEvent)??null;
      return e;
    }

    return null;
}

// for the user userPubkey, returns the relay of its contact contactPubkey
String getRelayOfUser(String userPubkey, String contactPubkey) {

  if(gDebug > 0) print("In getRelayOfUser: Searching relay for contact $contactPubkey" );

  String relay = "";
  if( userPubkey == "" || contactPubkey == "") {
    return "";
  }

  if( gContactLists.containsKey(userPubkey)) {
    List<Contact>? contacts = gContactLists[userPubkey];
    if( contacts != null) {
      for( int i = 0; i < contacts.length; i++) {
        if( contacts[i].id == contactPubkey) {
          relay = contacts[i].relay;
          return relay;
        }
      }
    }
  }
  // if not found return empty string
  return relay;
}

// If given event is kind 0 event, then populates gKindONames with that info
// returns true if entry was created or modified, false otherwise
bool processKind0Event(Event e) {
  if( e.eventData.kind != 0) {
    return false;
  }

  String content = e.eventData.content;
  if( content.isEmpty) {
    return false;
  }

  String name = "";
  String about = "";
  String picture = "";

  try {
    dynamic json = jsonDecode(content);
    name = json["name"];
    about = json["about"];    
    picture = json["picture"];    
  } catch(ex) {
    //if( gDebug != 0) print("Warning: In processKind0Event: caught exception for content: ${e.eventData.content}");
    if( name.isEmpty) {
      //return false;
    }
  }

  bool newEntry = false, entryModified = false;
  if( !gKindONames.containsKey(e.eventData.pubkey)) {    
    gKindONames[e.eventData.pubkey] = UserNameInfo(e.eventData.createdAt, name, about, picture, null);

    newEntry = true;;
  } else {
    int oldTime = gKindONames[e.eventData.pubkey]?.createdAt??0;
    if( oldTime < e.eventData.createdAt) {
      Event? oldContactEvent = gKindONames[e.eventData.pubkey]?.latestContactEvent;
      gKindONames[e.eventData.pubkey] = UserNameInfo(e.eventData.createdAt, name, about, picture, oldContactEvent);
      entryModified = true;;
    }
  }

  if(gDebug > 0) { 
    print("At end of processKind0Events: for name = $name ${newEntry? "added entry": ( entryModified?"modified entry": "No change done")} ");
  }
  return newEntry || entryModified;
}

// If given event is kind 3 event, then populates gKindONames with contact info
// returns true if entry was created or modified, false otherwise
bool processKind3Event(Event newContactEvent) {
  if( newContactEvent.eventData.kind != 3) {
    return false;
  }

  bool newEntry = false, entryModified = false;
  if( !gKindONames.containsKey(newContactEvent.eventData.pubkey)) {
    gKindONames[newContactEvent.eventData.pubkey] = UserNameInfo(null, null, null, null, newContactEvent, newContactEvent.eventData.createdAt);
    newEntry = true;;
  } else {
    // if entry already exists, then check its old time and update only if we have a newer entry now
    int oldTime = gKindONames[newContactEvent.eventData.pubkey]?.createdAtKind3??0;
    if( oldTime < newContactEvent.eventData.createdAt) {
      int? createdAt = gKindONames[newContactEvent.eventData.pubkey]?.createdAt??null;
      String? name = gKindONames[newContactEvent.eventData.pubkey]?.name, about = gKindONames[newContactEvent.eventData.pubkey]?.about, picture = gKindONames[newContactEvent.eventData.pubkey]?.picture;
      
      gKindONames[newContactEvent.eventData.pubkey] = UserNameInfo(createdAt, name, about, picture, newContactEvent, newContactEvent.eventData.createdAt );
      entryModified = true;;
    }
  }

  if(gDebug > 0) { 
      print("At end of processKind3Events:  ${newEntry? "added entry": ( entryModified?"modified entry": "No change done")} ");
  }
  return newEntry || entryModified;
}

// returns name by looking up global list gKindONames, which is populated by kind 0 events
String getAuthorName(String pubkey, [int len = 3]) {
  String max3(String v) => v.length > len? v.substring(0,len) : v.substring(0, v.length);
  String name = "";
  if( gKindONames[pubkey]?.name == null || gKindONames[pubkey]?.name?.length == 0)
     name = max3(pubkey);
  else 
    name = (gKindONames[pubkey]?.name)??max3(pubkey);
  return name;
}

// returns full public key(s) for the given username( which can be first few letters of pubkey, or the user name)
Set<String> getPublicKeyFromName(String inquiredName) {
  if( inquiredName.length < 2) {
    return {};
  }
  Set<String> pubkeys = {};
  gKindONames.forEach((pubkey, userInfo) {
    // check both the user name, and the pubkey to search for the user
    // check username 
    if( userInfo.name != null) {
      int minNameLen = min( inquiredName.length, (userInfo.name?.length)??0);
      if( inquiredName.toLowerCase() == userInfo.name?.substring(0, minNameLen).toLowerCase()) {
        pubkeys.add(pubkey);
      }
    }

    // check public key
    if( inquiredName.length <= pubkey.length) {
      if( pubkey.substring(0, inquiredName.length) == inquiredName) {
        pubkeys.add(pubkey);
      }
    }
  });

  return pubkeys;
}

// returns the seconds since eponch N days ago
int getSecondsDaysAgo( int N) {
  return  DateTime.now().subtract(Duration(days: N)).millisecondsSinceEpoch ~/ 1000;
}

void printUnderlined(String x) =>  { stdout.write("$x\n${getNumDashes(x.length)}\n")}; 

void printDepth(int d) {
  for( int i = 0; i < gSpacesPerDepth * d + gNumLeftMarginSpaces; i++) {
    stdout.write(" ");
  }
}

void printCenteredHeadline(displayName) {
  int numDashes = 10; // num of dashes on each side
  int startText = gNumLeftMarginSpaces + ( gTextWidth - (displayName.length + 2 * numDashes)) ~/ 2; 
  if( startText < 0) 
    startText = 0;

  String str = getNumSpaces(startText) + getNumDashes(numDashes) + displayName + getNumDashes(numDashes);
  print(str);
   
}

String getDepthSpaces(int d) {
  String str = "";
  for( int i = 0; i < gSpacesPerDepth * d + gNumLeftMarginSpaces; i++) {
    str += " ";
  }
  return str;
}

String getNumSpaces(int num) {
  String s = "";
  for( int i = 0; i < num; i++) {
    s += " ";
  }
  return s;
}

String getNumDashes(int num, [String dashType = "-"]) {
  String s = "";
  for( int i = 0; i < num; i++) {
    s += dashType;
  }
  return s;
}

// make a paragraph of s that starts at numSpaces ( from screen left), and does not extend beyond gTextWidth+gNumLeftMarginSpaces. break it, or add 
// a newline if it goes beyond gTextWidth + gNumLeftMarginSpaces
String makeParagraphAtDepth(String s, int depthInSpaces) {
  
  String newString = "";
  String spacesString = getNumSpaces(depthInSpaces + gNumLeftMarginSpaces);


  int lenPerLine = gTextWidth - depthInSpaces;
  //print("In makeParagraphAtDepth: gNumLeftMarginSpaces = $gNumLeftMarginSpaces depthInSPaces = $depthInSpaces LenPerLine = $lenPerLine gTextWidth = $gTextWidth ");
  for(int startIndex = 0; startIndex < s.length; ) {
    List listCulledLine = getLineWithMaxLen(s, startIndex, lenPerLine, spacesString);

    String line = listCulledLine[0];
    int lenReturned = listCulledLine[1] as int;

    //print("In makeParagraphAtDepth: line len = ${line.length}  lenReturned = $lenReturned");

    if( line.length == 0 || lenReturned == 0) break;

    newString += line;
    startIndex += lenReturned;
  }

  return newString;
}

// returns from string[startIndex:] the first len number of chars. no newline is added. 
List getLineWithMaxLen(String s, int startIndex, int lenPerLine, String spacesString) {

  if( startIndex >= s.length)
    return ["", 0];

  String line = "";

  int firstLineLen = -1;
  // if length required is greater than the length of string remaing, return whatever remains
  int numCharsInLine = 0;

  int i = startIndex;
  for(; i < startIndex + lenPerLine && i < s.length; i++) {
    line += s[i];
    numCharsInLine ++;

    if( s[i] == "\n") {
      i++;
      numCharsInLine = 0;
      line += spacesString;
      break;
    }
  }


  //print(" ${numCharsInLine > lenPerLine}  ||  ${( (numCharsInLine == lenPerLine) && (s.length > startIndex + numCharsInLine))}");
  if( numCharsInLine > lenPerLine || ( (numCharsInLine == lenPerLine) && (s.length > startIndex + numCharsInLine) )) {
    bool lineBroken = false;

    // line is broken only if the returned line is the longest it can be, and
    // if its length is greater than the gMaxLenBrokenWord constant
    if( line.length >= lenPerLine &&  line.length > gMaxLenUnbrokenWord  ) {
      //print("   line ending seems to be cutting words");
      int i = line.length - 1;

      // find a whitespace character
      for( ; i > 0 && !isWordSeparater(line[i]); i--);
      // for ended 

      if( line.length - i  < gMaxLenUnbrokenWord) {

        // break the line here if its not a word separator
        if( isWordSeparater(line[i])) {
          //print("    isWordSeparater i = $i line[i] = ${line[i]}");
          firstLineLen = i;
          int newLineStart = i + 1;
          if( line[i] != ' ')
            newLineStart = i;
          line = line.substring(0, i) + "\n" + spacesString + line.substring(newLineStart, line.length);
          lineBroken = true;
        }
      }
    }


    if( !lineBroken ) {
      if( s.length > i ) {
        line += "\n";
        line += spacesString;
      }
    }
  }

  return  [line, i - startIndex];
}

bool nonEnglish(String str) {
  bool result = false;
  return result;
}

bool isNumeric(String s) {
 return double.tryParse(s) != null;
}

bool isWordSeparater(String s) {
  if( s.length != 1) {
    return false;
  }
  return s[0] == ' ' || s[0] == '\n' || s[0] == '\r' || s[0] == '\t' 
      || s[0] == ',' || s[0] == '.' || s[0] == '-' || s[0] == '('|| s[0] == ')';
}


bool isWhitespace(String s) {
  if( s.length != 1) {
    return false;
  }
  return s[0] == ' ' || s[0] == '\n' || s[0] == '\r' || s[0] == '\t';
}

extension StringX on String {


  int isChannelPageNumber(int max) {
  
    if(this.length < 2 || this[0] != '/') {
      return 0;
    } 

    String rest = this.substring(1);

    //print("rest = $rest");
    int? n = int.tryParse(rest);
    if( n != null) {
      if( n < max)
        return n;
    }
    return 0;
  }

  isEnglish( ) {
    // since smaller words can be smileys they should not be translated
    if( length < 10) 
      return true;
    
    if( !isLatinAlphabet())
      return false;

    if (isFrench())
      return false;

    return true;
  }

  isPortugese() {
    false; // https://1000mostcommonwords.com/1000-most-common-portuguese-words/
  }

  bool isFrench() {

    // https://www.thoughtco.com/most-common-french-words-1372759
    List<String> frenchWords = ["oui", "je", "le", "un", "de", "et", "merci", "une", "ce", "pas"];
    for( int i = 0; i < frenchWords.length; i++) {
      if( this.toLowerCase().contains(" ${frenchWords[i]} ")) {
        if( gDebug > 0) print("isFrench: Found ${this.toString()} is french"); 
        return true;
      }
    }
    return false;
  }

  isLatinAlphabet({caseSensitive = false}) {
    int countLatinletters = 0;
    for (int i = 0; i < length; i++) {
      final target = caseSensitive ? this[i] : this[i].toLowerCase();
      if ( (target.codeUnitAt(0) > 96 && target.codeUnitAt(0) < 123)  || ( isNumeric(target) ) || isWhitespace(target)) {
        countLatinletters++; 
      }
    }
    
    if( countLatinletters < ( 40.0/100 ) * length ) {
      if( gDebug > 0) print("in isLatinAlphabet: latin letters: $countLatinletters and total = $length ");
      return false;
    } else {
      return true;
    }
  }
}    

// The contact only stores id and relay of contact. The actual name is stored in a global variable/map
class Contact {
  String id, relay;
  Contact(this.id, this.relay);

  @override 
  String toString() {
    return 'id: $id ( ${getAuthorName(id)})     relay: $relay';
  }
}

String addEscapeChars(String str) {
  return str.replaceAll("\"", "\\\"");
}

String getShaId(String pubkey, int createdAt, String kind, String strTags, String content) {
  String buf = '[0,"$pubkey",$createdAt,$kind,[$strTags],"$content"]';
  var bufInBytes = utf8.encode(buf);
  var value = sha256.convert(bufInBytes);
  return value.toString();
}

// get printable date from seconds since epoch
String getPrintableDate(int createdAt) {
  final df1 = DateFormat('hh:mm a');
  final df2 = DateFormat(DateFormat.ABBR_MONTH_DAY);
  String strDate = df1.format(DateTime.fromMillisecondsSinceEpoch(createdAt*1000));
  strDate += " ${df2.format(DateTime.fromMillisecondsSinceEpoch(createdAt*1000))}";
  return strDate;
}

/*
 * Returns true if this is a valid direct message to just this user
 */
bool isValidDirectMessage(EventData directMessageData) {
  bool validUserMessage = false;

  List<String> allPtags = [];
  directMessageData.tags.forEach((tag) {
    if( tag.length < 2 )
      return;
    if( tag[0] == "p" && tag[1].length == 64) { // basic length sanity test
      allPtags.add(tag[1]);
    }
  });

  if( directMessageData.pubkey == userPublicKey && allPtags.length == 1) {
    validUserMessage = true; // case where this user is sender
  } else {
    if ( directMessageData.pubkey != userPublicKey) {
      if( allPtags.length == 1 && allPtags[0] == userPublicKey) {
        validUserMessage = true; // case where this user is recipeint 
      }
    }
  }
  return validUserMessage;
}

String getRandomPrivKey() {
  FortunaRandom fr = FortunaRandom();
  final _sGen = Random.secure();;
  fr.seed(KeyParameter(
      Uint8List.fromList(List.generate(32, (_) => _sGen.nextInt(255)))));

  BigInt randomNumber = fr.nextBigInteger(256);
  String strKey = randomNumber.toRadixString(16);
  if( strKey.length < 64) {
    int numZeros = 64 - strKey.length;
    for(int i = 0; i < numZeros; i++) {
      strKey = "0" + strKey;
    }
  }
  return strKey;
}
   
// pointy castle source https://github.com/PointyCastle/pointycastle/blob/master/tutorials/aes-cbc.md
// https://github.com/bcgit/pc-dart/blob/master/tutorials/aes-cbc.md
// 3 https://github.com/Dhuliang/flutter-bsv/blob/42a2d92ec6bb9ee3231878ffe684e1b7940c7d49/lib/src/aescbc.dart

/// Decrypt data using self private key
String myPrivateDecrypt( String privateString, 
                         String publicString, 
                         String b64encoded,
                        [String b64IV = ""]) {

  Uint8List encdData = convert.base64.decode(b64encoded);
  final rawData = myPrivateDecryptRaw(privateString, publicString, encdData, b64IV);
  return convert.Utf8Decoder().convert(rawData.toList());
}


Map<String, List<List<int>>> gMapByteSecret = {};

Uint8List myPrivateDecryptRaw( String privateString, 
                               String publicString, 
                               Uint8List cipherText,
                               [String b64IV = ""]) {
try {

  List<List<int>> byteSecret = [];
  if( gMapByteSecret.containsKey(publicString)) {
      byteSecret = gMapByteSecret[publicString]??[];
  }

  if( byteSecret.isEmpty) {
    byteSecret = Kepler.byteSecret(privateString, publicString);;
    gMapByteSecret[publicString] = byteSecret;
  }

  final secretIV = byteSecret;
  
  final key = Uint8List.fromList(secretIV[0]);

  final iv = b64IV.length > 6
      ? convert.base64.decode(b64IV)
      : Uint8List.fromList(secretIV[1]);


  CipherParameters params = new PaddedBlockCipherParameters(
      new ParametersWithIV(new KeyParameter(key), iv), null);

  PaddedBlockCipherImpl cipherImpl = new PaddedBlockCipherImpl(
      new PKCS7Padding(), new CBCBlockCipher(new AESEngine()));


  cipherImpl.init(false,
                  params as PaddedBlockCipherParameters<CipherParameters?,
                                                        CipherParameters?>);

  final Uint8List  finalPlainText = Uint8List(cipherText.length); // allocate space

  var offset = 0;
  while (offset < cipherText.length - 16) {
    offset += cipherImpl.processBlock(cipherText, offset, finalPlainText, offset);
  }

  //remove padding
  offset += cipherImpl.doFinal(cipherText, offset, finalPlainText, offset);
  assert(offset == cipherText.length);
  return  finalPlainText.sublist(0, offset);
} catch(e) {
    if( gDebug >= 0) print("Decryption error =  $e");
    return Uint8List(0);
}
}

/// Encrypt data using self private key in nostr format ( with trailing ?iv=)
String myEncrypt( String privateString, 
                         String publicString, 
                         String plainText) {
  Uint8List uintInputText = convert.Utf8Encoder().convert(plainText);
  final encryptedString = myEncryptRaw(privateString, publicString, uintInputText);
  return encryptedString;
}

String myEncryptRaw( String privateString, 
                     String publicString, 
                     Uint8List uintInputText) {
  final secretIV = Kepler.byteSecret(privateString, publicString);
  final key = Uint8List.fromList(secretIV[0]);

  // generate iv  https://stackoverflow.com/questions/63630661/aes-engine-not-initialised-with-pointycastle-securerandom
  FortunaRandom fr = FortunaRandom();
  final _sGen = Random.secure();;
  fr.seed(KeyParameter(
      Uint8List.fromList(List.generate(32, (_) => _sGen.nextInt(255)))));
  final iv = fr.nextBytes(16); //Uint8List.fromList(secretIV[1]);
   
  CipherParameters params = new PaddedBlockCipherParameters(
      new ParametersWithIV(new KeyParameter(key), iv), null);

  PaddedBlockCipherImpl cipherImpl = new PaddedBlockCipherImpl(
      new PKCS7Padding(), new CBCBlockCipher(new AESEngine()));

  cipherImpl.init(true,  // means to encrypt
                  params as PaddedBlockCipherParameters<CipherParameters?,
                                                        CipherParameters?>);
  
  final Uint8List  outputEncodedText = Uint8List(uintInputText.length + 16); // allocate space

  var offset = 0;
  while (offset < uintInputText.length - 16) {
    offset += cipherImpl.processBlock(uintInputText, offset, outputEncodedText, offset);
  }

  //add padding 
  offset += cipherImpl.doFinal(uintInputText, offset, outputEncodedText, offset);
  assert(offset == uintInputText.length);
  final Uint8List finalEncodedText = outputEncodedText.sublist(0, offset);

  String stringIv = convert.base64.encode(iv);;
  String outputPlainText = convert.base64.encode(finalEncodedText);
  outputPlainText = outputPlainText + "?iv=" + stringIv;
  return  outputPlainText;
}


Set<Event> readEventsFromFile(String filename) {
  Set<Event> events = {};
  final File  file   = File(filename);

  // sync read
  try {
    List<String> lines = file.readAsLinesSync();
    for( int i = 0; i < lines.length; i++ ) {
        Event e = Event.fromJson(lines[i], "", true);
        events.add(e);
    }
  } on Exception catch(e) {
    //print("cannot open file $gEventsFilename");
    if( gDebug > 0) print("Could not open file. error =  $e");
  }

  if( gDebug > 0) print("In readEventsFromFile: returning ${events.length} total events");
  return events;
}

bool isValidPubkey(String pubkey) {
  if( pubkey.length == 64) {
    return true;
  }

  return false;
}


List<String> getEncryptedChannelKeys(List<DirectMessageRoom> directRooms, Map<String, Tree> tempChildEventsMap, String channelId) {
  Event? e = tempChildEventsMap[channelId]?.event;
  if( e != null) {
    //print("\n----------------\nIn getEncryptedChannelKeys for encrypted channel $channelId");
    String creatorPubKey = e.eventData.pubkey;
    for( int i = 0; i < directRooms.length; i++) {
      DirectMessageRoom room = directRooms[i];
      if( room.otherPubkey == creatorPubKey) {
        //print("got other pubkey $creatorPubKey");
        for( int j = 0; j < room.messageIds.length; j++) {
          String messageId = room.messageIds[j];
          if( channelId == "14eda528a651fa55ac706280711d616f57ab449a9bc4a97efcd1e236e53a1044" ) {
            //printInColor( "j = $j messageId = ${messageId}\n", redColor);
          }

          Event? messageEvent = tempChildEventsMap[messageId]?.event;
          if( messageEvent != null) {
            //print("got a message which is: ${messageEvent.eventData.evaluatedContent}");
            //print(messageEvent.eventData.getStrForChannel(0));
            String evaluatedContent = messageEvent.eventData.evaluatedContent;
            if( evaluatedContent.startsWith("App Encrypted Channels:")) {
              //print("got App");
              if( channelId == "14eda528a651fa55ac706280711d616f57ab449a9bc4a97efcd1e236e53a1044" ) {
                //print("evaluatedContent = $evaluatedContent");
                //print("num messages: ${room.messageIds.length}");
              }

              if( evaluatedContent.contains(channelId) && evaluatedContent.length == 288) {
                //print("in getEncryptedChannelKeys:    success: got password in pvt message: $evaluatedContent");
                //print("len of evaluated content: ${evaluatedContent.length} ");
                String priKey = evaluatedContent.substring(159, 159 + 64);
                String pubKey = evaluatedContent.substring(224, 224 + 64);

                if( priKey.length == 64 && pubKey.length == 64) {
                  return [priKey, pubKey];
                }
              }
            }
          } else {
            print("could not get message event");
          }
        }
      }
    }
  }
  return [];
}

String myGetPublicKey(String prikey) {
  String pubkey = getPublicKey(prikey);

  if( pubkey.length < 64) {
    int numZeros = 64 - pubkey.length;
    for(int i = 0; i < numZeros; i++) {
      pubkey = "0" + pubkey;
    }
  }
  return pubkey;
}
