import dayjs from "dayjs";
import { Cond, Condition } from "./Condition";
import { Fn } from "./Function";
import { Q } from "./Query";

const flavor = Q.flavors.mysql;

describe("Condition", () => {
  // EQUAL
  it("should format equal", () => {
    expect(Cond.equal("foo", 123).toSQL(flavor)).toEqual("`foo` = 123");
    expect(Cond.equal("foo", "123").toSQL(flavor)).toEqual('`foo` = "123"');
  });

  // NOT EQUAL
  it("should format notEqual", () => {
    expect(Cond.notEqual("foo", 123).toSQL(flavor)).toEqual("`foo` != 123");
    expect(Cond.notEqual("foo", "123").toSQL(flavor)).toEqual('`foo` != "123"');
  });

  // GREATER THAN
  it("should format greaterThan", () => {
    expect(Cond.greaterThan("foo", 123).toSQL(flavor)).toEqual("`foo` > 123");
    expect(Cond.greaterThan("foo", "123").toSQL(flavor)).toEqual(
      '`foo` > "123"'
    );
  });

  // LESS THAN
  it("should format lessThan", () => {
    expect(Cond.lessThan("foo", 123).toSQL(flavor)).toEqual("`foo` < 123");
    expect(Cond.lessThan("foo", "123").toSQL(flavor)).toEqual('`foo` < "123"');
  });

  // GREATER THAN OR EQUAL
  it("should format greaterThanOrEqual", () => {
    expect(Cond.greaterThanOrEqual("foo", 123).toSQL(flavor)).toEqual(
      "`foo` >= 123"
    );
    expect(Cond.greaterThanOrEqual("foo", "123").toSQL(flavor)).toEqual(
      '`foo` >= "123"'
    );
  });

  // LESS THAN OR EQUAL
  it("should format lessThanOrEqual", () => {
    expect(Cond.lessThanOrEqual("foo", 123).toSQL(flavor)).toEqual(
      "`foo` <= 123"
    );
    expect(Cond.lessThanOrEqual("foo", "123").toSQL(flavor)).toEqual(
      '`foo` <= "123"'
    );
  });

  // BETWEEN
  it("should format between", () => {
    expect(Cond.between("foo", [1, 2]).toSQL(flavor)).toEqual(
      "`foo` BETWEEN 1 AND 2"
    );
    expect(Cond.between("foo", ["1", 2]).toSQL(flavor)).toEqual(
      '`foo` BETWEEN "1" AND 2'
    );
  });
  it("should format between", () => {
    expect(Cond.between("foo", [1, 2]).toSQL(flavor)).toEqual(
      "`foo` BETWEEN 1 AND 2"
    );
    expect(
      Cond.between("foo", [dayjs("2024-01-01").startOf("year"), 2]).toSQL(
        flavor
      )
    ).toEqual('`foo` BETWEEN "2024-01-01 00:00:00" AND 2');
  });

  // IN
  it("should format in", () => {
    expect(Cond.in("foo", [1, 2, 3])?.toSQL(flavor)).toEqual(
      "`foo` IN (1, 2, 3)"
    );
    expect(Cond.in("foo", ["1", "2", "3"])?.toSQL(flavor)).toEqual(
      '`foo` IN ("1", "2", "3")'
    );
    // Fail-closed: empty / null lists render `IN (NULL)` (never matches) rather than
    // dropping the filter, so an empty IN can never silently expose all rows.
    expect(Cond.in("foo", [])?.toSQL(flavor)).toEqual("`foo` IN (NULL)");
    expect(Cond.in("foo", null)?.toSQL(flavor)).toEqual("`foo` IN (NULL)");
  });

  // NOT IN
  it("should format notIn", () => {
    expect(Cond.notIn("foo", [1, 2, 3]).toSQL(flavor)).toEqual(
      "`foo` NOT IN (1, 2, 3)"
    );
    // BUG: strings in notIn are treated as column names instead of string literals
    // Expected: '`foo` NOT IN ("1", "2", "3")'
    // Actual: '`foo` NOT IN (`1`, `2`, `3`)' - treats as columns!
    expect(Cond.notIn("foo", ["1", "2", "3"]).toSQL(flavor)).toEqual(
      '`foo` NOT IN ("1", "2", "3")'
    );
  });

  // IN and NOT IN API consistency
  it("should treat string values consistently in both in() and notIn()", () => {
    // Real-world example from the report
    const inResult = Cond.in("type", ["IncomingInvoice", "ExpenditureCashSlip"])?.toSQL(flavor);
    const notInResult = Cond.notIn("type", ["IncomingInvoice", "ExpenditureCashSlip"]).toSQL(flavor);

    // Both should quote strings as values, not treat them as column names
    expect(inResult).toEqual('`type` IN ("IncomingInvoice", "ExpenditureCashSlip")');
    expect(notInResult).toEqual('`type` NOT IN ("IncomingInvoice", "ExpenditureCashSlip")');
  });

  // IN/NOT IN edge cases
  it("should handle single-element arrays", () => {
    expect(Cond.in("foo", ["only_one"])?.toSQL(flavor)).toEqual(
      '`foo` IN ("only_one")'
    );
    expect(Cond.notIn("foo", [42]).toSQL(flavor)).toEqual("`foo` NOT IN (42)");
  });

  it("should handle boolean values in arrays", () => {
    expect(Cond.in("active", [true, false])?.toSQL(flavor)).toEqual(
      "`active` IN (true, false)"
    );
    expect(Cond.notIn("active", [true]).toSQL(flavor)).toEqual(
      "`active` NOT IN (true)"
    );
  });

  it("should handle Date objects in arrays", () => {
    const date1 = new Date("2024-01-15T10:30:00Z");
    const date2 = new Date("2024-06-20T14:00:00Z");
    const inResult = Cond.in("created_at", [date1, date2])?.toSQL(flavor);
    expect(inResult).toContain("`created_at` IN (");
    expect(inResult).toContain("2024-01-15");
    expect(inResult).toContain("2024-06-20");
  });

  it("should handle dayjs objects in arrays", () => {
    const day1 = dayjs("2024-01-01");
    const day2 = dayjs("2024-12-31");
    const inResult = Cond.in("date", [day1, day2])?.toSQL(flavor);
    expect(inResult).toContain("`date` IN (");
    expect(inResult).toContain("2024-01-01");
    expect(inResult).toContain("2024-12-31");
  });

  it("should handle Q.raw() expressions in arrays", () => {
    expect(Cond.in("col", [Q.raw("NOW()"), Q.raw("CURDATE()")])?.toSQL(flavor)).toEqual(
      "`col` IN (NOW(), CURDATE())"
    );
    expect(Cond.notIn("col", [Q.raw("NULL")])?.toSQL(flavor)).toEqual(
      "`col` NOT IN (NULL)"
    );
  });

  it("should not allow FunctionExpression in arrays (use Q.raw instead)", () => {
    // FunctionExpression cannot be used as a value - this is by design
    expect(() => Cond.in("col", [Fn.max("price")])).toThrow(
      "FunctionExpression cannot be used as a value"
    );
    // Use Q.raw() for function calls in IN/NOT IN
    expect(Cond.in("col", [Q.raw("MAX(price)")])?.toSQL(flavor)).toEqual(
      "`col` IN (MAX(price))"
    );
  });

  it("should handle explicit column references with Q.raw()", () => {
    // When user explicitly wants column references, they can use Q.raw()
    expect(Cond.in("col", [Q.raw("other_col"), Q.raw("another")])?.toSQL(flavor)).toEqual(
      "`col` IN (other_col, another)"
    );
  });

  it("should handle double negation with not(notIn())", () => {
    expect(Cond.not(Cond.notIn("foo", [1, 2, 3])).toSQL(flavor)).toEqual(
      "NOT (`foo` NOT IN (1, 2, 3))"
    );
    expect(Cond.not(Cond.notIn("type", ["a", "b"])).toSQL(flavor)).toEqual(
      'NOT (`type` NOT IN ("a", "b"))'
    );
  });

  // AND
  it("should format and", () => {
    const condition1 = Cond.equal("foo", 123);
    const condition2 = Cond.greaterThan("bar", 50);
    expect(Cond.and([condition1, condition2])?.toSQL(flavor)).toEqual(
      "(`foo` = 123 AND `bar` > 50)"
    );
    expect(Cond.and([])).toBeNull();
    expect(Cond.and(null)).toBeNull();
    expect(Cond.and([null])).toBeNull();
    expect(Cond.and([Cond.or([])])).toBeNull();
  });

  // OR
  it("should format or", () => {
    const condition1 = Cond.equal("foo", 123);
    const condition2 = Cond.lessThan("bar", 50);
    expect(Cond.or([condition1, condition2])?.toSQL(flavor)).toEqual(
      "(`foo` = 123 OR `bar` < 50)"
    );
    expect(Cond.or([])).toBeNull();
    expect(Cond.or(null)).toBeNull();
    expect(Cond.or([null])).toBeNull();
    expect(Cond.or([Cond.and([])])).toBeNull();
  });

  // NULL
  it("should format null", () => {
    expect(Cond.null("foo").toSQL(flavor)).toEqual("`foo` IS NULL");
  });

  // NOT NULL
  it("should format not null", () => {
    expect(Cond.notNull("foo").toSQL(flavor)).toEqual("`foo` IS NOT NULL");
  });

  // IS NULL
  it("should format isNull", () => {
    expect(Cond.isNull("foo").toSQL(flavor)).toEqual("`foo` IS NULL");
  });

  // IS NOT NULL
  it("should format isNotNull", () => {
    expect(Cond.isNotNull("foo").toSQL(flavor)).toEqual("`foo` IS NOT NULL");
  });

  // LIKE
  it("should format like", () => {
    expect(Cond.like("foo", "He_lo wor%").toSQL(flavor)).toEqual(
      '`foo` LIKE "He_lo wor%"'
    );
  });

  // NOT LIKE
  it("should format like", () => {
    expect(Cond.notLike("foo", "He_lo wor%").toSQL(flavor)).toEqual(
      '`foo` NOT LIKE "He_lo wor%"'
    );
  });

  // NOT
  it("should format not", () => {
    expect(Cond.not(Cond.equal("foo", 123)).toSQL(flavor)).toEqual(
      "NOT (`foo` = 123)"
    );
    expect(Cond.not(Cond.greaterThan("bar", 50)).toSQL(flavor)).toEqual(
      "NOT (`bar` > 50)"
    );
  });

  it("should format not with complex conditions", () => {
    const andCondition = Cond.and([
      Cond.equal("foo", 123),
      Cond.greaterThan("bar", 50),
    ]);
    expect(Cond.not(andCondition!).toSQL(flavor)).toEqual(
      "NOT ((`foo` = 123 AND `bar` > 50))"
    );

    const orCondition = Cond.or([
      Cond.equal("foo", 123),
      Cond.lessThan("bar", 50),
    ]);
    expect(Cond.not(orCondition!).toSQL(flavor)).toEqual(
      "NOT ((`foo` = 123 OR `bar` < 50))"
    );
  });

  it("should format nested not conditions", () => {
    const notCondition = Cond.not(Cond.equal("foo", 123));
    expect(Cond.not(notCondition).toSQL(flavor)).toEqual(
      "NOT (NOT (`foo` = 123))"
    );
  });

  it("should format not with other condition types", () => {
    expect(Cond.not(Cond.null("foo")).toSQL(flavor)).toEqual(
      "NOT (`foo` IS NULL)"
    );
    expect(Cond.not(Cond.in("foo", [1, 2, 3])!).toSQL(flavor)).toEqual(
      "NOT (`foo` IN (1, 2, 3))"
    );
    expect(Cond.not(Cond.between("foo", [1, 10])).toSQL(flavor)).toEqual(
      "NOT (`foo` BETWEEN 1 AND 10)"
    );
    expect(Cond.not(Cond.like("foo", "%test%")).toSQL(flavor)).toEqual(
      'NOT (`foo` LIKE "%test%")'
    );
  });
});

describe("Conditions.fromString", () => {
  it("should handle an empty array condition", () => {
    const condition = Cond.fromString("column", "");
    expect(condition!.toSQL(flavor)).toEqual('`column` = ""');
  });

  it("should handle a single condition string", () => {
    const condition = Cond.fromString("column", ">=10");
    expect(condition!.toSQL(flavor)).toEqual('`column` >= "10"');
  });

  it("should handle different operators", () => {
    expect(Cond.fromString("column", ">10")!.toSQL(flavor)).toEqual(
      '`column` > "10"'
    );
    expect(Cond.fromString("column", "<=20")!.toSQL(flavor)).toEqual(
      '`column` <= "20"'
    );
    expect(Cond.fromString("column", "~value")!.toSQL(flavor)).toMatch(/LIKE/);
  });

  it("should default to equal condition", () => {
    const condition = Cond.fromString("column", "value");
    expect(condition!.toSQL(flavor)).toEqual('`column` = "value"');
  });

  it("should use default space delimiter for ~ operator", () => {
    const condition = Cond.fromString("column", "~test");
    const sql = condition!.toSQL(flavor);
    expect(sql).toContain('`column` LIKE "test%"');
    expect(sql).toContain('`column` LIKE "% test%"');
    expect(sql).toMatch(/OR/);
  });

  it("should support multiple delimiters for ~ operator", () => {
    const condition = Cond.fromString("column", "~test", [" ", ","]);
    const sql = condition!.toSQL(flavor);
    expect(sql).toContain('`column` LIKE "test%"');
    expect(sql).toContain('`column` LIKE "% test%"');
    expect(sql).toContain('`column` LIKE "%,test%"');
    expect(sql).toMatch(/OR/);
  });

  it("should support custom delimiters for ~ operator", () => {
    const condition = Cond.fromString("column", "~test", ["-", "_", "."]);
    const sql = condition!.toSQL(flavor);
    expect(sql).toContain('`column` LIKE "test%"');
    expect(sql).toContain('`column` LIKE "%-test%"');
    expect(sql).toContain('`column` LIKE "%_test%"');
    expect(sql).toContain('`column` LIKE "%.test%"');
    expect(sql).toMatch(/OR/);
  });

  it("should use default space delimiter for !~ operator", () => {
    const condition = Cond.fromString("column", "!~test");
    const sql = condition!.toSQL(flavor);
    expect(sql).toContain('`column` NOT LIKE "test%"');
    expect(sql).toContain('`column` NOT LIKE "% test%"');
    expect(sql).toMatch(/AND/);
  });

  it("should support multiple delimiters for !~ operator", () => {
    const condition = Cond.fromString("column", "!~test", [" ", ","]);
    const sql = condition!.toSQL(flavor);
    expect(sql).toContain('`column` NOT LIKE "test%"');
    expect(sql).toContain('`column` NOT LIKE "% test%"');
    expect(sql).toContain('`column` NOT LIKE "%,test%"');
    expect(sql).toMatch(/AND/);
  });

  it("should support custom delimiters for !~ operator", () => {
    const condition = Cond.fromString("column", "!~test", ["-", "_", "."]);
    const sql = condition!.toSQL(flavor);
    expect(sql).toContain('`column` NOT LIKE "test%"');
    expect(sql).toContain('`column` NOT LIKE "%-test%"');
    expect(sql).toContain('`column` NOT LIKE "%_test%"');
    expect(sql).toContain('`column` NOT LIKE "%.test%"');
    expect(sql).toMatch(/AND/);
  });
});

describe("Case-insensitive LIKE conditions", () => {
  const mysqlFlavor = Q.flavors.mysql;
  const postgresFlavor = Q.flavors.postgres;

  it("should generate ILIKE for PostgreSQL", () => {
    expect(
      Cond.like("name", "%john%", { caseInsensitive: true }).toSQL(
        postgresFlavor
      )
    ).toBe('"name" ILIKE \'%john%\'');
  });

  it("should generate LOWER() wrapper for MySQL", () => {
    expect(
      Cond.like("name", "%john%", { caseInsensitive: true }).toSQL(mysqlFlavor)
    ).toBe('LOWER(`name`) LIKE LOWER("%john%")');
  });

  it("should generate NOT ILIKE for PostgreSQL", () => {
    expect(
      Cond.notLike("name", "%john%", { caseInsensitive: true }).toSQL(
        postgresFlavor
      )
    ).toBe('"name" NOT ILIKE \'%john%\'');
  });

  it("should generate NOT LIKE with LOWER() for MySQL", () => {
    expect(
      Cond.notLike("name", "%john%", { caseInsensitive: true }).toSQL(
        mysqlFlavor
      )
    ).toBe('LOWER(`name`) NOT LIKE LOWER("%john%")');
  });

  it("should work with ilike() shorthand for PostgreSQL", () => {
    expect(Cond.ilike("name", "%john%").toSQL(postgresFlavor)).toBe(
      '"name" ILIKE \'%john%\''
    );
  });

  it("should work with ilike() shorthand for MySQL", () => {
    expect(Cond.ilike("name", "%john%").toSQL(mysqlFlavor)).toBe(
      'LOWER(`name`) LIKE LOWER("%john%")'
    );
  });

  it("should work with notIlike() shorthand for PostgreSQL", () => {
    expect(Cond.notIlike("name", "%john%").toSQL(postgresFlavor)).toBe(
      '"name" NOT ILIKE \'%john%\''
    );
  });

  it("should work with notIlike() shorthand for MySQL", () => {
    expect(Cond.notIlike("name", "%john%").toSQL(mysqlFlavor)).toBe(
      'LOWER(`name`) NOT LIKE LOWER("%john%")'
    );
  });

  it("should generate case-sensitive LIKE when caseInsensitive is false", () => {
    expect(
      Cond.like("name", "%john%", { caseInsensitive: false }).toSQL(
        postgresFlavor
      )
    ).toBe('"name" LIKE \'%john%\'');
    expect(
      Cond.like("name", "%john%", { caseInsensitive: false }).toSQL(mysqlFlavor)
    ).toBe('`name` LIKE "%john%"');
  });

  it("should generate case-sensitive LIKE by default", () => {
    expect(Cond.like("name", "%john%").toSQL(postgresFlavor)).toBe(
      '"name" LIKE \'%john%\''
    );
    expect(Cond.like("name", "%john%").toSQL(mysqlFlavor)).toBe(
      '`name` LIKE "%john%"'
    );
  });

  it("should serialize and deserialize caseInsensitive flag", () => {
    const original = Cond.like("name", "%test%", { caseInsensitive: true });
    const serialized = original.serialize();
    const deserialized = Condition.deserialize(serialized);
    expect(deserialized!.toSQL(postgresFlavor)).toBe(
      original.toSQL(postgresFlavor)
    );
    expect(deserialized!.toSQL(mysqlFlavor)).toBe(original.toSQL(mysqlFlavor));
  });

  it("should serialize and deserialize ilike shorthand", () => {
    const original = Cond.ilike("name", "%test%");
    const serialized = original.serialize();
    const deserialized = Condition.deserialize(serialized);
    expect(deserialized!.toSQL(postgresFlavor)).toBe(
      original.toSQL(postgresFlavor)
    );
  });

  it("should serialize and deserialize notIlike shorthand", () => {
    const original = Cond.notIlike("name", "%test%");
    const serialized = original.serialize();
    const deserialized = Condition.deserialize(serialized);
    expect(deserialized!.toSQL(postgresFlavor)).toBe(
      original.toSQL(postgresFlavor)
    );
  });

  it("should handle caseInsensitive: false when deserializing old data without the flag", () => {
    // Simulate old serialized data without caseInsensitive field
    // First, get properly formatted key from a real condition
    const realCondition = Cond.like("name", "%test%");
    const realJson = realCondition.toJSON();
    // Create a copy without caseInsensitive to simulate old data
    const oldJson = {
      type: realJson.type,
      key: realJson.key,
      pattern: realJson.pattern,
      isLike: realJson.isLike,
      // no caseInsensitive field
    };
    const deserialized = Condition.fromJSON(oldJson);
    // Should default to case-sensitive (caseInsensitive: false)
    expect(deserialized.toSQL(postgresFlavor)).toBe('"name" LIKE \'%test%\'');
    expect(deserialized.toSQL(mysqlFlavor)).toBe('`name` LIKE "%test%"');
  });
});

describe("SQL Injection Prevention", () => {
  it("should escape quotes in LIKE patterns", () => {
    const maliciousPattern = "test'; DROP TABLE users; --";
    const sql = Cond.like("column", maliciousPattern).toSQL(flavor);
    // The pattern should be properly escaped and contained within quotes
    expect(sql).toContain("LIKE");
    // The SQL should not allow the injection to break out of the string
    expect(sql).toEqual('`column` LIKE "test\'; DROP TABLE users; --"');
  });

  it("should escape quotes in NOT LIKE patterns", () => {
    const maliciousPattern = 'test"; DELETE FROM users; --';
    const sql = Cond.notLike("column", maliciousPattern).toSQL(flavor);
    // Double quotes should be doubled to prevent injection
    expect(sql).toContain('NOT LIKE "test""; DELETE FROM users; --"');
  });

  it("should escape quotes in LIKE patterns via fromString", () => {
    const maliciousSearch = "~test'; DROP TABLE users; --";
    const condition = Cond.fromString("column", maliciousSearch);
    const sql = condition!.toSQL(flavor);
    // The injection attempt should be contained within the quoted string
    expect(sql).toContain('LIKE "test');
    expect(sql).toContain('DROP TABLE users; --%"');
  });

  it("should escape quotes in IN values", () => {
    const sql = Cond.in("col", ["it's", 'say "hello"'])?.toSQL(flavor);
    // Single quotes should pass through, double quotes should be escaped
    expect(sql).toContain("it's");
    expect(sql).toContain('say ""hello""');
  });

  it("should escape SQL injection attempts in IN values", () => {
    const sql = Cond.in("col", ["'; DROP TABLE x; --"])?.toSQL(flavor);
    // The malicious string should be properly quoted as a value
    expect(sql).toEqual('`col` IN ("\'; DROP TABLE x; --")');
  });

  it("should escape SQL injection attempts in NOT IN values", () => {
    const sql = Cond.notIn("col", ['"; DELETE FROM y; --']).toSQL(flavor);
    // Double quotes should be escaped
    expect(sql).toContain('""; DELETE FROM y; --"');
  });
});

describe("Condition with FunctionExpression key (issue #14)", () => {
  it("Cond.in accepts FunctionExpression as key", () => {
    const sql = Cond.in(Fn.substring("fd.account", 1, 1), [
      "0",
      "1",
      "2",
      "3",
      "4",
    ])?.toSQL(flavor);
    expect(sql).toEqual(
      'SUBSTRING(`fd`.`account`,1,1) IN ("0", "1", "2", "3", "4")'
    );
  });

  it("Cond.notIn accepts FunctionExpression as key", () => {
    const sql = Cond.notIn(Fn.substring("fd.account", 1, 1), ["x"]).toSQL(
      flavor
    );
    expect(sql).toEqual('SUBSTRING(`fd`.`account`,1,1) NOT IN ("x")');
  });

  it("Cond.like accepts FunctionExpression as key", () => {
    const sql = Cond.like(Fn.substring("fd.account", 1, 3), "00%").toSQL(
      flavor
    );
    expect(sql).toEqual('SUBSTRING(`fd`.`account`,1,3) LIKE "00%"');
  });

  it("Cond.notLike accepts FunctionExpression as key", () => {
    const sql = Cond.notLike(Fn.substring("fd.account", 1, 3), "00%").toSQL(
      flavor
    );
    expect(sql).toEqual('SUBSTRING(`fd`.`account`,1,3) NOT LIKE "00%"');
  });

  it("Cond.isNull / isNotNull accept FunctionExpression as key", () => {
    expect(Cond.isNull(Fn.max("price")).toSQL(flavor)).toEqual(
      "MAX(`price`) IS NULL"
    );
    expect(Cond.isNotNull(Fn.max("price")).toSQL(flavor)).toEqual(
      "MAX(`price`) IS NOT NULL"
    );
  });

  it("Cond.columnEqual accepts FunctionExpression on either side", () => {
    const sql = Cond.columnEqual(
      Fn.substring("a.col", 1, 1),
      Fn.substring("b.col", 1, 1)
    ).toSQL(flavor);
    expect(sql).toEqual(
      "SUBSTRING(`a`.`col`,1,1) = SUBSTRING(`b`.`col`,1,1)"
    );
  });
});
