import type { FieldMetadata, FilterExpression, FilterTree } from "@olenbetong/appframe-core";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";

import { FilterEditor } from "./FilterEditor.js";

const FIELDS: FieldMetadata[] = [
	{ name: "PersonID", caption: "Person ID", type: "string", nullable: true },
	{ name: "Age", caption: "Age", type: "number" },
	{ name: "Created", caption: "Created", type: "datetime" },
	{ name: "IsActive", caption: "Is active", type: "boolean" },
	{ name: "Secret", caption: "Secret", type: "string", excludeFromFilter: true },
];

/** A tree with a single root `and` group holding one expression. */
function filterWith(column: string, operator: string, value: unknown, valueType = "string"): FilterTree {
	return {
		type: "group",
		mode: "and",
		items: [{ type: "expression", column, operator, value, valueType } as never],
	};
}

function firstExpression(onChange: ReturnType<typeof vi.fn>, call = 0) {
	let [filter] = onChange.mock.calls[call] as [FilterTree];

	return filter.items[0] as FilterExpression;
}

describe("FilterEditor", () => {
	it("renders one row per expression", () => {
		render(<FilterEditor fields={FIELDS} value={filterWith("PersonID", "beginswith", "silv")} />);

		expect(screen.getAllByLabelText("Field")).toHaveLength(1);
		expect(screen.getByLabelText("Operator")).toHaveValue("beginswith");
		expect(screen.getByLabelText("Value")).toHaveValue("silv");
	});

	it("wraps a bare expression in a group", () => {
		let filter = {
			type: "expression",
			column: "Age",
			operator: "equals",
			value: "42",
			valueType: "number",
		} as never as FilterTree;

		render(<FilterEditor fields={FIELDS} value={filter} />);

		expect(screen.getByLabelText("Value")).toHaveValue(42);
	});

	it("hides fields marked as excluded from the field picker", async () => {
		let user = userEvent.setup();
		render(<FilterEditor fields={FIELDS} value={{ type: "group", mode: "and", items: [] }} />);

		await user.click(screen.getByLabelText("Add condition"));

		expect(screen.queryByRole("option", { name: "Secret" })).not.toBeInTheDocument();
		expect(screen.getByRole("option", { name: "Person ID" })).toBeInTheDocument();
	});

	it("adds an expression when a field is picked in the blank row", async () => {
		let user = userEvent.setup();
		let onChange = vi.fn();
		render(<FilterEditor fields={FIELDS} value={filterWith("PersonID", "beginswith", "silv")} onChange={onChange} />);

		await user.click(screen.getByLabelText("Add condition"));
		await user.click(screen.getByRole("option", { name: "Age" }));

		expect(onChange).toHaveBeenCalledTimes(1);
		let [filter] = onChange.mock.calls[0] as [FilterTree];

		expect(filter.items).toHaveLength(2);
		// Numbers default to "equals", strings to "begins with".
		expect(filter.items[1]).toMatchObject({ column: "Age", operator: "equals", valueType: "number" });
	});

	it("changes the operator and resets the value when the arity changes", async () => {
		let user = userEvent.setup();
		let onChange = vi.fn();
		render(<FilterEditor fields={FIELDS} value={filterWith("Age", "equals", "42", "number")} onChange={onChange} />);

		await user.selectOptions(screen.getByLabelText("Operator"), "between");

		expect(firstExpression(onChange)).toMatchObject({ operator: "between", value: ["", ""] });
	});

	it("renders no value editor for parameterless operators", () => {
		render(<FilterEditor fields={FIELDS} value={filterWith("IsActive", "istrue", null, "null")} />);

		expect(screen.queryByLabelText("Value")).not.toBeInTheDocument();
	});

	it("renders two inputs for range operators", () => {
		render(<FilterEditor fields={FIELDS} value={filterWith("Age", "between", ["1", "10"], "number")} />);

		expect(screen.getByLabelText("From")).toHaveValue(1);
		expect(screen.getByLabelText("To")).toHaveValue(10);
	});

	it("renders one chip per value for list operators", () => {
		render(<FilterEditor fields={FIELDS} value={filterWith("PersonID", "inlist", ["a", "b"])} />);

		expect(screen.getByRole("button", { name: "Remove a" })).toBeInTheDocument();
		expect(screen.getByRole("button", { name: "Remove b" })).toBeInTheDocument();
	});

	it("adds a list value on Enter", async () => {
		let user = userEvent.setup();
		let onChange = vi.fn();
		render(<FilterEditor fields={FIELDS} value={filterWith("PersonID", "inlist", ["a"])} onChange={onChange} />);

		await user.type(screen.getByLabelText("Value"), "b{Enter}");

		let [filter] = onChange.mock.calls.at(-1) as [FilterTree];

		expect((filter.items[0] as FilterExpression).value).toEqual(["a", "b"]);
	});

	it("switches a date value to a relative value", async () => {
		let user = userEvent.setup();
		let onChange = vi.fn();
		render(
			<FilterEditor fields={FIELDS} value={filterWith("Created", "equals", "", "datetime")} onChange={onChange} />,
		);

		await user.click(screen.getByRole("button", { name: "Use a relative value" }));

		expect(firstExpression(onChange)).toMatchObject({ value: "Yesterday", valueType: "special" });
	});

	it("does not offer relative values for numbers", () => {
		render(<FilterEditor fields={FIELDS} value={filterWith("Age", "equals", "1", "number")} />);

		expect(screen.queryByRole("button", { name: "Use a relative value" })).not.toBeInTheDocument();
	});

	it("removes a condition", async () => {
		let user = userEvent.setup();
		let onChange = vi.fn();
		render(<FilterEditor fields={FIELDS} value={filterWith("PersonID", "beginswith", "silv")} onChange={onChange} />);

		await user.click(screen.getByRole("button", { name: "Remove condition" }));

		let [filter] = onChange.mock.calls[0] as [FilterTree];

		expect(filter.items).toHaveLength(0);
	});

	it("hides every editing affordance when read only", () => {
		render(<FilterEditor fields={FIELDS} value={filterWith("PersonID", "beginswith", "silv")} readOnly />);

		expect(screen.queryByLabelText("Add condition")).not.toBeInTheDocument();
		expect(screen.queryByRole("button", { name: "Remove condition" })).not.toBeInTheDocument();
		expect(screen.queryByRole("button", { name: "Add group" })).not.toBeInTheDocument();
		expect(screen.getByLabelText("Group operator")).toBeDisabled();
	});

	it("keeps a row editable when the field is not in the view", () => {
		render(<FilterEditor fields={FIELDS} value={filterWith("Removed", "equals", "x")} />);

		expect(screen.getByLabelText("Field")).toHaveValue("Removed");
	});

	it("uses supplied options as a value list", () => {
		render(
			<FilterEditor
				fields={FIELDS}
				value={filterWith("PersonID", "equals", "a")}
				getFieldOptions={(field) =>
					field.name === "PersonID"
						? [
								{ value: "a", label: "Alpha" },
								{ value: "b", label: "Bravo" },
							]
						: undefined
				}
			/>,
		);

		expect(screen.getByLabelText("Value")).toHaveValue("Alpha");
	});

	it("changes the mode of the root group", async () => {
		let user = userEvent.setup();
		let onChange = vi.fn();
		render(<FilterEditor fields={FIELDS} value={filterWith("PersonID", "beginswith", "silv")} onChange={onChange} />);

		expect(screen.getByLabelText("Group operator")).toHaveValue("and");

		await user.selectOptions(screen.getByLabelText("Group operator"), "or");

		let [filter] = onChange.mock.calls[0] as [FilterTree];

		expect(filter.mode).toBe("or");
	});

	it("adds and removes nested groups", async () => {
		let user = userEvent.setup();
		let onChange = vi.fn();
		let { rerender } = render(
			<FilterEditor fields={FIELDS} value={filterWith("PersonID", "beginswith", "silv")} onChange={onChange} />,
		);

		expect(screen.queryByRole("button", { name: "Remove group" })).not.toBeInTheDocument();

		await user.click(screen.getByRole("button", { name: "Add group" }));

		let [withGroup] = onChange.mock.calls[0] as [FilterTree];
		expect(withGroup.items).toHaveLength(2);
		// A nested group defaults to the opposite mode of its parent.
		expect(withGroup.items[1]).toMatchObject({ type: "group", mode: "or", items: [] });

		rerender(<FilterEditor fields={FIELDS} value={withGroup} onChange={onChange} />);
		expect(screen.getAllByLabelText("Group operator")).toHaveLength(2);

		await user.click(screen.getByRole("button", { name: "Remove group" }));

		let [removed] = onChange.mock.calls[1] as [FilterTree];
		expect(removed.items).toHaveLength(1);
	});

	it("edits a condition inside a nested group", async () => {
		let user = userEvent.setup();
		let onChange = vi.fn();
		let filter: FilterTree = {
			type: "group",
			mode: "and",
			items: [
				{ type: "expression", column: "Age", operator: "equals", value: "1", valueType: "number" },
				{
					type: "group",
					mode: "or",
					items: [{ type: "expression", column: "PersonID", operator: "beginswith", value: "a", valueType: "string" }],
				},
			],
		};
		render(<FilterEditor fields={FIELDS} value={filter} onChange={onChange} />);

		await user.click(screen.getAllByRole("button", { name: "Remove condition" })[1]);

		let [next] = onChange.mock.calls[0] as [FilterTree];

		expect(next.items[0]).toMatchObject({ column: "Age" });
		expect(next.items[1]).toMatchObject({ type: "group", items: [] });
	});

	it("keeps its own state when uncontrolled", async () => {
		let user = userEvent.setup();
		render(<FilterEditor fields={FIELDS} defaultValue={filterWith("PersonID", "beginswith", "silv")} />);

		await user.clear(screen.getByLabelText("Value"));
		await user.type(screen.getByLabelText("Value"), "gold");

		expect(screen.getByLabelText("Value")).toHaveValue("gold");
	});
});
