import json, re, socket, threading, time

from tinysdk import TinyLogger
from tinysdk.tinylogger import TinyDocumentLogger, _TinyJsonFormatter


class TestTinyLoggerInit:
  """Test initialization and env var configuration."""

  def test_reads_all_env_vars(self, tinylogger_env):
    logger = TinyLogger()
    assert logger.service_name == tinylogger_env["service_name"]
    assert logger.cluster == tinylogger_env["cluster"]
    assert logger.namespace == tinylogger_env["namespace"]
    assert logger.instance_id == tinylogger_env["instance_id"]
    assert logger.heartbeat_interval == tinylogger_env["heartbeat_interval"]
    assert logger.log_level == tinylogger_env["log_level"].upper()

  def test_defaults_when_no_env_vars(self):
    logger = TinyLogger()
    assert logger.service_name == "unknown-service"
    assert logger.cluster == "unknown-cluster"
    assert logger.namespace == "default"
    assert logger.heartbeat_interval == 30
    assert logger.log_level == "INFO"

  def test_instance_id_defaults_to_hostname(self):
    logger = TinyLogger()
    assert logger.instance_id == socket.gethostname()

  def test_log_level_normalized_to_uppercase(self, monkeypatch):
    monkeypatch.setenv("LOG_LEVEL", "debug")
    logger = TinyLogger()
    assert logger.log_level == "DEBUG"

  def test_start_time_captured_at_init(self):
    before = time.time()
    logger = TinyLogger()
    after = time.time()
    assert before <= logger.start_time <= after

  def test_heartbeat_not_started_by_default(self):
    logger = TinyLogger()
    assert logger._heartbeat_thread is None
    assert logger._heartbeat_seq == 0
    assert not logger._heartbeat_stop_event.is_set()

  def test_internal_logger_name_and_handler(self):
    logger = TinyLogger()
    assert logger._logger.name.startswith("tinysdk.logger.")
    assert len(logger._logger.handlers) == 1
    assert isinstance(logger._logger.handlers[0].formatter, _TinyJsonFormatter)

  def test_second_instance_does_not_accumulate_handlers(self):
    TinyLogger()
    logger = TinyLogger()
    assert len(logger._logger.handlers) == 1

  def test_two_instances_have_independent_underlying_loggers(self):
    logger_a = TinyLogger()
    logger_b = TinyLogger()
    assert logger_a._logger is not logger_b._logger


class TestTinyLoggerCrashSafety:
  """Verify the logger never raises regardless of bad input."""

  def test_none_context(self):
    TinyLogger().log("msg", context=None)

  def test_invalid_level(self):
    TinyLogger().log("msg", level="NOT_A_LEVEL")

  def test_none_error(self):
    TinyLogger().log("msg", error=None)

  def test_circular_context(self):
    ctx = {}
    ctx["self"] = ctx
    TinyLogger().log("msg", context=ctx)

  def test_bad_object_in_context(self):
    TinyLogger().recovery("msg", context={"bad": object()})

  def test_logger_continues_after_suppressed_error(self, capsys, tinylogger_env):
    """suppress_raise must not leave the logger in a broken state."""
    logger = TinyLogger()
    logger.log("msg", context={"bad": object()})  # suppressed, nothing written
    logger.log("still works")
    assert json.loads(capsys.readouterr().out.strip())["message"] == "still works"


class TestTinyLoggerOutput:
  """Test actual JSON content emitted to stdout."""

  def _capture(self, capsys, fn):
    logger = TinyLogger()
    fn(logger)
    return json.loads(capsys.readouterr().out.strip())

  def test_contains_all_schema_fields(self, capsys, tinylogger_env):
    data = self._capture(capsys, lambda l: l.log("test"))
    for field in ["timestamp", "service", "instance", "cluster", "namespace", "level", "event_type", "message", "context", "document_id", "error"]:
      assert field in data, f"Schema field '{field}' missing from emitted JSON"

  def test_is_valid_json(self, capsys, tinylogger_env):
    data = self._capture(capsys, lambda l: l.log("test"))
    assert isinstance(data, dict)

  def test_service_identity_fields(self, capsys, tinylogger_env):
    data = self._capture(capsys, lambda l: l.log("test"))
    assert data["service"] == tinylogger_env["service_name"]
    assert data["instance"] == tinylogger_env["instance_id"]
    assert data["cluster"] == tinylogger_env["cluster"]
    assert data["namespace"] == tinylogger_env["namespace"]

  def test_timestamp_is_iso8601_utc(self, capsys, tinylogger_env):
    data = self._capture(capsys, lambda l: l.log("test"))
    assert re.match(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$", data["timestamp"]), (
      f"timestamp '{data['timestamp']}' is not ISO-8601 UTC with Z suffix"
    )

  def test_log_event_type(self, capsys, tinylogger_env):
    data = self._capture(capsys, lambda l: l.log("test"))
    assert data["event_type"] == "LOG"

  def test_recovery_event_type_and_level(self, capsys, tinylogger_env):
    data = self._capture(capsys, lambda l: l.recovery("connection restored"))
    assert data["event_type"] == "RECOVERY"
    assert data["level"] == "INFO"

  def test_message_field_value(self, capsys, tinylogger_env):
    data = self._capture(capsys, lambda l: l.log("hello world"))
    assert data["message"] == "hello world"

  def test_message_truncated_at_500_chars(self, capsys, tinylogger_env):
    data = self._capture(capsys, lambda l: l.log("x" * 1000))
    assert len(data["message"]) == 500

  def test_level_error(self, capsys, tinylogger_env):
    data = self._capture(capsys, lambda l: l.log("test", level="ERROR"))
    assert data["level"] == "ERROR"

  def test_level_normalized_to_uppercase(self, capsys, tinylogger_env):
    data = self._capture(capsys, lambda l: l.log("test", level="error"))
    assert data["level"] == "ERROR"

  def test_invalid_level_defaults_to_info(self, capsys, tinylogger_env):
    data = self._capture(capsys, lambda l: l.log("test", level="BADLEVEL"))
    assert data["level"] == "INFO"

  def test_warn_level_defaults_to_info(self, capsys, tinylogger_env):
    """'WARN' is not in the allow-list (use 'WARNING'); it defaults to INFO."""
    data = self._capture(capsys, lambda l: l.log("test", level="WARN"))
    assert data["level"] == "INFO"

  def test_debug_suppressed_at_info_log_level(self, capsys, tinylogger_env):
    """DEBUG events are filtered when LOG_LEVEL=INFO."""
    logger = TinyLogger()
    logger.log("debug message", level="DEBUG")
    assert capsys.readouterr().out == ""

  def test_context_matches_input(self, capsys, tinylogger_env):
    ctx = {"duration_ms": 234, "items": 3}
    data = self._capture(capsys, lambda l: l.log("test", context=ctx))
    assert data["context"] == ctx

  def test_context_empty_dict_when_not_provided(self, capsys, tinylogger_env):
    data = self._capture(capsys, lambda l: l.log("test"))
    assert data["context"] == {}
    assert isinstance(data["context"], dict)

  def test_document_id_present(self, capsys, tinylogger_env):
    data = self._capture(capsys, lambda l: l.log("test", document_id="doc-abc123"))
    assert data["document_id"] == "doc-abc123"

  def test_document_id_empty_string_when_not_provided(self, capsys, tinylogger_env):
    data = self._capture(capsys, lambda l: l.log("test"))
    assert data["document_id"] == ""

  def test_error_field_format(self, capsys, tinylogger_env):
    data = self._capture(capsys, lambda l: l.log("test", error=ValueError("something went wrong")))
    assert "ValueError" in data["error"]
    assert "something went wrong" in data["error"]

  def test_error_empty_string_when_not_provided(self, capsys, tinylogger_env):
    data = self._capture(capsys, lambda l: l.log("test"))
    assert data["error"] == ""

  def test_error_type_only_when_no_message(self, capsys, tinylogger_env):
    data = self._capture(capsys, lambda l: l.log("test", level="ERROR", error=ValueError()))
    assert data["error"] == "ValueError"

  def test_heartbeat_json_structure(self, capsys, monkeypatch):
    monkeypatch.setenv("HEARTBEAT_INTERVAL", "1")
    logger = TinyLogger()
    logger.start_heartbeat()
    time.sleep(1.5)
    logger._heartbeat_stop_event.set()
    logger._heartbeat_thread.join(timeout=3)
    lines = [json.loads(l) for l in capsys.readouterr().out.strip().split("\n") if l and "HEARTBEAT" in l]
    assert lines, "No heartbeat output captured"
    data = lines[0]
    assert data["event_type"] == "HEARTBEAT"
    assert data["message"] == "service alive"
    assert data["level"] == "INFO"
    assert "uptime_seconds" in data["context"]
    assert "heartbeat_seq" in data["context"]
    assert data["context"]["heartbeat_seq"] >= 1


class TestTinyDocumentLogger:
  """Test TinyDocumentLogger binding and output."""

  def _capture(self, capsys, fn):
    logger = TinyLogger()
    fn(logger)
    return json.loads(capsys.readouterr().out.strip())

  def test_for_document_returns_document_logger(self):
    logger = TinyLogger()
    doc = logger.for_document("doc-123")
    assert isinstance(doc, TinyDocumentLogger)
    assert doc._document_id == "doc-123"
    assert doc._logger is logger

  def test_log_injects_document_id(self, capsys, tinylogger_env):
    data = self._capture(capsys, lambda l: l.for_document("doc-xyz").log("processing started"))
    assert data["document_id"] == "doc-xyz"
    assert data["event_type"] == "LOG"
    assert data["message"] == "processing started"

  def test_recovery_injects_document_id(self, capsys, tinylogger_env):
    data = self._capture(capsys, lambda l: l.for_document("doc-xyz").recovery("step recovered"))
    assert data["document_id"] == "doc-xyz"
    assert data["event_type"] == "RECOVERY"

  def test_log_passes_context_through(self, capsys, tinylogger_env):
    ctx = {"step": "extraction", "page": 3}
    data = self._capture(capsys, lambda l: l.for_document("doc-1").log("msg", context=ctx))
    assert data["context"] == ctx

  def test_log_passes_error_through(self, capsys, tinylogger_env):
    data = self._capture(capsys, lambda l: l.for_document("doc-1").log("failed", level="ERROR", error=RuntimeError("boom")))
    assert "RuntimeError" in data["error"]
    assert "boom" in data["error"]
    assert data["level"] == "ERROR"

  def test_two_document_loggers_emit_distinct_ids(self, capsys, tinylogger_env):
    logger = TinyLogger()
    doc1 = logger.for_document("doc-1")
    doc2 = logger.for_document("doc-2")
    doc1.log("first")
    doc2.log("second")
    lines = [json.loads(l) for l in capsys.readouterr().out.strip().split("\n") if l]
    assert len(lines) == 2
    assert lines[0]["document_id"] == "doc-1"
    assert lines[1]["document_id"] == "doc-2"

  def test_multiple_document_loggers_share_parent(self):
    logger = TinyLogger()
    doc1 = logger.for_document("doc-1")
    doc2 = logger.for_document("doc-2")
    assert doc1._logger is doc2._logger


class TestTinyLoggerHeartbeat:
  """Test heartbeat thread behavior."""

  def test_starts_daemon_thread(self):
    logger = TinyLogger()
    logger.start_heartbeat()
    assert logger._heartbeat_thread.is_alive()
    assert logger._heartbeat_thread.daemon is True

  def test_idempotent_reuses_same_thread(self):
    logger = TinyLogger()
    logger.start_heartbeat()
    first_thread = logger._heartbeat_thread
    logger.start_heartbeat()
    assert logger._heartbeat_thread is first_thread

  def test_stops_cleanly(self, monkeypatch):
    monkeypatch.setenv("HEARTBEAT_INTERVAL", "1")
    logger = TinyLogger()
    logger.start_heartbeat()
    logger._heartbeat_stop_event.set()
    logger._heartbeat_thread.join(timeout=3)
    assert not logger._heartbeat_thread.is_alive()

  def test_increments_sequence_counter(self, capsys, monkeypatch):
    monkeypatch.setenv("HEARTBEAT_INTERVAL", "1")
    logger = TinyLogger()
    logger.start_heartbeat()
    time.sleep(2.5)
    logger._heartbeat_stop_event.set()
    logger._heartbeat_thread.join(timeout=3)
    lines = [json.loads(l) for l in capsys.readouterr().out.strip().split("\n") if l and "HEARTBEAT" in l]
    assert len(lines) >= 2
    seq_values = [l["context"]["heartbeat_seq"] for l in lines]
    assert seq_values == sorted(seq_values)
    assert seq_values[-1] > seq_values[0]

  def test_restart_after_stop(self, monkeypatch):
    monkeypatch.setenv("HEARTBEAT_INTERVAL", "1")
    logger = TinyLogger()
    logger.start_heartbeat()
    first_thread = logger._heartbeat_thread
    logger._heartbeat_stop_event.set()
    logger._heartbeat_thread.join(timeout=3)
    assert not first_thread.is_alive()
    logger.start_heartbeat()
    assert logger._heartbeat_thread is not first_thread
    assert logger._heartbeat_thread.is_alive()


class TestTinyLoggerErrorFormatting:
  """Test error formatting."""

  def test_format_error_with_message(self):
    logger = TinyLogger()
    formatted = logger._format_error(ValueError("test error message"))
    assert "ValueError" in formatted
    assert "test error message" in formatted

  def test_format_error_without_message(self):
    logger = TinyLogger()
    assert logger._format_error(ValueError()) == "ValueError"

  def test_format_error_with_none(self):
    logger = TinyLogger()
    assert logger._format_error(None) == ""

  def test_format_various_exception_types(self):
    logger = TinyLogger()
    for exc in [ValueError("v"), TypeError("t"), RuntimeError("r"), ConnectionError("c"), TimeoutError("t")]:
      assert type(exc).__name__ in logger._format_error(exc)


class TestTinyLoggerThreadSafety:
  """Test concurrent logging does not raise."""

  def test_concurrent_logging_from_multiple_threads(self):
    logger = TinyLogger()
    errors = []

    def log_messages():
      try:
        for i in range(10):
          logger.log(f"message {i}")
      except Exception as e:
        errors.append(e)

    threads = [threading.Thread(target=log_messages) for _ in range(5)]
    for t in threads:
      t.start()
    for t in threads:
      t.join()
    assert len(errors) == 0

  def test_heartbeat_and_main_thread_concurrent(self, capsys, monkeypatch):
    monkeypatch.setenv("HEARTBEAT_INTERVAL", "1")
    logger = TinyLogger()
    logger.start_heartbeat()
    for i in range(3):
      logger.log(f"main thread message {i}")
      time.sleep(0.4)
    logger._heartbeat_stop_event.set()
    logger._heartbeat_thread.join(timeout=3)
    lines = [json.loads(l) for l in capsys.readouterr().out.strip().split("\n") if l]
    assert len(lines) >= 3
