/**
 * Logger implementation — pure record-builder over an injected LogSink.
 *
 * All I/O goes through the sink. `{ now, newId }` are injected for
 * deterministic tests (P2). No ambient state (P3).
 */

import type {
  Attributes,
  ErrorAttributes,
  Level,
  LogContext,
  LogDeps,
  Logger,
  LogLineRecord,
  LogSink,
  Span,
  SpanCloseRecord,
  SpanLink,
  SpanOpenRecord,
  SpanStatus,
} from "../contracts/logging.js";

interface LoggerState {
  readonly ctx: LogContext;
  readonly attrs: Attributes | undefined;
  readonly deps: LogDeps;
  readonly sink: LogSink;
}

function mergeAttributes(
  base: Attributes | undefined,
  extra: Attributes | undefined,
): Attributes | undefined {
  if (base === undefined && extra === undefined) return undefined;
  if (base === undefined) return extra;
  if (extra === undefined) return base;
  return { ...base, ...extra };
}

function isScalarAttr(value: unknown): value is string | number | boolean | null {
  const t = typeof value;
  return t === "string" || t === "number" || t === "boolean" || value === null;
}

function emitLog(state: LoggerState, level: Level, msg: string, attrs?: Attributes): void {
  const merged = mergeAttributes(state.attrs, attrs);
  const base = {
    kind: "log" as const,
    level,
    msg,
    timestamp: state.deps.now(),
    extensionId: state.ctx.extensionId,
  };
  const record: LogLineRecord =
    state.ctx.conversationId !== undefined ||
    state.ctx.turnId !== undefined ||
    state.ctx.spanId !== undefined ||
    state.ctx.parentSpanId !== undefined ||
    merged !== undefined
      ? {
          ...base,
          ...(state.ctx.conversationId !== undefined
            ? { conversationId: state.ctx.conversationId }
            : {}),
          ...(state.ctx.turnId !== undefined ? { turnId: state.ctx.turnId } : {}),
          ...(state.ctx.spanId !== undefined ? { spanId: state.ctx.spanId } : {}),
          ...(state.ctx.parentSpanId !== undefined ? { parentSpanId: state.ctx.parentSpanId } : {}),
          ...(merged !== undefined ? { attributes: merged } : {}),
        }
      : base;
  try {
    state.sink.emit(record);
  } catch {
    // Swallow — D7: the turn is sovereign (never break the caller).
  }
}

function buildSpanOpen(
  state: LoggerState,
  name: string,
  spanId: string,
  attrs?: Attributes,
  body?: string,
  parentSpanId?: string,
): SpanOpenRecord {
  const base = {
    kind: "span-open" as const,
    spanId,
    name,
    timestamp: state.deps.now(),
    extensionId: state.ctx.extensionId,
  };
  const merged = mergeAttributes(state.attrs, attrs);
  const effectiveParent = parentSpanId ?? state.ctx.parentSpanId;
  return {
    ...base,
    ...(state.ctx.conversationId !== undefined ? { conversationId: state.ctx.conversationId } : {}),
    ...(state.ctx.turnId !== undefined ? { turnId: state.ctx.turnId } : {}),
    ...(effectiveParent !== undefined ? { parentSpanId: effectiveParent } : {}),
    ...(merged !== undefined ? { attributes: merged } : {}),
    ...(body !== undefined ? { body } : {}),
  };
}

function buildSpanLink(
  target: { readonly spanId: string; readonly turnId?: string },
  reason?: string,
): SpanLink {
  return {
    spanId: target.spanId,
    ...(target.turnId !== undefined ? { turnId: target.turnId } : {}),
    ...(reason !== undefined ? { reason } : {}),
  };
}

/**
 * Create a structured Logger. Pure factory — all I/O goes through the
 * injected sink. `{ now, newId }` are injected for deterministic tests.
 *
 * @param ctx  Initial correlation context (extensionId + optional ids).
 * @param sink  Fire-and-forget record sink.
 * @param deps  Clock + id generator.
 * @param attrs  Optional default attributes (from child()).
 */
export function createLogger(
  ctx: LogContext,
  sink: LogSink,
  deps: LogDeps,
  attrs?: Attributes,
): Logger {
  const state: LoggerState = { ctx, attrs, deps, sink };

  function makeSpan(
    name: string,
    spanAttrs?: Attributes,
    parentSpanId?: string,
    body?: string,
  ): Span {
    const spanId = deps.newId();
    const mergedParent = parentSpanId ?? state.ctx.spanId;
    const spanCtx: LogContext = {
      extensionId: ctx.extensionId,
      ...(ctx.conversationId !== undefined ? { conversationId: ctx.conversationId } : {}),
      ...(ctx.turnId !== undefined ? { turnId: ctx.turnId } : {}),
      spanId,
      ...(mergedParent !== undefined ? { parentSpanId: mergedParent } : {}),
    };

    const openRecord = buildSpanOpen(state, name, spanId, spanAttrs, body, mergedParent);
    const spanAttrsMutable: Record<string, string | number | boolean | null> =
      spanAttrs !== undefined ? { ...spanAttrs } : {};
    const links: SpanLink[] = [];
    const openedAt = deps.now();

    try {
      sink.emit(openRecord);
    } catch {
      // Swallow — D7.
    }

    const spanLogger = createLogger(spanCtx, sink, deps, state.attrs);

    const span: Span = {
      id: spanId,
      log: spanLogger,
      setAttributes(newAttrs: Attributes): void {
        for (const [key, value] of Object.entries(newAttrs)) {
          spanAttrsMutable[key] = value;
        }
      },
      addLink(target, reason): void {
        links.push(buildSpanLink(target, reason));
      },
      child(childName: string, childAttrs?: Attributes, childBody?: string): Span {
        return makeSpan(childName, childAttrs, spanId, childBody);
      },
      end(outcome?): void {
        const closedAt = deps.now();
        const err = outcome?.err;
        let status: SpanStatus = "ok";
        if (err !== undefined && err !== null) {
          status = "error";
          const errMsg = err instanceof Error ? err.message : String(err);
          spanAttrsMutable["error.message"] = errMsg;
          if (err instanceof Error && err.stack !== undefined) {
            spanAttrsMutable["error.stack"] = err.stack;
          }
        }
        if (outcome?.attrs !== undefined) {
          for (const [key, value] of Object.entries(outcome.attrs)) {
            spanAttrsMutable[key] = value;
          }
        }

        const hasAttrs = Object.keys(spanAttrsMutable).length > 0;
        // Merge child-bound default attrs (state.attrs) the SAME way span-open
        // does (buildSpanOpen). Without this, an attribute bound via
        // `logger.child({ attrs })` appears on the span-open record but NOT the
        // span-close record — so a query like `warm = true` can't find the
        // closed span (with its usage/status). Open and close must agree.
        const mergedCloseAttrs = mergeAttributes(
          state.attrs,
          hasAttrs ? spanAttrsMutable : undefined,
        );
        const hasLinks = links.length > 0;
        const base = {
          kind: "span-close" as const,
          spanId,
          name,
          timestamp: closedAt,
          durationMs: closedAt - openedAt,
          status,
          extensionId: ctx.extensionId,
        };
        const closeRecord: SpanCloseRecord = {
          ...base,
          ...(ctx.conversationId !== undefined ? { conversationId: ctx.conversationId } : {}),
          ...(ctx.turnId !== undefined ? { turnId: ctx.turnId } : {}),
          ...(mergedParent !== undefined ? { parentSpanId: mergedParent } : {}),
          ...(mergedCloseAttrs !== undefined ? { attributes: mergedCloseAttrs } : {}),
          ...(hasLinks ? { links: [...links] } : {}),
          ...(outcome?.body !== undefined ? { body: outcome.body } : {}),
        };
        try {
          sink.emit(closeRecord);
        } catch {
          // Swallow — D7.
        }
      },
    };

    return span;
  }

  const logger: Logger = {
    debug(msg: string, attrs?: Attributes): void {
      emitLog(state, "debug", msg, attrs);
    },
    info(msg: string, attrs?: Attributes): void {
      emitLog(state, "info", msg, attrs);
    },
    warn(msg: string, attrs?: Attributes): void {
      emitLog(state, "warn", msg, attrs);
    },
    error(msg: string, attrs?: ErrorAttributes): void {
      const err = attrs?.err;
      if (err !== undefined && err !== null) {
        // Extract scalar attributes (everything except err).
        const scalarAttrs: Record<string, string | number | boolean | null> = {};
        if (attrs !== undefined) {
          for (const [key, value] of Object.entries(attrs)) {
            if (key !== "err" && isScalarAttr(value)) {
              scalarAttrs[key] = value;
            }
          }
        }
        const merged = mergeAttributes(
          state.attrs,
          Object.keys(scalarAttrs).length > 0 ? scalarAttrs : undefined,
        );
        const errMsg = err instanceof Error ? err.message : String(err);
        const errorAttrs: Record<string, string | number | boolean | null> = {
          ...(merged ?? {}),
          "error.message": errMsg,
        };
        if (err instanceof Error && err.stack !== undefined) {
          errorAttrs["error.stack"] = err.stack;
        }
        emitLog(state, "error", msg, errorAttrs as Attributes);
      } else {
        // No err field — filter to scalar attributes only.
        const scalarAttrs: Record<string, string | number | boolean | null> = {};
        if (attrs !== undefined) {
          for (const [key, value] of Object.entries(attrs)) {
            if (isScalarAttr(value)) {
              scalarAttrs[key] = value;
            }
          }
        }
        emitLog(
          state,
          "error",
          msg,
          Object.keys(scalarAttrs).length > 0 ? (scalarAttrs as Attributes) : undefined,
        );
      }
    },
    child(childCtx: Partial<LogContext> & { readonly attrs?: Attributes }): Logger {
      const convId = childCtx.conversationId ?? ctx.conversationId;
      const tId = childCtx.turnId ?? ctx.turnId;
      const sId = childCtx.spanId ?? ctx.spanId;
      const pId = childCtx.parentSpanId ?? ctx.parentSpanId;
      const newCtx: LogContext = {
        extensionId: ctx.extensionId,
        ...(convId !== undefined ? { conversationId: convId } : {}),
        ...(tId !== undefined ? { turnId: tId } : {}),
        ...(sId !== undefined ? { spanId: sId } : {}),
        ...(pId !== undefined ? { parentSpanId: pId } : {}),
      };
      const newAttrs = mergeAttributes(state.attrs, childCtx.attrs);
      return createLogger(newCtx, sink, deps, newAttrs);
    },
    span(name: string, attrs?: Attributes, body?: string): Span {
      return makeSpan(name, attrs, undefined, body);
    },
  };

  return logger;
}
