import { Effect } from "effect";
import { describe, expect, it } from "vitest";

import { composeSignal, SignalValidationError } from "./signal";
import type { SignalValidationReason } from "./signal";

type TestSignalScope =
  | {
      _tag: "Organization";
      organizationId: string;
    }
  | {
      _tag: "Project";
      organizationId: string;
      projectId: string;
    };

interface TestSourceMessage {
  conversationId: string;
  messageId: string;
  rawText: string;
  createdAt: number;
}

interface TestSignalInput {
  id: string;
  scope: TestSignalScope;
  sourceMessages: [TestSourceMessage, TestSourceMessage];
  problemStatement: {
    title: string;
    summary: string;
    desiredOutcome: string;
    constraints: string[];
  };
  processedBy: {
    agentName: string;
    agentInstanceId: string;
  };
  createdAt: number;
}

const makeSignalInput = (): TestSignalInput => ({
  createdAt: 300,
  id: "signal-1",
  problemStatement: {
    constraints: ["Keep existing account data"],
    desiredOutcome: "Users can complete setup.",
    summary: "The setup flow stops before completion.",
    title: "Users cannot finish setup",
  },
  processedBy: {
    agentInstanceId: "organization-1",
    agentName: "zopu",
  },
  scope: {
    _tag: "Organization",
    organizationId: "organization-1",
  },
  sourceMessages: [
    {
      conversationId: "conversation-1",
      createdAt: 100,
      messageId: "message-1",
      rawText: "First source",
    },
    {
      conversationId: "conversation-1",
      createdAt: 200,
      messageId: "message-2",
      rawText: "Second source",
    },
  ],
});

const runSignal = (input: unknown) => Effect.runSync(composeSignal(input));

const getValidationError = (
  input: unknown,
  reason: SignalValidationReason
): SignalValidationError => {
  const error = Effect.runSync(Effect.flip(composeSignal(input)));
  expect(error).toBeInstanceOf(SignalValidationError);
  expect(error.reason).toBe(reason);
  return error;
};

describe("composeSignal", () => {
  it("accepts organization scope", () => {
    const signal = runSignal(makeSignalInput());

    expect(signal.scope).toEqual({
      _tag: "Organization",
      organizationId: "organization-1",
    });
  });

  it("accepts project scope", () => {
    const input = makeSignalInput();
    input.scope = {
      _tag: "Project",
      organizationId: "organization-1",
      projectId: "project-1",
    };

    const signal = runSignal(input);

    expect(signal.scope).toEqual({
      _tag: "Project",
      organizationId: "organization-1",
      projectId: "project-1",
    });
  });

  it("rejects empty IDs and processed-agent identity", () => {
    const mutations: readonly ((input: TestSignalInput) => void)[] = [
      (input) => {
        input.id = "";
      },
      (input) => {
        input.scope.organizationId = "";
      },
      (input) => {
        input.scope = {
          _tag: "Project",
          organizationId: "organization-1",
          projectId: "",
        };
      },
      (input) => {
        input.sourceMessages[0].conversationId = "";
      },
      (input) => {
        input.sourceMessages[0].messageId = "";
      },
      (input) => {
        input.processedBy.agentName = "";
      },
      (input) => {
        input.processedBy.agentInstanceId = "";
      },
    ];

    for (const mutate of mutations) {
      const input = makeSignalInput();
      mutate(input);
      getValidationError(input, "InvalidInput");
    }
  });

  it("rejects negative and fractional timestamps", () => {
    const mutations: readonly ((input: TestSignalInput) => void)[] = [
      (input) => {
        input.sourceMessages[0].createdAt = -1;
      },
      (input) => {
        input.sourceMessages[0].createdAt = 1.5;
      },
      (input) => {
        input.createdAt = -1;
      },
      (input) => {
        input.createdAt = 1.5;
      },
    ];

    for (const mutate of mutations) {
      const input = makeSignalInput();
      mutate(input);
      getValidationError(input, "InvalidInput");
    }
  });

  it("rejects empty problem-statement fields", () => {
    for (const field of ["title", "summary", "desiredOutcome"] as const) {
      const input = makeSignalInput();
      input.problemStatement[field] = "";
      getValidationError(input, "InvalidInput");
    }
  });

  it("rejects whitespace-only problem fields and constraints", () => {
    const input = makeSignalInput();
    input.problemStatement.title = "   ";
    input.problemStatement.constraints = ["\t\n"];

    getValidationError(input, "InvalidInput");
  });

  it("rejects an empty constraint item", () => {
    const input = makeSignalInput();
    input.problemStatement.constraints = [""];

    getValidationError(input, "InvalidInput");
  });

  it("accepts an empty constraints list", () => {
    const input = makeSignalInput();
    input.problemStatement.constraints = [];

    expect(runSignal(input).problemStatement.constraints).toEqual([]);
  });

  it("rejects an empty source list", () => {
    const input = {
      ...makeSignalInput(),
      sourceMessages: [],
    };

    getValidationError(input, "InvalidInput");
  });

  it("rejects duplicate source identities", () => {
    const input = makeSignalInput();
    input.sourceMessages[1].messageId = input.sourceMessages[0].messageId;

    getValidationError(input, "DuplicateSource");
  });

  it("rejects sources from mixed conversations", () => {
    const input = makeSignalInput();
    input.sourceMessages[1].conversationId = "conversation-2";

    getValidationError(input, "MixedConversation");
  });

  it("accepts non-chronological source order with preserved timestamps", () => {
    const input = makeSignalInput();
    // Agent selects the later message first (logical, not chronological order).
    const [earlier, later] = input.sourceMessages;
    input.sourceMessages = [later, earlier];

    const signal = runSignal(input);

    // Ordinal follows the agent's selection, not timestamps.
    expect(signal.sourceMessages.map(({ messageId }) => messageId)).toEqual([
      "message-2",
      "message-1",
    ]);
    // Original timestamps are preserved exactly on each source.
    const timestamps = signal.sourceMessages.map(({ createdAt }) => createdAt);
    expect(timestamps).toEqual([200, 100]);
  });

  it("preserves source order and exact raw whitespace", () => {
    const input = makeSignalInput();
    input.sourceMessages[0].rawText = "  first source\n";
    input.sourceMessages[1].rawText = "\tsecond source  ";

    const signal = runSignal(input);

    expect(signal.sourceMessages.map(({ messageId }) => messageId)).toEqual([
      "message-1",
      "message-2",
    ]);
    expect(signal.sourceMessages.map(({ rawText }) => rawText)).toEqual([
      "  first source\n",
      "\tsecond source  ",
    ]);
  });

  it("preserves problem-statement whitespace while validating trimmed non-emptiness", () => {
    const input = makeSignalInput();
    input.problemStatement.title = "  Setup blocked  ";
    input.problemStatement.constraints = ["  Preserve data  "];

    const signal = runSignal(input);

    expect(signal.problemStatement.title).toBe("  Setup blocked  ");
    expect(signal.problemStatement.constraints).toEqual(["  Preserve data  "]);
  });

  it("does not include candidate, work-unit, or run fields", () => {
    const signal = runSignal({
      ...makeSignalInput(),
      candidateId: "candidate-1",
      runId: "run-1",
      workUnitId: "work-unit-1",
    });

    expect(signal).not.toHaveProperty("candidateId");
    expect(signal).not.toHaveProperty("workUnitId");
    expect(signal).not.toHaveProperty("runId");
  });
});
