// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
//
// SPDX-License-Identifier: GPL-3.0-or-later

import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Type } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";
import { ConfigError } from "../src/util/errors.js";
import { loadConfig } from "../src/config/loader.js";
import { partialObject } from "../src/config/schema.js";

describe("config validation", () => {
  let configDir: string;
  let configPath: string;
  const originalEnv = { ...process.env };

  beforeEach(() => {
    configDir = mkdtempSync(join(tmpdir(), "rumilo-cfg-test-"));
    configPath = join(configDir, "config.toml");
    process.env["XDG_CONFIG_HOME"] = join(configDir, "..");
    // loadConfig looks for <XDG_CONFIG_HOME>/rumilo/config.toml
    // So we need the dir structure to match
    const rumiloDir = join(configDir, "..", "rumilo");
    require("node:fs").mkdirSync(rumiloDir, { recursive: true });
    configPath = join(rumiloDir, "config.toml");
  });

  afterEach(() => {
    process.env = { ...originalEnv };
    try {
      rmSync(configDir, { recursive: true, force: true });
      // Also clean up the rumilo dir we created
      const rumiloDir = join(configDir, "..", "rumilo");
      rmSync(rumiloDir, { recursive: true, force: true });
    } catch {}
  });

  test("rejects defaults.model with wrong type (number instead of string)", async () => {
    writeFileSync(
      configPath,
      `[defaults]\nmodel = 42\ncleanup = true\n`,
    );
    await expect(loadConfig()).rejects.toThrow(ConfigError);
    await expect(loadConfig()).rejects.toThrow(/defaults\/model/);
  });

  test("rejects defaults.cleanup with wrong type (string instead of boolean)", async () => {
    writeFileSync(
      configPath,
      `[defaults]\nmodel = "anthropic:claude-sonnet-4-20250514"\ncleanup = "yes"\n`,
    );
    await expect(loadConfig()).rejects.toThrow(ConfigError);
    await expect(loadConfig()).rejects.toThrow(/defaults\/cleanup/);
  });

  test("rejects repo.default_depth with wrong type (string instead of number)", async () => {
    writeFileSync(
      configPath,
      `[repo]\ndefault_depth = "deep"\n`,
    );
    await expect(loadConfig()).rejects.toThrow(ConfigError);
    await expect(loadConfig()).rejects.toThrow(/repo\/default_depth/);
  });

  test("rejects repo.default_depth below minimum (0)", async () => {
    writeFileSync(
      configPath,
      `[repo]\ndefault_depth = 0\n`,
    );
    await expect(loadConfig()).rejects.toThrow(ConfigError);
    await expect(loadConfig()).rejects.toThrow(/default_depth/);
  });

  test("rejects unknown top-level section type (number instead of object)", async () => {
    // web should be an object but we pass a string value at top level
    writeFileSync(
      configPath,
      `[defaults]\nmodel = "x"\ncleanup = true\n[web]\nmodel = 123\n`,
    );
    await expect(loadConfig()).rejects.toThrow(ConfigError);
  });

  test("accepts valid partial config (only [repo] section)", async () => {
    writeFileSync(
      configPath,
      `[repo]\nmodel = "anthropic:claude-sonnet-4-20250514"\ndefault_depth = 5\n`,
    );
    const { config } = await loadConfig();
    expect(config.repo.model).toBe("anthropic:claude-sonnet-4-20250514");
    expect(config.repo.default_depth).toBe(5);
    // defaults should come from defaultConfig
    expect(config.defaults.model).toBe("anthropic:claude-sonnet-4-20250514");
  });

  test("accepts valid complete config", async () => {
    writeFileSync(
      configPath,
      [
        `[defaults]`,
        `model = "openai:gpt-4"`,
        `cleanup = false`,
        ``,
        `[web]`,
        `model = "openai:gpt-4"`,
        ``,
        `[repo]`,
        `model = "openai:gpt-4"`,
        `default_depth = 3`,
        `blob_limit = "10m"`,
      ].join("\n"),
    );
    const { config } = await loadConfig();
    expect(config.defaults.model).toBe("openai:gpt-4");
    expect(config.defaults.cleanup).toBe(false);
    expect(config.repo.default_depth).toBe(3);
  });

  test("error message includes path and expected type for diagnostics", async () => {
    writeFileSync(
      configPath,
      `[defaults]\nmodel = 42\ncleanup = true\n`,
    );
    try {
      await loadConfig();
      throw new Error("should have thrown");
    } catch (e: any) {
      expect(e).toBeInstanceOf(ConfigError);
      expect(e.message).toContain("/defaults/model");
      expect(e.message).toMatch(/string/i);
    }
  });
});

describe("partialObject deep-partial behavior", () => {
  const NestedSchema = Type.Object({
    name: Type.String(),
    inner: Type.Object({
      host: Type.String(),
      port: Type.Number(),
    }),
  });

  const PartialNested = partialObject(NestedSchema);

  test("accepts empty object (all fields optional at every level)", () => {
    const result = Value.Check(PartialNested, {});
    expect(result).toBe(true);
  });

  test("accepts object with nested section present but inner fields omitted", () => {
    const result = Value.Check(PartialNested, { inner: {} });
    expect(result).toBe(true);
  });

  test("accepts object with partial inner fields of a nested object", () => {
    const result = Value.Check(PartialNested, { inner: { host: "localhost" } });
    expect(result).toBe(true);
  });

  test("accepts fully specified object", () => {
    const result = Value.Check(PartialNested, {
      name: "test",
      inner: { host: "localhost", port: 8080 },
    });
    expect(result).toBe(true);
  });

  test("rejects wrong type inside nested object", () => {
    const result = Value.Check(PartialNested, { inner: { port: "not-a-number" } });
    expect(result).toBe(false);
  });

  test("rejects wrong type at top level", () => {
    const result = Value.Check(PartialNested, { name: 123 });
    expect(result).toBe(false);
  });

  test("does not recurse into Type.Record", () => {
    const SchemaWithRecord = Type.Object({
      headers: Type.Record(Type.String(), Type.String()),
    });
    const Partial = partialObject(SchemaWithRecord);
    // Record should remain as-is (not turned into a partial object)
    // Valid: omitted entirely
    expect(Value.Check(Partial, {})).toBe(true);
    // Valid: proper record
    expect(Value.Check(Partial, { headers: { "x-key": "val" } })).toBe(true);
    // Invalid: wrong value type in record
    expect(Value.Check(Partial, { headers: { "x-key": 42 } })).toBe(false);
  });

  test("handles deeply nested objects (3 levels)", () => {
    const DeepSchema = Type.Object({
      level1: Type.Object({
        level2: Type.Object({
          value: Type.Number(),
        }),
      }),
    });
    const PartialDeep = partialObject(DeepSchema);
    expect(Value.Check(PartialDeep, {})).toBe(true);
    expect(Value.Check(PartialDeep, { level1: {} })).toBe(true);
    expect(Value.Check(PartialDeep, { level1: { level2: {} } })).toBe(true);
    expect(Value.Check(PartialDeep, { level1: { level2: { value: 42 } } })).toBe(true);
    expect(Value.Check(PartialDeep, { level1: { level2: { value: "nope" } } })).toBe(false);
  });
});
