/*
  StreamGoose Twitch backend — Go reference implementation
  
  Created by Luxferre in 2024, released into public domain
*/

package main

import (
  "fmt"
  "log"
  "strings"
  "strconv"
  "time"
  "bytes"
  "net"
  "net/http"
  "io/ioutil"
  "path/filepath"
  "unicode/utf8"
  "encoding/json"
)

/* Twitch badge URL cache */
var TW_BADGE_CACHE map[string]string
/* Twitch OAuth global cache */
var TW_OAUTH_TOKEN string
var TW_CLIENT_ID string
var TW_BROADCASTER_ID string

/* helper function to write a string to the socket */
func tcpwritestr(conn net.Conn, str string) int {
  i, err := conn.Write([]byte(str))
  if err != nil {
    log.Fatal("Error writing to socket: ", err)
  }
  return i
}

/* helper function to read a string from the socket */
func tcpreadstr(conn net.Conn) string {
  reply := make([]byte, 2048)
  _, err := conn.Read(reply)
  if err != nil {
    log.Fatal("Error reading from socket: ", err)
  }
  return string(reply)
}

/* non-IRC API request helper function */
func tw_api_req(url string, data string) []byte {
  method := "GET"
  if len(data) > 0 {
    method = "POST"
  }
  req, err := http.NewRequest(method, "https://api.twitch.tv/helix" + url, bytes.NewReader([]byte(data)))
  req.Header.Set("Authorization", "Bearer " + TW_OAUTH_TOKEN)
  req.Header.Set("Client-Id", TW_CLIENT_ID)
  if len(data) > 0 {
    req.Header.Set("Content-Type", "application/json")
  }

  client := &http.Client{}
  resp, err := client.Do(req)
  if err != nil {
    log.Fatal(err)
  }
  body, _ := ioutil.ReadAll(resp.Body)
  resp.Body.Close()
  return body
}

/* badge cache population function */
func tw_fetch_badge_cache(channelname string) map[string]string {
  readycache := make(map[string]string)
  var ustruct map[string]interface{}
  /* get broadcaster ID from the channel name */
  body := tw_api_req("/users?login=" + channelname, "")
  if len(body) > 0 {
    err := json.Unmarshal(body, &ustruct)
    if err == nil {
      if _, ok := ustruct["data"]; ok && len(ustruct["data"].([]interface{})) > 0 {
        bdata := ustruct["data"].([]interface{})[0].(map[string]interface{})
        TW_BROADCASTER_ID = bdata["id"].(string)
      }
    }
  }
  /* get the global badges */
  body = tw_api_req("/chat/badges/global", "")
  if len(body) > 0 {
    err := json.Unmarshal(body, &ustruct)
    if err == nil {
      if _, ok := ustruct["data"]; ok && len(ustruct["data"].([]interface{})) > 0 {
        for _, bdata := range ustruct["data"].([]interface{}) { /* fetch individual badges */
          bsetid := bdata.(map[string]interface{})["set_id"].(string)
          for _, bver := range bdata.(map[string]interface{})["versions"].([]interface{}) {
            bid := bver.(map[string]interface{})["id"].(string)  /* badge ID */
            burl := bver.(map[string]interface{})["image_url_1x"].(string) /* badge primary URL */
            readycache[bsetid + "_" + bid] = burl
          }
        }
      }
    }
  }
  if TW_BROADCASTER_ID != "" { /* also get the channel-specific badges */
    body = tw_api_req(fmt.Sprintf("/chat/badges?broadcaster_id=%s", TW_BROADCASTER_ID), "")
    if len(body) > 0 {
      err := json.Unmarshal(body, &ustruct)
      if err == nil {
        if _, ok := ustruct["data"]; ok && len(ustruct["data"].([]interface{})) > 0 {
          for _, bdata := range ustruct["data"].([]interface{}) { /* fetch individual badges */
            bsetid := bdata.(map[string]interface{})["set_id"].(string)
            for _, bver := range bdata.(map[string]interface{})["versions"].([]interface{}) {
              bid := bver.(map[string]interface{})["id"].(string)  /* badge ID */
              burl := bver.(map[string]interface{})["image_url_1x"].(string) /* badge primary URL */
              readycache[bsetid + "_" + bid] = burl
            }
          }
        }
      }
    }
  }
  return readycache
}

/* emote handling function */
func tw_handle_emotes(text string, emote_data string) string {
  replacements := make(map[string]string) /* future replacement string cache */
  parts := strings.Split(emote_data, "/")
  textlen := utf8.RuneCountInString(text)
  for _, emotedesc := range parts { /* iterate over emote descriptors */
    emote_id, emote_range, _ := strings.Cut(emotedesc, ":")
    emote_ranges := strings.Split(emote_range, ",") /* there can be several ranges */
    for _, r := range emote_ranges {
      estart, eend, _ := strings.Cut(r, "-")
      estart_int, _ := strconv.Atoi(estart)
      eend_int, _ := strconv.Atoi(eend)
      if estart_int < 0 {
        estart_int = 0
      }
      if eend_int >= textlen {
        eend_int = textlen - 1
      }
      /* use rune conversion for correct offsets */
      replacements[string([]rune(text)[estart_int:eend_int + 1])] = "#TWEMOTE-" + emote_id + "#"
    }
  }
  for from, to := range replacements { /* iterate over replacements */
    text = strings.ReplaceAll(text, from, to)
  }
  return text  
}

/* main message procesing function */
func tw_process_message(rawmsg string) {
  parts := strings.Split(rawmsg, " :") /* get raw message parts */
  /* first part is metadata, second is a full IRC username,
      the rest is the message ending with \r\n */
  metadata_str := strings.TrimSpace(parts[0])
  msg_text := ""
  if(len(parts) > 2) {
    msg_text = strings.Join(parts[2:], " :")
    msg_text = strings.ReplaceAll(msg_text, "\u0000", "") /* remove excess null bytes */
  }
  metadata := make(map[string]string) /* store parsed metadata here */
  parts = strings.Split(metadata_str, ";")
  for _, field := range parts { /* iterate over metadata fields */
    if strings.Contains(field, "=") {
      fieldkey, fieldval, _ := strings.Cut(field, "=")
      metadata[fieldkey] = fieldval
    }
  }
  /* if the message came from a real user, this is the one we should process */
  /* but first, check if this is a single-message deletion event */
  if strings.HasPrefix(rawmsg, "@login=") && strings.Contains(rawmsg, " CLEARMSG ") {
    /* prepare output message */
    out_message := ChatMessage {
      Type: "delmsg",
      NativeId: metadata["target-msg-id"],
    }
    /* send it to the message broker via the message_transport channel */
    message_transport <- out_message
  } else if strings.Contains(rawmsg, " CLEARCHAT ") { /* bulk message deletion event */
    /* prepare output message */
    out_message := ChatMessage {
      Type: "deluser",
      AuthorId: metadata["target-user-id"],
    }
    /* send it to the message broker via the message_transport channel */
    message_transport <- out_message
  } else if strings.HasPrefix(rawmsg, "@badge-info=") { /* normal message or usernotice */
    /* convert the timestamp */
    ts, err := strconv.ParseInt(metadata["tmi-sent-ts"], 10, 0)
    if err != nil { /* fallback to current time if anything */
      ts = time.Now().UnixMilli()
    }
    sent_dt := time.UnixMilli(ts)
    
    /* process Twitch emotes before any positional changes can occur */
    if _, ok := metadata["emotes"]; ok && len(metadata["emotes"]) > 0 {
      msg_text = tw_handle_emotes(msg_text, metadata["emotes"])
    }

    /* sanitize \x01ACTION... \x01 in the text*/
    msg_text = strings.ReplaceAll(msg_text, "\u0001ACTION", "/me")
    msg_text = strings.ReplaceAll(msg_text, "\u0001", "")

    /* prepare output message */
    out_message := ChatMessage {
      Type: "tw",
      NativeId: metadata["id"],
      AuthorId: metadata["user-id"],
      Username: metadata["display-name"],
      Timestamp: sent_dt.UTC().Format("2006-01-02T15:04:05Z"),
      Text: strings.TrimSpace(msg_text),
      Icon: "",
    }

    /* process username badges */
    if _, ok := metadata["badges"]; ok {
      badges := strings.Split(metadata["badges"], ",") /* they are comma-delimited */
      if len(badges) > 0 { /* process badge names */
        for _, badge := range badges { /* extract the badge name and version */
          bname, bver, _ := strings.Cut(badge, "/")
          if len(bname) > 0 {
            out_message.Type += "^" + bname + "^badgetw" + bname + "_" + bver
          }
        }
      }
    }

    /* process username color preference */
    if _, ok := metadata["color"]; ok && len(metadata["color"]) > 0 {
      out_message.Type += "^color" + metadata["color"]
    }

    /* process third-party action */
    if strings.HasPrefix(out_message.Text, "/me") {
      out_message.Type += "^me"
      out_message.Text = strings.TrimSpace(out_message.Text[4:])
    }

    /* process system messages (e.g. in USERNOTICE event) */
    if _, ok := metadata["system-msg"]; ok && len(metadata["system-msg"]) > 0 {
      sysmsg := strings.ReplaceAll(metadata["system-msg"], "\\s", " ")
      oldtext := out_message.Text
      out_message.Text = strings.Join(strings.Fields(sysmsg), " ")
      if len(oldtext) > 0 {
        out_message.Text += ": " + oldtext
      }
    }

    /* finally, send it to the message broker via the message_transport channel */
    message_transport <- out_message
  }
}

/* entry point */
func twitch_run(config map[string]interface{}) {
  token_path := filepath.Join(APP_ROOT_DIR, "auth", "twitch-token.txt")
  token, err := ioutil.ReadFile(token_path)
  token_err_msg := "[twitch] No Twitch token file provided or it is empty. "
  token_err_msg += "Please visit https://twitchapps.com/tmi/, generate a token there "
  token_err_msg += "and paste it into the " + token_path +" file!"
  if err != nil {
    log.Fatal(token_err_msg + "\nDetailed error: ", err)
  }
  config["tw_token"] = strings.TrimSpace(string(token))
  if len(config["tw_token"].(string)) < 1 {
    log.Fatal(token_err_msg)
  }

  cid_path := filepath.Join(APP_ROOT_DIR, "auth", "twitch-cid.txt")
  cid, err := ioutil.ReadFile(cid_path)
  cid_err_msg := "[twitch] No Twitch client file provided or it is empty. "
  cid_err_msg += "Please visit https://twitchapps.com/tmi/, find a client ID in the source "
  cid_err_msg += "and paste it into the " + cid_path +" file!"
  if err != nil {
    log.Fatal(cid_err_msg + "\nDetailed error: ", err)
  }

  /* populate TW_OAUTH_TOKEN and TW_CLIENT_ID globals for non-IRC API calls */
  TW_CLIENT_ID = strings.TrimSpace(string(cid))
  TW_OAUTH_TOKEN, _ = strings.CutPrefix(config["tw_token"].(string), "oauth:")
  
  /* load the Twitch badge cache */
  chname, _ := strings.CutPrefix(config["tw_channel"].(string), "#")
  TW_BADGE_CACHE = tw_fetch_badge_cache(chname)


  /* Connect to the Twitch IRC server */
  conn, err := net.Dial("tcp", "irc.chat.twitch.tv:6667")
  if err != nil {
    log.Fatal("[twitch] Error connecting to Twitch: ", err)
  }

  tcpwritestr(conn, fmt.Sprintf("PASS %s\n", config["tw_token"]))
  tcpwritestr(conn, fmt.Sprintf("NICK %s\n", config["tw_nickname"]))
  tcpwritestr(conn, fmt.Sprintf("JOIN %s\n", config["tw_channel"]))
  /* request advanced chat message capabilities */
  tcpwritestr(conn, "CAP REQ :twitch.tv/membership twitch.tv/tags twitch.tv/commands\n")

  /* set up subscriptions and start the WebSocket event loop goroutine */
  go tw_ws_pubsub_readloop()

  log.Println("StreamGoose Twitch Go backend connected")

  var rawmsg string

  for { /* main loop */
    rawmsg = tcpreadstr(conn)
    if _, ok := config["debug"]; ok && config["debug"].(bool) == true {
      log.Println(rawmsg)
    }
    if strings.HasPrefix(rawmsg, "PING") { /* respond to the PING message */
      tcpwritestr(conn, "PONG\n")
    } else { /* run main procesing */
      tw_process_message(rawmsg)
    }
  }
}
