#!/usr/bin/env ruby
# frozen_string_literal: false

$LOAD_PATH << __dir__

require 'helper'
require 'open3'
require 'weakref'

class OjWriter < Minitest::Test

  class Jeez
    attr_accessor :x, :y

    def initialize(x, y)
      @x = x
      @y = y
    end
  end # Jeez

  def setup
    @default_options = Oj.default_options
  end

  def teardown
    Oj.default_options = @default_options
  end

  def test_string_writer_empty_array
    w = Oj::StringWriter.new(:indent => 0)
    w.push_array()
    w.pop()
    assert_equal("[]\n", w.to_s)
  end

  def test_string_writer_nested_array
    w = Oj::StringWriter.new(:indent => 0)
    w.push_array()
    w.push_array()
    w.pop()
    w.push_array()
    w.push_array()
    w.pop()
    w.pop()
    w.push_array()
    w.pop()
    w.pop()
    assert_equal("[[],[[]],[]]\n", w.to_s)
  end

  def test_string_writer_empty_object
    w = Oj::StringWriter.new(:indent => 0)
    w.push_object()
    w.pop()
    assert_equal("{}\n", w.to_s)
  end

  def test_string_writer_push_null_value_with_key
    w = Oj::StringWriter.new(:indent => 0, :mode => :compat)
    w.push_object()
    w.push_value(nil, 'nothing')
    w.pop()
    assert_equal(%|{"nothing":null}\n|, w.to_s)
  end

  def test_string_writer_nested_object
    w = Oj::StringWriter.new(:indent => 0)
    w.push_object()
    w.push_object('a1')
    w.pop()
    w.push_object('a2')
    w.push_object('b')
    w.pop()
    w.pop()
    w.push_object('a3')
    w.pop()
    w.pop()
    assert_equal(%|{"a1":{},"a2":{"b":{}},"a3":{}}\n|, w.to_s)
  end

  def test_string_writer_nested_key
    w = Oj::StringWriter.new(:indent => 0)
    w.push_object()
    w.push_key('a1')
    w.push_object()
    w.pop()
    w.push_key('a2')
    w.push_object('x')
    w.push_object('b')
    w.pop()
    w.pop()
    w.push_key('a3')
    w.push_object()
    w.push_key('a4')
    w.push_value(37)
    w.pop()
    w.pop()
    assert_equal(%|{"a1":{},"a2":{"b":{}},"a3":{"a4":37}}\n|, w.to_s)
  end

  def test_string_writer_value_array
    w = Oj::StringWriter.new(:indent => 2)
    w.push_array()
    w.push_value(7)
    w.push_value(7.3)
    w.push_value(true)
    w.push_value(nil)
    w.push_value('a string')
    w.push_value({'a' => 65})
    w.push_value([1, 2])
    w.pop()
    assert_equal(%|[
  7,
  7.3,
  true,
  null,
  "a string",
  {
    "a":65
  },
  [
    1,
    2
  ]
]
|, w.to_s)
  end

  def test_string_writer_block
    w = Oj::StringWriter.new(:indent => 0)
    w.push_object() {
      w.push_object('a1') {
        w.push_value(7, 'a7')
      }
      w.push_array('a2') {
        w.push_value('x')
        w.push_value(3)
      }
    }
    assert_equal(%|{"a1":{"a7":7},"a2":["x",3]}\n|, w.to_s)
  end

  def test_string_writer_json
    w = Oj::StringWriter.new(:indent => 0)
    w.push_array()
    w.push_json('7')
    w.push_json('true')
    w.push_json(%|"a string"|)
    w.push_object()
    w.push_json('{"a":65}', 'x')
    w.pop()
    w.pop()
    assert_equal(%|[7,true,"a string",{"x":{"a":65}}]\n|, w.to_s)
  end

  def test_string_writer_pop_excess
    w = Oj::StringWriter.new(:indent => 0)
    begin
      w.push_object() {
        w.push_key('key')
      }
    rescue Exception
      assert(true)
      return
    end
    assert(false, '*** expected an exception')
  end

  def test_string_writer_array_key
    w = Oj::StringWriter.new(:indent => 0)
    begin
      w.push_array() {
        w.push_key('key')
        w.push_value(7)
      }
    rescue Exception
      assert(true)
      return
    end
    assert(false, '*** expected an exception')
  end

  def test_string_writer_pop_with_key
    w = Oj::StringWriter.new(:indent => 0)
    begin
      w.pop()
    rescue Exception
      assert(true)
      return
    end
    assert(false, '*** expected an exception')
  end

  def test_string_writer_obj_no_key
    w = Oj::StringWriter.new(:indent => 0)
    w.push_object()
    begin
      w.push_value(59)
    rescue Exception
      assert(true)
      return
    end
    assert(false, '*** expected an exception')
  end

  def test_string_writer_deep
    cnt = 1000
    w = Oj::StringWriter.new(:indent => 0)
    cnt.times do
      w.push_array()
    end
    cnt.times do
      w.pop()
    end
    # if no exception then passed
    assert(true)
  end

  def test_string_writer_pop_all
    w = Oj::StringWriter.new(:indent => 0)
    w.push_object()
    w.push_object('a1')
    w.pop()
    w.push_array('a2')
    w.push_value(3)
    w.push_array()
    w.pop_all()
    assert_equal(%|{"a1":{},"a2":[3,[]]}\n|, w.to_s)
  end

  # The :only, :except, :create_id, and :ignore options each allocate a buffer
  # in the writer that the writer must free when it is collected. The writers
  # are not kept alive here so that a leak checker sees the buffers as lost.

  def test_string_writer_only
    w = Oj::StringWriter.new(:mode => :compat, :indent => 0, :only => ['a'])
    w.push_value({'a' => 1, 'b' => 2})
    assert_equal(%|{"a":1}|, w.to_s)
  end

  def test_string_writer_except
    w = Oj::StringWriter.new(:mode => :compat, :indent => 0, :except => ['b'])
    w.push_value({'a' => 1, 'b' => 2})
    assert_equal(%|{"a":1}|, w.to_s)
  end

  def test_string_writer_create_id
    w = Oj::StringWriter.new(:mode => :custom, :indent => 0, :create_id => '^custom', :create_additions => true)
    w.push_value(Jeez.new(3, 'z'))
    assert_equal(%|{"^custom":"OjWriter::Jeez","x":3,"y":"z"}|, w.to_s)
  end

  def test_string_writer_ignore
    w = Oj::StringWriter.new(:mode => :object, :indent => 0, :ignore => [Jeez])
    w.push_value({'a' => Jeez.new(3, 'z'), 'b' => 1})
    assert_equal(%|{"b":1}|, w.to_s)
  end

  def test_stream_writer_only
    output = StringIO.open('', 'w+')
    w = Oj::StreamWriter.new(output, :mode => :compat, :indent => 0, :only => ['a'])
    w.push_value({'a' => 1, 'b' => 2})
    assert_equal(%|{"a":1}|, output.string())
  end

  def test_stream_writer_create_id
    output = StringIO.open('', 'w+')
    w = Oj::StreamWriter.new(output, :mode => :custom, :indent => 0, :create_id => '^custom', :create_additions => true)
    w.push_value(Jeez.new(3, 'z'))
    assert_equal(%|{"^custom":"OjWriter::Jeez","x":3,"y":"z"}|, output.string())
  end

  # A writer keeps the options it was created with, so replacing the defaults
  # afterwards must neither change what the writer dumps nor free the buffers
  # the writer is still using.
  def test_string_writer_default_options_replaced
    Oj.default_options = {:mode => :custom, :indent => 0, :create_id => '^d', :create_additions => true}
    w = Oj::StringWriter.new()
    Oj.default_options = {:create_id => '^e'}
    w.push_value(Jeez.new(3, 'z'))
    assert_equal(%|{"^d":"OjWriter::Jeez","x":3,"y":"z"}|, w.to_s)

    w = nil
    GC.start
  end

  # Overwrite the machine stack before collecting. Without this a stale
  # reference left on the stack can keep an object alive and hide a missing
  # mark.
  def scrub_stack(depth = 128)
    return 0 if 0 == depth

    pad = 'x' * 64
    scrub_stack(depth - 1) + pad.size
  end

  def collect_garbage
    scrub_stack
    4.times { GC.start(full_mark: true, immediate_sweep: true) }
  end

  # A writer holds the classes from the options and a stream writer holds the
  # stream as well. If they are not marked they can be collected while the
  # writer is still alive which leaves the writer with dangling references.
  def test_stream_writer_marks_stream
    # The stream is not kept in a local variable so the writer is the only one
    # holding a reference to it.
    w = Oj::StreamWriter.new((output = StringIO.new), :mode => :compat, :indent => 0)
    ref = WeakRef.new(output)
    output = nil
    collect_garbage
    assert(ref.weakref_alive?, 'the stream was collected while the writer was alive')

    w.push_object()
    w.push_value(1, 'a')
    w.pop_all()
    assert_equal(%|{"a":1}\n|, ref.string())
  end

  def test_string_writer_marks_hash_class
    klass = Class.new(Hash)
    w = Oj::StringWriter.new(:mode => :object, :indent => 0, :hash_class => klass)
    ref = WeakRef.new(klass)
    klass = nil
    collect_garbage
    assert(ref.weakref_alive?, 'the hash_class was collected while the writer was alive')

    w.push_value({'a' => 1})
    assert_equal(%|{"a":1}|, w.to_s)
  end

  def test_stream_writer_marks_array_class
    klass = Class.new(Array)
    w = Oj::StreamWriter.new((output = StringIO.new), :mode => :object, :indent => 0, :array_class => klass)
    ref = WeakRef.new(klass)
    klass = nil
    collect_garbage
    assert(ref.weakref_alive?, 'the array_class was collected while the writer was alive')

    w.push_value([1])
    w.pop_all()
    assert_equal(%|[1]|, output.string())
  end

  # A :match_string option builds a chain of compiled regexps in the writer that
  # the writer must free when it is collected. The writers are not kept alive
  # here so that a leak checker sees the chain as lost.

  def test_string_writer_match_string
    w = Oj::StringWriter.new(:mode => :custom, :indent => 0, :match_string => {/^x/ => Jeez})
    w.push_value({'a' => 1})
    assert_equal(%|{"a":1}|, w.to_s)
  end

  def test_stream_writer_match_string
    output = StringIO.open('', 'w+')
    w = Oj::StreamWriter.new(output, :mode => :custom, :indent => 0, :match_string => {/^x/ => Jeez})
    w.push_value({'a' => 1})
    assert_equal(%|{"a":1}|, output.string())
  end

  def test_string_writer_reset
    w = Oj::StringWriter.new(:indent => 0)
    w.push_array()
    w.pop()
    w.reset()
    assert_equal('', w.to_s)
  end

  # Stream Writer

  class SString < ::String
    alias write concat
  end

  def test_stream_writer_encoding
    output = SString.new.force_encoding(::Encoding::UTF_8)
    w = Oj::StreamWriter.new(output, :indent => 0)
    # Oddly enough, when pushing ASCII characters with UTF-8 encoding or even
    # ASCII-8BIT does not change the output encoding. Pushing any non-ASCII no
    # matter what the encoding changes the output encoding to ASCII-8BIT.
    x = '香港' # Hong Kong
    x = x.force_encoding('ASCII-8BIT')
    w.push_value(x)
    assert_equal(::Encoding::UTF_8, output.encoding)
  end

  def test_stream_writer_empty_array
    output = StringIO.open('', 'w+')
    w = Oj::StreamWriter.new(output, :indent => 0)
    w.push_array()
    w.pop()
    assert_equal("[]\n", output.string())
  end

  def test_stream_writer_mixed_stringio
    output = StringIO.open('', 'w+')
    w = Oj::StreamWriter.new(output, :indent => 0)
    w.push_object()
    w.push_object('a1')
    w.pop()
    w.push_object('a2')
    w.push_array('b')
    w.push_value(7)
    w.push_value(true)
    w.push_value('string')
    w.pop()
    w.pop()
    w.push_object('a3')
    w.pop()
    w.pop()
    result = output.string()
    assert_equal(%|{"a1":{},"a2":{"b":[7,true,"string"]},"a3":{}}\n|, result)
  end

  def test_stream_writer_mixed_file
    filename = File.join(__dir__, 'open_file_test.json')
    File.open(filename, 'w') do |f|
      w = Oj::StreamWriter.new(f, :indent => 0)
      w.push_object()
      w.push_object('a1')
      w.pop()
      w.push_object('a2')
      w.push_array('b')
      w.push_value(7)
      w.push_value(true)
      w.push_value('string')
      w.pop()
      w.pop()
      w.push_object('a3')
      w.pop()
      w.pop()
    end
    content = File.read(filename)
    assert_equal(%|{"a1":{},"a2":{"b":[7,true,"string"]},"a3":{}}\n|, content)
  end

  def test_stream_writer_nested_key_object
    output = StringIO.open('', 'w+')
    w = Oj::StreamWriter.new(output, :indent => 0)
    w.push_object()
    w.push_key('a1')
    w.push_object()
    w.pop()
    w.push_key('a2')
    w.push_object('x')
    w.push_object('b')
    w.pop()
    w.pop()
    w.push_key('a3')
    w.push_object()
    w.push_key('a4')
    w.push_value(37)
    w.pop()
    w.pop()
    assert_equal(%|{"a1":{},"a2":{"b":{}},"a3":{"a4":37}}\n|, output.string())
  end

  def push_stuff(w, pop_all=true)
    w.push_object()
    w.push_object('a1')
    w.pop()
    w.push_object('a2')
    w.push_array('b')
    w.push_value(7)
    w.push_value(true)
    w.push_value('string')
    w.pop()
    w.pop()
    w.push_object('a3')
    if pop_all
      w.pop_all()
    else
      w.pop()
      w.pop()
    end
  end

  def test_stream_writer_buf_small
    output = StringIO.open('', 'w+')
    w = Oj::StreamWriter.new(output, :indent => 0, :buffer_size => 20)
    push_stuff(w)
    assert_equal(%|{"a1":{},"a2":{"b":[7,true,"string"]},"a3":{}}\n|, output.string())
  end

  def test_stream_writer_buf_large
    output = StringIO.open('', 'w+')
    w = Oj::StreamWriter.new(output, :indent => 0, :buffer_size => 16_000)
    push_stuff(w)
    assert_equal(%|{"a1":{},"a2":{"b":[7,true,"string"]},"a3":{}}\n|, output.string())
  end

  def test_stream_writer_buf_flush
    output = StringIO.open('', 'w+')
    w = Oj::StreamWriter.new(output, :indent => 0, :buffer_size => 4096)
    push_stuff(w, false)
    # no flush so nothing should be in the output yet
    assert_equal('', output.string())
    w.flush()
    assert_equal(%|{"a1":{},"a2":{"b":[7,true,"string"]},"a3":{}}\n|, output.string())
  end

  def test_stream_writer_buf_flush_small
    output = StringIO.open('', 'w+')
    w = Oj::StreamWriter.new(output, :indent => 0, :buffer_size => 30)
    push_stuff(w, false)
    # flush should be called at 30 bytes but since the writes are chunky flush
    # is called after adding "string".
    assert_equal(%|{"a1":{},"a2":{"b":[7,true,"string"|, output.string())
    w.flush()
    assert_equal(%|{"a1":{},"a2":{"b":[7,true,"string"]},"a3":{}}\n|, output.string())
  end

  def test_stream_writer_push_null_value_with_key
    output = StringIO.open('', 'w+')
    w = Oj::StreamWriter.new(output, :indent => 0)
    w.push_object()
    w.push_value(nil, 'nothing')
    w.pop()
    assert_equal(%|{"nothing":null}\n|, output.string())
  end

  def test_stream_writer_subprocess
    skip if RbConfig::CONFIG['host_os'] =~ /(mingw|mswin)/

    Open3.popen3("/bin/bash", "-c", "cat > /dev/null") do |stdin, _stdout, _stderr, _wait_thr|
      w = Oj::StreamWriter.new(stdin, :indent => 0)
      w.push_array()
      chunk = "{\"foo\":\"#{"bar"*1000}\"}"
      1000.times do |_|
        w.push_json(chunk)
      end
      w.pop()
      stdin.close
    end
  end
end # OjWriter
