JavaScript-style Debug Console
// This source code is subject to the terms of the Mozilla Public License 2.0 at https://mozilla.org/MPL/2.0/
// © algotraderdev
// @version=5
// @description JavaScript-style Debug Console for Pine Coders
library("Console", overlay = true)

// Enum for log levels.
int LOG_LEVEL_DEBUG = 0
int LOG_LEVEL_INFO = 1
int LOG_LEVEL_WARN = 2
int LOG_LEVEL_ERROR = 3

// @variable The index of the column containing timestamps in the console table.
int COLUMN_TIMESTAMP = 0
// @variable The index of the column containing log messages in the console table.
int COLUMN_MSG = 1

// @function Returns a human-readable timestamp.
// @returns The formatted timestamp.
timestamp() =>
    switch
        timeframe.isdwm => str.format_time(time, 'yyyy-MM-dd', syminfo.timezone)
        timeframe.isseconds => str.format_time(time, 'yyyy-MM-dd HH:mm:ss', syminfo.timezone)
        => str.format_time(time, 'yyyy-MM-dd HH:mm', syminfo.timezone)

// @type A formatted log message to display in the console, including a timestamp.
// @field timestamp The formatted timestamp for the log message.
// @field level The log level.
// @field msg The message to display.
export type Log
    string timestamp
    int level
    string msg

// @type A class representing a simple counter for a label.
// @field label The label to track.
// @field count The count for the label.
export type Counter
    string label
    int count

// @type A debug console to display logs to the user.
// @field table The underlying table object to display the logs.
// @field logs An array of `Log` objects containing timestamped log messages to display.
// @field max_rows An integer specifying the maximum number of rows to display in the console.
// @field width int The width of the message column as a % of the indicator's visual space.
// @field text_size The size of the text in the console. Same as what the `table.cell` function accepts at
//   https://www.tradingview.com/pine-script-reference/v5/#fun_table%7Bdot%7Dcell
// @field timestamp_color The color of the timestamp column.
// @field info_text_color The color of the `INFO` log messages.
// @field debug_text_color The color of the `DEBUG` log messages.
// @field warn_text_color The color of the `WARN` log messages.
// @field error_text_color The color of the `ERROR` log messages.
// @field indent A string representing the current indentation to use when displaying new logs.
// @field isHidden Whether the console is hidden.
export type Console
    table table
    Log[] logs
    Counter[] counters
    int max_rows
    int width
    string text_size
    color timestamp_color
    color info_text_color
    color debug_text_color
    color warn_text_color
    color error_text_color
    string indent = ''
    bool isHidden = false

// Gets the appropriate color for a log message based on its severity level.
// @param log The Log object containing the severity level of the message.
// @returns color The color code to apply to the message when displayed in the console.
method _get_text_color(Console this, Log log) =>
    switch log.level
        LOG_LEVEL_DEBUG => this.debug_text_color
        LOG_LEVEL_WARN => this.warn_text_color
        LOG_LEVEL_ERROR => this.error_text_color
        => this.info_text_color

// @function Writes a `Log` object at a specified row index.
// @param row_index The index of the row in which to write the log.
// @param log The `Log` object to be written to the specified row.
method _write(Console this, int row_index, Log log) =>
    if not this.isHidden
        this.table.cell(COLUMN_TIMESTAMP, row_index, log.timestamp,
          text_color = this.timestamp_color,
          text_size = this.text_size,
          text_halign = text.align_left,
          text_valign = text.align_top,
          text_font_family = font.family_monospace)
        this.table.cell(COLUMN_MSG, row_index, log.msg,
          width = this.width,
          text_color = this._get_text_color(log),
          text_size = this.text_size,
          text_halign = text.align_left,
          text_valign = text.align_top,
          text_font_family = font.family_monospace)

// @function Flushes all the stored logs to the underlying table.
method _flush(Console this) =>
    for [i, l] in this.logs
        this._write(i, l)

// @function Writes a message with the specified level to the console.
// @param level The log level.
// @param msg The message to write to the console.
method _log(Console this, int level, string msg) =>
    // If the number of existing logs in the console exceeds the 'max_rows' limit, the oldest
    // log is removed to make room for the new log.
    if this.logs.size() == this.max_rows
        this.logs.shift()
        this._flush()

    Log l = Log.new(timestamp(), level, this.indent + msg)
    this._write(this.logs.size(), l)
    this.logs.push(l)

// @function Writes a message to the console at the `DEBUG` log level.
// @param msg The message to write to the console.
export method debug(Console this, string msg) =>
    this._log(LOG_LEVEL_DEBUG, msg)

// @function Writes a message to the console at the `INFO` log level.
// @param msg The message to write to the console.
export method info(Console this, string msg) =>
    this._log(LOG_LEVEL_INFO, msg)

// @function Writes a message to the console at the `INFO` log level.
// @param msg The message to write to the console.
export method log(Console this, string msg) =>
    this.info(msg)

// @function Writes a message to the console at the `WARN` log level.
// @param msg The message to write to the console.
export method warn(Console this, string msg) =>
    this._log(LOG_LEVEL_WARN, msg)

// @function Writes a message to the console at the `ERROR` log level.
// @param msg The message to write to the console.
export method error(Console this, string msg) =>
    this._log(LOG_LEVEL_ERROR, msg)

// @function Clears the underlying table, without impacting the logs.
method _clearTable(Console this) =>
    this.table.clear(0, 0, 1, this.logs.size() - 1)

// @function Clears all content from the console.
export method clear(Console this) =>
    this._clearTable()
    this.logs.clear()

// @function Hides the console.
export method hide(Console this) =>
    this._clearTable()
    this.isHidden := true

// @function Shows the console.
export method show(Console this) =>
    this.isHidden := false
    this._flush()

// @function Creates a new inline group in the console, causing any subsequent console messages
//   to be indented by an additional level, until `groupEnd()` is called.
export method group(Console this) =>
    this.indent += '    '

// @function Exits the current inline group in the console.
export method groupEnd(Console this) =>
    if str.length(this.indent) > 0
        this.indent := str.substring(this.indent, 4)

// @function Gets the counter for the specified label.
// @param label The label to get the counter for.
// @returns The counter for the specified label, or a newly created counter if one does not already exist.
method _getCounter(Console this, string label) =>
    Counter counter = for counter in this.counters
        if counter.label == label
            counter
    if na(counter)
        counter := Counter.new(label, 0)
        this.counters.push(counter)
    counter

// @function Logs the number of times that this particular call to `count()` has been called.
// @param label If supplied, `count()` outputs the number of times it has been called with that label.
export method count(Console this, string label = 'default') =>
    Counter counter = this._getCounter(label)
    counter.count += 1

    this.info(str.format('{0}: {1}', counter.label, counter.count))

// @function Resets counter used with `count()`.
// @param label The label for which the count will be reset to 0.
export method countReset(Console this, string label = 'default') =>
    Counter counter = this._getCounter(label)
    counter.count := 0

// @function Writes an error message to the console if the assertion is false.
//   If the assertion is true, nothing happens.
// @param assertion A boolean assertion.
// @param msg The message to write to the console if the assertion is false.
export method assert(Console this, bool assertion, string msg) =>
    if not assertion
        this.error('Assertion failed: ' + msg)

// @function Creates a new `Console` instance.
// @param position The position of the console. Same as what the `table.new` function accepts at 
//   https://www.tradingview.com/pine-script-reference/v5/#fun_table{dot}new
// @param max_rows The maximum number of rows to display.
// @param width The width of the message column as a % of the indicator's visual space.
// @param text_size The size of the text in the console. Same as what the `table.cell` function accepts at
//   https://www.tradingview.com/pine-script-reference/v5/#fun_table%7Bdot%7Dcell
// @param background_color The background color of the console.
// @param timestamp_color The color of the timestamp column.
// @param info_text_color The color of the `INFO` log messages.
// @param debug_text_color The color of the `DEBUG` log messages.
// @param warn_text_color The color of the `WARN` log messages.
// @param error_text_color The color of the `ERROR` log messages.
export new(
  string position = position.bottom_right,
  int max_rows = 50,
  int width = 0,
  string text_size = size.normal,
  color background_color = #000000CC,
  color timestamp_color = #AAAAAA,
  color info_text_color = #DDDDDD,
  color debug_text_color = #AAAAAA,
  color warn_text_color = #FFEB3B,
  color error_text_color = #ff3c00) =>
    table t = table.new(
      position = position,
      columns = 2,
      rows = max_rows,
      bgcolor = background_color,
      frame_width = 0,
      border_width = 0)
    Console.new(
      table = t,
      logs = array.new<Log>(),
      counters = array.new<Counter>(),
      max_rows = max_rows,
      width = width,
      text_size = text_size,
      timestamp_color = timestamp_color,
      info_text_color = info_text_color,
      debug_text_color = debug_text_color,
      warn_text_color = warn_text_color,
      error_text_color = error_text_color)

// ======================================================================
// Example
// ======================================================================

var console = new()
if barstate.islast
    console.debug('This is a DEBUG message')
    console.log('This is an INFO message')
    console.warn('This is a WARN message')
    console.error('This is an ERROR message')