"""Test errors.py."""

import collections
import csv
import os
import textwrap

from pytype import errors
from pytype import file_utils
from pytype import state as frame_state
from pytype.tests import test_utils

import unittest

_TEST_ERROR = "test-error"
_MESSAGE = "an error message on 'here'"


class ErrorTest(unittest.TestCase):

  @errors._error_name(_TEST_ERROR)
  def test_init(self):
    e = errors.Error(errors.SEVERITY_ERROR, _MESSAGE, filename="foo.py",
                     lineno=123, methodname="foo", keyword="here")
    self.assertEqual(errors.SEVERITY_ERROR, e._severity)
    self.assertEqual(_MESSAGE, e._message)
    self.assertEqual(e._name, _TEST_ERROR)
    self.assertEqual("foo.py", e._filename)
    self.assertEqual(123, e._lineno)
    self.assertEqual("foo", e._methodname)
    self.assertEqual("here", e.keyword)

  @errors._error_name(_TEST_ERROR)
  def test_with_stack(self):
    # Opcode of None.
    e = errors.Error.with_stack(None, errors.SEVERITY_ERROR, _MESSAGE,
                                keyword="here")
    self.assertEqual(errors.SEVERITY_ERROR, e._severity)
    self.assertEqual(_MESSAGE, e._message)
    self.assertEqual(e._name, _TEST_ERROR)
    self.assertEqual(None, e._filename)
    self.assertEqual(0, e._lineno)
    self.assertEqual(None, e._methodname)
    self.assertEqual("here", e.keyword)
    # Opcode of None.
    op = test_utils.FakeOpcode("foo.py", 123, "foo")
    e = errors.Error.with_stack(op.to_stack(), errors.SEVERITY_ERROR, _MESSAGE,
                                keyword="here")
    self.assertEqual(errors.SEVERITY_ERROR, e._severity)
    self.assertEqual(_MESSAGE, e._message)
    self.assertEqual(e._name, _TEST_ERROR)
    self.assertEqual("foo.py", e._filename)
    self.assertEqual(123, e._lineno)
    self.assertEqual("foo", e._methodname)
    self.assertEqual("here", e.keyword)

  @errors._error_name(_TEST_ERROR)
  def test_no_traceback_stack_len_1(self):
    # Stack of length 1
    op = test_utils.FakeOpcode("foo.py", 123, "foo")
    error = errors.Error.with_stack(op.to_stack(), errors.SEVERITY_ERROR, "")
    self.assertIsNone(error._traceback)

  @errors._error_name(_TEST_ERROR)
  def test_no_traceback_no_opcode(self):
    # Frame without opcode
    op = test_utils.FakeOpcode("foo.py", 123, "foo")
    stack = [frame_state.SimpleFrame(), frame_state.SimpleFrame(op)]
    error = errors.Error.with_stack(stack, errors.SEVERITY_ERROR, "")
    self.assertIsNone(error._traceback)

  @errors._error_name(_TEST_ERROR)
  def test_traceback(self):
    stack = test_utils.fake_stack(errors.MAX_TRACEBACK_LENGTH + 1)
    error = errors.Error.with_stack(stack, errors.SEVERITY_ERROR, "")
    self.assertMultiLineEqual(error._traceback, textwrap.dedent("""
      Called from (traceback):
        line 0, in function0
        line 1, in function1
        line 2, in function2""").lstrip())

  @errors._error_name(_TEST_ERROR)
  def test_truncated_traceback(self):
    stack = test_utils.fake_stack(errors.MAX_TRACEBACK_LENGTH + 2)
    error = errors.Error.with_stack(stack, errors.SEVERITY_ERROR, "")
    self.assertMultiLineEqual(error._traceback, textwrap.dedent("""
      Called from (traceback):
        line 0, in function0
        ...
        line 3, in function3""").lstrip())

  def test__error_name(self):
    # This should be true as long as at least one method is annotated with
    # _error_name(_TEST_ERROR).
    self.assertIn(_TEST_ERROR, errors._ERROR_NAMES)

  def test_no_error_name(self):
    # It is illegal to create an error outside of an @error_name annotation.
    self.assertRaises(AssertionError, errors.Error, errors.SEVERITY_ERROR,
                      _MESSAGE)

  @errors._error_name(_TEST_ERROR)
  def test_str(self):
    e = errors.Error(errors.SEVERITY_ERROR, _MESSAGE, filename="foo.py",
                     lineno=123, methodname="foo")
    message = "an error message on 'here'"
    error = "File \"foo.py\", line 123, in foo: " + message + " [test-error]"
    self.assertEqual(error, str(e))

  @errors._error_name(_TEST_ERROR)
  def test_write_to_csv(self):
    errorlog = errors.ErrorLog()
    op = test_utils.FakeOpcode("foo.py", 123, "foo")
    message, details = "This is an error", "with\nsome\ndetails: \"1\", 2, 3"
    errorlog.error(op.to_stack(), message, details + "0")
    errorlog.error(op.to_stack(), message, details + "1")
    with file_utils.Tempdir() as d:
      filename = d.create_file("errors.csv")
      errorlog.print_to_csv_file(filename)
      with open(filename, "r") as fi:
        rows = list(csv.reader(fi, delimiter=","))
        self.assertEqual(len(rows), 2)
        for i, row in enumerate(rows):
          filename, lineno, name, actual_message, actual_details = row
          self.assertEqual(filename, "foo.py")
          self.assertEqual(lineno, "123")
          self.assertEqual(name, _TEST_ERROR)
          self.assertEqual(actual_message, message)
          self.assertEqual(actual_details, details + str(i))

  @errors._error_name(_TEST_ERROR)
  def test_write_to_csv_with_traceback(self):
    errorlog = errors.ErrorLog()
    stack = test_utils.fake_stack(2)
    errorlog.error(stack, "", "some\ndetails")
    with file_utils.Tempdir() as d:
      filename = d.create_file("errors.csv")
      errorlog.print_to_csv_file(filename)
      with open(filename, "r") as fi:
        (_, _, _, _, actual_details), = list(csv.reader(fi, delimiter=","))
        self.assertMultiLineEqual(actual_details, textwrap.dedent("""
          some
          details

          Called from (traceback):
            line 0, in function0""").lstrip())


class ErrorLogBaseTest(unittest.TestCase):

  @errors._error_name(_TEST_ERROR)
  def test_error(self):
    errorlog = errors.ErrorLog()
    op = test_utils.FakeOpcode("foo.py", 123, "foo")
    errorlog.error(op.to_stack(), "unknown attribute %s" % "xyz")
    self.assertEqual(len(errorlog), 1)
    e = list(errorlog)[0]  # iterate the log and save the first error.
    self.assertEqual(errors.SEVERITY_ERROR, e._severity)
    self.assertEqual("unknown attribute xyz", e._message)
    self.assertEqual(e._name, _TEST_ERROR)
    self.assertEqual("foo.py", e._filename)

  @errors._error_name(_TEST_ERROR)
  def test_error_with_details(self):
    errorlog = errors.ErrorLog()
    errorlog.error(None, "My message", "one\ntwo")
    self.assertEqual(textwrap.dedent("""
        My message [test-error]
          one
          two
        """).lstrip(), str(errorlog))

  @errors._error_name(_TEST_ERROR)
  def test_warn(self):
    errorlog = errors.ErrorLog()
    op = test_utils.FakeOpcode("foo.py", 123, "foo")
    errorlog.warn(op.to_stack(), "unknown attribute %s", "xyz")
    self.assertEqual(len(errorlog), 1)
    e = list(errorlog)[0]  # iterate the log and save the first error.
    self.assertEqual(errors.SEVERITY_WARNING, e._severity)
    self.assertEqual("unknown attribute xyz", e._message)
    self.assertEqual(e._name, _TEST_ERROR)
    self.assertEqual("foo.py", e._filename)

  @errors._error_name(_TEST_ERROR)
  def test_has_error(self):
    errorlog = errors.ErrorLog()
    self.assertFalse(errorlog.has_error())
    # A warning is part of the error log, but isn't severe.
    errorlog.warn(None, "A warning")
    self.assertEqual(len(errorlog), 1)
    self.assertFalse(errorlog.has_error())
    # An error is severe.
    errorlog.error(None, "An error")
    self.assertEqual(len(errorlog), 2)
    self.assertTrue(errorlog.has_error())

  @errors._error_name(_TEST_ERROR)
  def test_duplicate_error_no_traceback(self):
    errorlog = errors.ErrorLog()
    stack = test_utils.fake_stack(2)
    errorlog.error(stack, "error")  # traceback
    errorlog.error(stack[-1:], "error")  # no traceback
    # Keep the error with no traceback.
    unique_errors = errorlog.unique_sorted_errors()
    self.assertEqual(len(unique_errors), 1)
    self.assertIsNone(unique_errors[0]._traceback)

  @errors._error_name(_TEST_ERROR)
  def test_error_without_stack(self):
    errorlog = errors.ErrorLog()
    stack = test_utils.fake_stack(1)
    errorlog.error(stack, "error_with_stack")
    errorlog.error([], "error_without_stack")
    unique_errors = errorlog.unique_sorted_errors()
    unique_errors = [(error.message, error.filename, error.lineno)
                     for error in unique_errors
                    ]
    self.assertEqual(
        [("error_without_stack", None, 0),
         ("error_with_stack", "foo.py", 0)],
        unique_errors,
    )

  @errors._error_name(_TEST_ERROR)
  def test_duplicate_error_shorter_traceback(self):
    errorlog = errors.ErrorLog()
    stack = test_utils.fake_stack(3)
    errorlog.error(stack, "error")  # longer traceback
    errorlog.error(stack[-2:], "error")  # shorter traceback
    # Keep the error with a shorter traceback.
    unique_errors = errorlog.unique_sorted_errors()
    self.assertEqual(len(unique_errors), 1)
    self.assertMultiLineEqual(unique_errors[0]._traceback, textwrap.dedent("""
      Called from (traceback):
        line 1, in function1""").lstrip())

  @errors._error_name(_TEST_ERROR)
  def test_unique_errors(self):
    errorlog = errors.ErrorLog()
    current_frame = frame_state.SimpleFrame(
        test_utils.FakeOpcode("foo.py", 123, "foo"))
    backframe1 = frame_state.SimpleFrame(
        test_utils.FakeOpcode("foo.py", 1, "bar"))
    backframe2 = frame_state.SimpleFrame(
        test_utils.FakeOpcode("foo.py", 2, "baz"))
    errorlog.error([backframe1, current_frame], "error")
    errorlog.error([backframe2, current_frame], "error")
    # Keep both errors, since the tracebacks are different.
    unique_errors = errorlog.unique_sorted_errors()
    self.assertEqual(len(unique_errors), 2)
    self.assertSetEqual(set(errorlog), set(unique_errors))


class ErrorDocTest(unittest.TestCase):
  ERROR_FILE_PATH = os.path.join(os.path.dirname(errors.__file__),
                                 "../docs/errors.md")

  def _check_and_get_documented_errors(self):
    with open(self.ERROR_FILE_PATH, "r") as f:
      lines = f.readlines()
    entries = [line[3:].strip() for line in lines if line.startswith("##")]
    counts = collections.Counter(entries)
    if any(count > 1 for count in counts.values()):
      raise AssertionError(
          "These errors.md entries have duplicates:\n  " +
          "\n  ".join(name for name, count in counts.items() if count > 1))
    sorted_entries = sorted(entries)
    if sorted_entries != entries:
      raise AssertionError(
          "These errors.md entries are out of alphabetical order:\n  " +
          "\n  ".join(
              actual for expected, actual in zip(sorted_entries, entries)
              if expected != actual))
    return set(entries)

  def test_error_doc(self):
    documented_errors = self._check_and_get_documented_errors()
    all_errors = errors._ERROR_NAMES - {_TEST_ERROR}
    undocumented_errors = all_errors - documented_errors
    if undocumented_errors:
      raise AssertionError(
          "These errors are missing entries in errors.md:\n  " +
          "\n  ".join(undocumented_errors) + "\n")
    deprecated_errors = documented_errors - all_errors
    if deprecated_errors:
      raise AssertionError(
          "These errors.md entries do not correspond to errors:\n  " +
          "\n  ".join(deprecated_errors))


if __name__ == "__main__":
  unittest.main()
