// packages/core-shared/src/instrumentation/otel/otel-logger.test.ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { context, trace } from "@opentelemetry/api";
import { logs, SeverityNumber } from "@opentelemetry/api-logs";
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
import {
  BasicTracerProvider,
  InMemorySpanExporter,
  SimpleSpanProcessor,
} from "@opentelemetry/sdk-trace-base";
import {
  LoggerProvider,
  InMemoryLogRecordExporter,
  SimpleLogRecordProcessor,
} from "@opentelemetry/sdk-logs";
import { OtelLogger } from "./otel-logger";

// Enable async context manager for span context propagation
const ctxManager = new AsyncLocalStorageContextManager();
ctxManager.enable();
context.setGlobalContextManager(ctxManager);

function setupProviders(): {
  logExporter: InMemoryLogRecordExporter;
  spanExporter: InMemorySpanExporter;
  loggerProvider: LoggerProvider;
  tracerProvider: BasicTracerProvider;
} {
  const logExporter = new InMemoryLogRecordExporter();
  const loggerProvider = new LoggerProvider();
  loggerProvider.addLogRecordProcessor(new SimpleLogRecordProcessor(logExporter));
  logs.setGlobalLoggerProvider(loggerProvider);

  const spanExporter = new InMemorySpanExporter();
  const tracerProvider = new BasicTracerProvider({
    spanProcessors: [new SimpleSpanProcessor(spanExporter)],
  });
  trace.setGlobalTracerProvider(tracerProvider);

  return { logExporter, spanExporter, loggerProvider, tracerProvider };
}

describe("OtelLogger", () => {
  let logExporter: InMemoryLogRecordExporter;
  let spanExporter: InMemorySpanExporter;
  let loggerProvider: LoggerProvider;
  let tracerProvider: BasicTracerProvider;

  beforeEach(() => {
    ({ logExporter, spanExporter, loggerProvider, tracerProvider } = setupProviders());
  });

  afterEach(async () => {
    await loggerProvider.shutdown();
    await tracerProvider.shutdown();
    logs.disable();
    trace.disable();
  });

  describe("captureException", () => {
    it("emits a log record with ERROR severity and exception attributes", () => {
      const logger = new OtelLogger();
      const err = new Error("something broke");
      err.name = "CustomError";

      logger.captureException(err, { tags: { feature: "blog" }, extras: { userId: "u1" } });

      const records = logExporter.getFinishedLogRecords();
      expect(records).toHaveLength(1);

      const [record] = records;
      expect(record!.severityNumber).toBe(SeverityNumber.ERROR);
      expect(record!.severityText).toBe("ERROR");
      expect(record!.body).toBe("something broke");
      expect(record!.attributes["exception.type"]).toBe("CustomError");
      expect(record!.attributes["exception.message"]).toBe("something broke");
      expect(typeof record!.attributes["exception.stacktrace"]).toBe("string");
      // Tags are prefixed with "tag."
      expect(record!.attributes["tag.feature"]).toBe("blog");
      // Extras are prefixed with "extra."
      expect(record!.attributes["extra.userId"]).toBe("u1");
    });

    it("applies sentry.fingerprint attribute when provided", () => {
      const logger = new OtelLogger();
      const err = new Error("fingerprinted");
      logger.captureException(err, { fingerprint: ["type-a", "src-blog"] });

      const [record] = logExporter.getFinishedLogRecords();
      expect(record!.attributes["sentry.fingerprint"]).toBe("type-a|src-blog");
    });

    it("is a no-op on second call for the same error (double-report guard)", () => {
      const logger = new OtelLogger();
      const err = new Error("once");

      logger.captureException(err);
      logger.captureException(err); // should be skipped

      expect(logExporter.getFinishedLogRecords()).toHaveLength(1);
    });

    it("marks error as reported after first call", () => {
      const logger = new OtelLogger();
      const err = new Error("mark-test");
      logger.captureException(err);

      expect(
        (err as unknown as Record<string, unknown>)["__sentryReported"],
      ).toBe(true);
    });

    it("wraps non-Error values into an Error object", () => {
      const logger = new OtelLogger();
      logger.captureException("plain string error");

      const [record] = logExporter.getFinishedLogRecords();
      expect(record!.body).toBe("plain string error");
      expect(record!.attributes["exception.message"]).toBe("plain string error");
    });
  });

  describe("captureMessage severity mapping", () => {
    it.each([
      ["info" as const, SeverityNumber.INFO, "INFO"],
      ["warning" as const, SeverityNumber.WARN, "WARNING"],
      ["error" as const, SeverityNumber.ERROR, "ERROR"],
    ] as const)(
      "level %s → severityNumber %d, severityText %s",
      (level, expectedNumber, expectedText) => {
        const logger = new OtelLogger();
        logger.captureMessage("test message", level);

        const [record] = logExporter.getFinishedLogRecords();
        expect(record!.severityNumber).toBe(expectedNumber);
        expect(record!.severityText).toBe(expectedText);
        expect(record!.body).toBe("test message");
      },
    );

    it("defaults to INFO when level is omitted", () => {
      const logger = new OtelLogger();
      logger.captureMessage("default level");

      const [record] = logExporter.getFinishedLogRecords();
      expect(record!.severityNumber).toBe(SeverityNumber.INFO);
      expect(record!.severityText).toBe("INFO");
    });

    it("includes tags and extras as prefixed attributes", () => {
      const logger = new OtelLogger();
      logger.captureMessage("msg", "warning", {
        tags: { service: "auth" },
        extras: { count: 5 },
      });

      const [record] = logExporter.getFinishedLogRecords();
      expect(record!.attributes["tag.service"]).toBe("auth");
      expect(record!.attributes["extra.count"]).toBe("5");
    });
  });

  describe("addBreadcrumb", () => {
    it("adds a span event when there is an active span", async () => {
      const logger = new OtelLogger();
      const otelTracer = trace.getTracer("test");

      await otelTracer.startActiveSpan("test-span", async (span) => {
        logger.addBreadcrumb({
          category: "http",
          message: "GET /api/blog",
          level: "info",
          data: { status: 200 },
        });
        span.end();
      });

      const spans = spanExporter.getFinishedSpans();
      expect(spans).toHaveLength(1);

      const [span] = spans;
      const breadcrumbEvent = span!.events.find((e) => e.name === "GET /api/blog");
      expect(breadcrumbEvent).toBeDefined();
      expect(breadcrumbEvent!.attributes!["breadcrumb.category"]).toBe("http");
      expect(breadcrumbEvent!.attributes!["breadcrumb.level"]).toBe("info");
      expect(breadcrumbEvent!.attributes!["extra.status"]).toBe("200");
    });

    it("is a no-op when there is no active span", () => {
      const logger = new OtelLogger();
      // No span active — should not throw
      expect(() =>
        logger.addBreadcrumb({ category: "nav", message: "page changed" }),
      ).not.toThrow();
    });
  });

  describe("setUser", () => {
    it("sets user.id attribute on the active span", async () => {
      const logger = new OtelLogger();
      const otelTracer = trace.getTracer("test");

      await otelTracer.startActiveSpan("user-span", async (span) => {
        logger.setUser({ id: "user-123" });
        span.end();
      });

      const [span] = spanExporter.getFinishedSpans();
      expect(span!.attributes["user.id"]).toBe("user-123");
    });

    it("sets user.id to empty string when called with null", async () => {
      const logger = new OtelLogger();
      const otelTracer = trace.getTracer("test");

      await otelTracer.startActiveSpan("logout-span", async (span) => {
        logger.setUser(null);
        span.end();
      });

      const [span] = spanExporter.getFinishedSpans();
      expect(span!.attributes["user.id"]).toBe("");
    });

    it("is a no-op when there is no active span", () => {
      const logger = new OtelLogger();
      expect(() => logger.setUser({ id: "u1" })).not.toThrow();
    });
  });
});
