import { describe, it, expect } from "bun:test"
import {
  configUnsetPaths,
  ConfigState,
  deepMerge,
  mergeScopedConfig,
  pruneConfigSet,
  stripNulls,
} from "../../webview-ui/src/utils/config-utils"
import type { Config } from "../../webview-ui/src/types/messages"

// ---------------------------------------------------------------------------
// Pure helpers
// ---------------------------------------------------------------------------

describe("deepMerge", () => {
  it("overrides scalar values", () => {
    const target: Config = { snapshot: true }
    const source: Partial<Config> = { snapshot: false }
    expect(deepMerge(target, source)).toEqual({ snapshot: false })
  })

  it("merges nested objects recursively", () => {
    const target: Config = { agent: { code: { temperature: 0.5 } } }
    const source: Partial<Config> = { agent: { code: { steps: 10 } } }
    const result = deepMerge(target, source)
    expect(result.agent?.code?.temperature).toBe(0.5)
    expect(result.agent?.code?.steps).toBe(10)
  })

  it("preserves keys not present in source", () => {
    const target: Config = { snapshot: true, username: "alice" }
    const source: Partial<Config> = { snapshot: false }
    expect(deepMerge(target, source)).toEqual({ snapshot: false, username: "alice" })
  })

  it("replaces arrays instead of merging them", () => {
    const target: Config = { instructions: ["a", "b"] }
    const source: Partial<Config> = { instructions: ["c"] }
    expect(deepMerge(target, source)).toEqual({ instructions: ["c"] })
  })

  it("preserves explicit false values in nested agent config", () => {
    const target: Config = { agent: { code: { disable: true, hidden: true } } }
    const source: Partial<Config> = { agent: { code: { disable: false, hidden: false } } }
    const result = deepMerge(target, source)
    expect(result.agent?.code?.disable).toBe(false)
    expect(result.agent?.code?.hidden).toBe(false)
  })
})

describe("scoped config normalization", () => {
  it("preserves indexing null overrides while stripping unrelated nulls", () => {
    const target = { username: "alice", indexing: { model: "global", dimension: 1024 } } as Config
    const source = { username: null, indexing: { model: null, dimension: null } } as unknown as Partial<Config>

    expect(mergeScopedConfig(target, source)).toEqual({ indexing: { model: null, dimension: null } })
  })

  it("builds clean set and unset payloads while preserving indexing null overrides", () => {
    const patch = {
      formatter: {},
      username: null,
      indexing: {
        model: null,
        dimension: null,
        searchMinScore: undefined,
        qdrant: { apiKey: undefined },
      },
    }

    expect(pruneConfigSet(patch)).toEqual({
      formatter: {},
      indexing: { model: null, dimension: null },
    })
    expect(configUnsetPaths(patch)).toEqual([
      ["username"],
      ["indexing", "searchMinScore"],
      ["indexing", "qdrant", "apiKey"],
    ])
  })
})

describe("stripNulls", () => {
  it("removes null values", () => {
    const cfg = { snapshot: true, username: null } as unknown as Config
    expect(stripNulls(cfg)).toEqual({ snapshot: true })
  })

  it("removes undefined values", () => {
    const cfg = { snapshot: true, username: undefined } as unknown as Config
    expect(stripNulls(cfg)).toEqual({ snapshot: true })
  })

  it("strips nulls recursively in nested objects", () => {
    const cfg = { agent: { code: { temperature: 0.5, prompt: null } } } as unknown as Config
    expect(stripNulls(cfg)).toEqual({ agent: { code: { temperature: 0.5 } } })
  })
})

// ---------------------------------------------------------------------------
// Config state machine — reproduces the actual message-handler flow
// ---------------------------------------------------------------------------

describe("ConfigState", () => {
  it("configLoaded sets config when no draft is pending", () => {
    const s = new ConfigState()
    s.handleConfigLoaded({ snapshot: true, username: "alice" })
    expect(s.config).toEqual({ snapshot: true, username: "alice" })
    expect(s.loading).toBe(false)
  })

  describe("configLoaded while draft is pending (the reported bug)", () => {
    it("preserves the user's pending toggle change", () => {
      const s = new ConfigState()

      // 1. Server sends initial config
      s.handleConfigLoaded({ snapshot: true, username: "alice" })
      expect(s.config.snapshot).toBe(true)

      // 2. User toggles snapshot off (but hasn't saved yet)
      s.updateConfig({ snapshot: false })
      expect(s.config.snapshot).toBe(false)
      expect(s.dirty).toBe(true)

      // 3. A configLoaded push arrives from the extension (e.g. SSE event,
      //    tab switch, or another webview triggers a config reload).
      //    The server still has snapshot: true.
      s.handleConfigLoaded({ snapshot: true, username: "alice" })

      // BUG (old code): config.snapshot would be reset to true here
      // FIX: the draft is re-applied, so the user's toggle stays false
      expect(s.config.snapshot).toBe(false)
      expect(s.config.username).toBe("alice")
      expect(s.dirty).toBe(true)
    })

    it("preserves nested draft changes across configLoaded pushes", () => {
      const s = new ConfigState()
      s.handleConfigLoaded({ agent: { code: { temperature: 0.7 } } })
      s.updateConfig({ agent: { code: { steps: 5 } } })

      // Server pushes a reload — temperature may have changed server-side
      s.handleConfigLoaded({ agent: { code: { temperature: 0.9 } } })

      expect(s.config.agent?.code?.steps).toBe(5)
      expect(s.config.agent?.code?.temperature).toBe(0.9)
    })

    it("preserves explicit false agent flags across configLoaded pushes", () => {
      const s = new ConfigState()
      s.handleConfigLoaded({ agent: { code: { disable: true, hidden: true } } })
      s.updateConfig({ agent: { code: { disable: false, hidden: false } } })

      s.handleConfigLoaded({ agent: { code: { disable: true, hidden: true } } })

      expect(s.config.agent?.code?.disable).toBe(false)
      expect(s.config.agent?.code?.hidden).toBe(false)
    })

    it("preserves clearing default_agent when the current default is hidden", () => {
      const s = new ConfigState()
      s.handleConfigLoaded({ default_agent: "code", agent: { code: { hidden: false } } })

      s.updateConfig({ agent: { code: { hidden: true } } })
      s.updateConfig({ default_agent: null })

      s.handleConfigLoaded({ default_agent: "code", agent: { code: { hidden: false } } })

      expect(s.config.agent?.code?.hidden).toBe(true)
      expect(s.config.default_agent).toBeUndefined()
    })

    it("preserves clearing default_agent when the current default is disabled", () => {
      const s = new ConfigState()
      s.handleConfigLoaded({ default_agent: "code", agent: { code: { disable: false } } })

      s.updateConfig({ agent: { code: { disable: true } } })
      s.updateConfig({ default_agent: null })

      s.handleConfigLoaded({ default_agent: "code", agent: { code: { disable: false } } })

      expect(s.config.agent?.code?.disable).toBe(true)
      expect(s.config.default_agent).toBeUndefined()
    })
  })

  describe("configUpdated while draft is pending", () => {
    it("preserves draft when update comes from another source", () => {
      const s = new ConfigState()
      s.handleConfigLoaded({ snapshot: true, username: "alice" })
      s.updateConfig({ snapshot: false })

      // Another webview (e.g. PermissionDock) saves a different setting
      s.handleConfigUpdated({ snapshot: true, username: "bob" })

      expect(s.config.snapshot).toBe(false) // draft preserved
      expect(s.config.username).toBe("bob") // server update applied
    })

    it("clears draft when update confirms our save", () => {
      const s = new ConfigState()
      s.handleConfigLoaded({ snapshot: true })
      s.updateConfig({ snapshot: false })
      s.saveConfig()
      expect(s.saving).toBe(true)

      // Server confirms the write
      s.handleConfigUpdated({ snapshot: false })

      expect(s.config.snapshot).toBe(false)
      expect(s.dirty).toBe(false)
      expect(s.saving).toBe(false)
      expect(Object.keys(s.draft).length).toBe(0)
    })

    it("clears default_agent when update confirms a null-sentinel save", () => {
      const s = new ConfigState()
      s.handleConfigLoaded({ default_agent: "code" })
      s.updateConfig({ default_agent: null })
      s.saveConfig()

      // Server confirms the write by returning config without default_agent.
      s.handleConfigUpdated({})

      expect(s.config.default_agent).toBeUndefined()
      expect(s.dirty).toBe(false)
      expect(s.saving).toBe(false)
      expect(Object.keys(s.draft).length).toBe(0)
    })

    it("preserves the null delete sentinel in the pending save payload", () => {
      const s = new ConfigState()
      s.handleConfigLoaded({ default_agent: "code" })
      s.updateConfig({ default_agent: null })

      expect(s.draft.default_agent).toBeNull()

      s.saveConfig()

      expect(s.saving).toBe(true)
      expect(s.draft.default_agent).toBeNull()
    })
  })

  describe("configSaved while a save is in-flight", () => {
    it("clears the draft after a confirmed write even if merged refresh is pending", () => {
      const s = new ConfigState()
      s.handleConfigLoaded({ agent: { code: { prompt: "Review" } } })
      s.updateConfig({ agent: { code: { prompt: null } } })
      s.saveConfig()

      s.handleConfigSaved()

      expect(s.saving).toBe(false)
      expect(s.dirty).toBe(false)
      expect(Object.keys(s.draft).length).toBe(0)
      expect(s.saved.agent?.code?.prompt).toBeUndefined()
      expect(s.config.agent?.code?.prompt).toBeUndefined()
    })
  })

  describe("configSaveFailed while a save is in-flight", () => {
    it("preserves pending null-sentinel clears so the user can retry", () => {
      const s = new ConfigState()
      s.handleConfigLoaded({ agent: { code: { prompt: "Review", temperature: 0.7 } }, default_agent: "code" })
      s.updateConfig({ agent: { code: { prompt: null, temperature: null } } })
      s.updateConfig({ default_agent: null })
      s.saveConfig()

      s.handleConfigSaveFailed({ agent: { code: { prompt: "Review", temperature: 0.7 } }, default_agent: "code" })

      expect(s.saving).toBe(false)
      expect(s.dirty).toBe(true)
      expect(s.draft.agent?.code?.prompt).toBeNull()
      expect(s.draft.agent?.code?.temperature).toBeNull()
      expect(s.draft.default_agent).toBeNull()
      expect(s.config.agent?.code?.prompt).toBeUndefined()
      expect(s.config.agent?.code?.temperature).toBeUndefined()
      expect(s.config.default_agent).toBeUndefined()
    })
  })

  it("ignores repeated save attempts while a save is already in-flight", () => {
    const s = new ConfigState()
    s.handleConfigLoaded({ snapshot: true })
    s.updateConfig({ snapshot: false })

    s.saveConfig()
    s.saveConfig()
    s.handleConfigUpdated({ snapshot: false })

    expect(s.saving).toBe(false)
    expect(s.dirty).toBe(false)
    expect(s.config.snapshot).toBe(false)
  })

  it("configLoaded is ignored while save is in-flight", () => {
    const s = new ConfigState()
    s.handleConfigLoaded({ snapshot: true })
    s.updateConfig({ snapshot: false })
    s.saveConfig()

    // A stale configLoaded arrives during the write round-trip
    s.handleConfigLoaded({ snapshot: true })

    // Config must not revert — the save is still in flight
    expect(s.config.snapshot).toBe(false)
  })

  it("discardConfig restores server state", () => {
    const s = new ConfigState()
    s.handleConfigLoaded({ snapshot: true, username: "alice" })
    s.updateConfig({ snapshot: false })
    expect(s.dirty).toBe(true)

    s.discardConfig()

    expect(s.config.snapshot).toBe(true)
    expect(s.dirty).toBe(false)
  })

  // -------------------------------------------------------------------------
  // Issue #9527: clearing an agent model override must unset it, not repopulate
  // -------------------------------------------------------------------------
  describe("clearing an agent model override (issue #9527)", () => {
    it("keeps null in the draft so the backend receives a delete sentinel", () => {
      const s = new ConfigState()
      s.handleConfigLoaded({ agent: { explore: { model: "anthropic/claude-sonnet-4-20250514" } } })

      // User clears the Model Override field. ModeEditView now sends `null`
      // instead of `undefined` (the fix). null is the delete sentinel that
      // patchJsonc maps to jsonc-parser's remove operation.
      s.updateConfig({ agent: { explore: { model: null } } })

      // Optimistic UI: stripNulls removes the key so the field renders empty.
      expect(s.config.agent?.explore?.model).toBeUndefined()
      expect(s.dirty).toBe(true)

      // Draft must retain the null so it survives JSON.stringify on the wire
      // and reaches patchJsonc as an explicit delete.
      expect(s.draft.agent?.explore?.model).toBeNull()
      expect(JSON.parse(JSON.stringify(s.draft))).toEqual({
        agent: { explore: { model: null } },
      })
    })

    it("undefined (the old buggy behavior) is dropped by JSON.stringify", () => {
      // Reproduction of the pre-fix bug: sending `undefined` results in an
      // empty patch on the wire, so the backend never deletes the override
      // and the next configUpdated pushes the stale model back into the UI.
      const draft = { agent: { explore: { model: undefined } } }
      expect(JSON.parse(JSON.stringify(draft))).toEqual({ agent: { explore: {} } })
    })

    it("confirms the save and drops the draft once the backend acks", () => {
      const s = new ConfigState()
      s.handleConfigLoaded({ agent: { explore: { model: "anthropic/claude-sonnet-4-20250514" } } })
      s.updateConfig({ agent: { explore: { model: null } } })
      s.saveConfig()

      // Backend removed the override and pushes the stripped config back.
      s.handleConfigUpdated({ agent: { explore: {} } })

      expect(s.config.agent?.explore?.model).toBeUndefined()
      expect(s.dirty).toBe(false)
      expect(s.saving).toBe(false)
      expect(Object.keys(s.draft).length).toBe(0)
    })
  })

  describe("clearing an agent variant override", () => {
    it("keeps null in the draft so the backend receives a delete sentinel", () => {
      const s = new ConfigState()
      s.handleConfigLoaded({
        agent: {
          explore: {
            model: "kilo/anthropic/claude-sonnet-4-6",
            variant: "high",
          },
        },
      })

      s.updateConfig({ agent: { explore: { variant: null } } })

      expect(s.config.agent?.explore?.variant).toBeUndefined()
      expect(s.dirty).toBe(true)
      expect(s.draft.agent?.explore?.variant).toBeNull()
      expect(JSON.parse(JSON.stringify(s.draft))).toEqual({
        agent: { explore: { variant: null } },
      })
    })
  })

  describe("agent permission patches", () => {
    it("merges nested per-agent permission patches into existing rules", () => {
      const s = new ConfigState()
      s.handleConfigLoaded({
        agent: {
          reviewer: {
            permission: {
              read: "allow",
              edit: "deny",
            },
          },
        },
      })

      s.updateConfig({ agent: { reviewer: { permission: { bash: "ask" } } } })

      expect(s.config.agent?.reviewer?.permission).toEqual({
        read: "allow",
        edit: "deny",
        bash: "ask",
      })
      expect(s.draft.agent?.reviewer?.permission).toEqual({ bash: "ask" })
    })

    it("keeps nested permission delete sentinels in the draft", () => {
      const s = new ConfigState()
      s.handleConfigLoaded({
        agent: {
          docs: {
            permission: {
              edit: { "*": "deny", "**/*.md": "allow" },
            },
          },
        },
      })

      s.updateConfig({ agent: { docs: { permission: { edit: { "**/*.md": null } } } } })

      expect(s.config.agent?.docs?.permission).toEqual({ edit: { "*": "deny" } })
      expect(s.draft.agent?.docs?.permission).toEqual({ edit: { "**/*.md": null } })
      expect(JSON.parse(JSON.stringify(s.draft))).toEqual({
        agent: { docs: { permission: { edit: { "**/*.md": null } } } },
      })
    })

    it("keeps tool-level permission delete sentinels in the draft", () => {
      const s = new ConfigState()
      s.handleConfigLoaded({
        agent: {
          reviewer: {
            permission: {
              read: "allow",
              bash: "deny",
            },
          },
        },
      })

      s.updateConfig({ agent: { reviewer: { permission: { bash: null } } } })

      expect(s.config.agent?.reviewer?.permission).toEqual({ read: "allow" })
      expect(s.draft.agent?.reviewer?.permission).toEqual({ bash: null })
      expect(JSON.parse(JSON.stringify(s.draft))).toEqual({
        agent: { reviewer: { permission: { bash: null } } },
      })
    })
  })
})
