import { beforeEach, describe, expect, it } from "vitest"
import {
  type EditorBlock,
  extractEmbeddedId,
  serializeBlocks,
  splitMarkdownIntoBlocks,
  stampBlockId,
} from "@/lib/block-parser"

// Deterministic ID generator for testing.
let nextId = 0
function testId(): string {
  return String(nextId++)
}

beforeEach(() => {
  nextId = 0
})

function parse(md: string): EditorBlock[] {
  return splitMarkdownIntoBlocks(md, testId)
}

describe("splitMarkdownIntoBlocks", () => {
  it("single block", () => {
    const result = parse("* TODO my item")
    expect(result).toEqual([{ id: "0", depth: 0, content: "TODO my item" }])
  })

  it("multiple blocks", () => {
    const result = parse("* Item one\n* Item two")
    expect(result).toEqual([
      { id: "0", depth: 0, content: "Item one" },
      { id: "1", depth: 0, content: "Item two" },
    ])
  })

  it("nested blocks", () => {
    const result = parse("* Root\n  * Child\n* Another")
    expect(result).toEqual([
      { id: "0", depth: 0, content: "Root" },
      { id: "1", depth: 1, content: "Child" },
      { id: "2", depth: 0, content: "Another" },
    ])
  })

  it("deep nesting", () => {
    const result = parse("* A\n  * B\n    * C\n  * D\n* E")
    expect(result).toEqual([
      { id: "0", depth: 0, content: "A" },
      { id: "1", depth: 1, content: "B" },
      { id: "2", depth: 2, content: "C" },
      { id: "3", depth: 1, content: "D" },
      { id: "4", depth: 0, content: "E" },
    ])
  })

  it("continuation lines without blank lines", () => {
    const result = parse("* Item with\n  continuation")
    expect(result).toHaveLength(1)
    expect(result[0].content).toBe("Item with\ncontinuation")
  })

  it("continuation lines strip structural GFM indent", () => {
    const result = parse("* TODO my item\n  gotta do the thing")
    expect(result).toHaveLength(1)
    expect(result[0].content).toBe("TODO my item\ngotta do the thing")
  })

  it("code fence inside a block", () => {
    const result = parse(
      "* A note\n  ```ts\n  const x = 1\n  const y = 2\n  ```",
    )
    expect(result).toHaveLength(1)
    expect(result[0].depth).toBe(0)
    expect(result[0].content).toContain("```ts")
    expect(result[0].content).toContain("const x = 1")
    expect(result[0].content).toContain("const y = 2")
  })

  it("blank line inside fence inside a block", () => {
    const md = "* A note\n  ```ts\n  const x = 1\n\n  const y = 2\n  ```"
    const result = parse(md)
    expect(result).toHaveLength(1)
    expect(result[0].content).toContain("\n\n")
  })

  it("two fences in one block", () => {
    const md =
      "* Thoughts\n  ```ts\n  const x = 1\n  ```\n  and also\n  ```css\n  .a { color: red }\n  ```"
    const result = parse(md)
    expect(result).toHaveLength(1)
    expect(result[0].content).toMatch(/```ts/)
    expect(result[0].content).toMatch(/```css/)
  })

  it("tilde fence inside a block", () => {
    const result = parse("* Using tilde\n  ~~~\n  code\n  ~~~")
    expect(result).toHaveLength(1)
    expect(result[0].content).toContain("~~~")
    expect(result[0].content).toContain("code")
  })

  it("nested item with fence", () => {
    const result = parse(
      "* Root\n  * Child with\n    ```py\n    pass\n    ```\n* Another",
    )
    expect(result).toHaveLength(3)
    expect(result[0]).toEqual({
      id: "0",
      depth: 0,
      content: "Root",
    })
    expect(result[1].depth).toBe(1)
    expect(result[1].content).toContain("Child with")
    expect(result[1].content).toContain("```py")
    expect(result[2]).toEqual({ id: "2", depth: 0, content: "Another" })
  })

  it("multiple blank lines between items", () => {
    const md = "* One\n\n\n* Two"
    const result = parse(md)
    expect(result).toEqual([
      { id: "0", depth: 0, content: "One" },
      { id: "1", depth: 0, content: "Two" },
    ])
  })

  it("blank line between items", () => {
    const md = "* One\n\n* Two"
    const result = parse(md)
    expect(result).toEqual([
      { id: "0", depth: 0, content: "One" },
      { id: "1", depth: 0, content: "Two" },
    ])
  })

  it("blank line between non-fence content splits list item (GFM behavior)", () => {
    // In GFM, a blank line between non-fence content within a list item
    // terminates the item. "Continuation" becomes a standalone paragraph
    // outside the list and is NOT captured as a block.
    const result = parse("* One\n\nContinuation\n* Two")
    expect(result).toHaveLength(2)
    expect(result[0].content).toBe("One")
    expect(result[1].content).toBe("Two")
  })

  it("empty block between non-empty blocks", () => {
    const result = parse("* \n* Item")
    expect(result).toHaveLength(2)
    expect(result[0].content).toBe("")
    expect(result[1].content).toBe("Item")
  })

  it("task state in block content", () => {
    const result = parse("* TODO my first item\n* DONE already completed")
    expect(result[0].content).toBe("TODO my first item")
    expect(result[1].content).toBe("DONE already completed")
  })

  it("block with inline math", () => {
    const result = parse("* Solve $x^2 + y^2 = z^2$")
    expect(result[0].content).toContain("$x^2 + y^2 = z^2$")
  })

  it("empty document", () => {
    expect(parse("")).toEqual([])
    expect(parse("  ")).toEqual([])
  })

  it("empty block", () => {
    const result = parse("* ")
    expect(result).toHaveLength(1)
    expect(result[0].content).toBe("")
    expect(result[0].depth).toBe(0)
  })

  it("single newline only", () => {
    expect(parse("\n")).toEqual([])
  })

  it("block with leading whitespace in content", () => {
    // Content after "* " should preserve its own leading whitespace
    const result = parse("*   indented content")
    expect(result[0].content).toBe("  indented content")
  })
})

describe("ID preservation", () => {
  it("preserves IDs by position when existing blocks provided", () => {
    const existing: EditorBlock[] = [
      { id: "keep-a", depth: 0, content: "old one" },
      { id: "keep-b", depth: 0, content: "old two" },
    ]
    const result = splitMarkdownIntoBlocks(
      "* new one\n* new two",
      testId,
      existing,
    )
    expect(result).toHaveLength(2)
    expect(result[0].id).toBe("keep-a")
    expect(result[1].id).toBe("keep-b")
  })

  it("generates new IDs for surplus blocks beyond existing", () => {
    const existing: EditorBlock[] = [{ id: "keep-a", depth: 0, content: "old" }]
    const result = splitMarkdownIntoBlocks(
      "* one\n* two\n* three",
      testId,
      existing,
    )
    expect(result).toHaveLength(3)
    expect(result[0].id).toBe("keep-a")
    expect(result[1].id).toBe("0") // new, from testId counter
    expect(result[2].id).toBe("1")
  })

  it("ignores existing IDs beyond parsed block count", () => {
    // When there are fewer blocks after re-parse, the extra existing IDs are dropped
    const existing: EditorBlock[] = [
      { id: "keep-a", depth: 0, content: "old one" },
      { id: "dropped-b", depth: 0, content: "old two" },
    ]
    const result = splitMarkdownIntoBlocks("* only one", testId, existing)
    expect(result).toHaveLength(1)
    expect(result[0].id).toBe("keep-a")
  })

  it("content and depth are always from the parsed input, not existing", () => {
    const existing: EditorBlock[] = [
      { id: "keep", depth: 99, content: "old content" },
    ]
    const result = splitMarkdownIntoBlocks("* new content", testId, existing)
    expect(result).toHaveLength(1)
    expect(result[0].id).toBe("keep")
    expect(result[0].content).toBe("new content")
    expect(result[0].depth).toBe(0)
  })
})

describe("embedded id::", () => {
  it("extractEmbeddedId returns null when no id:: present", () => {
    expect(extractEmbeddedId("TODO my item")).toBeNull()
    expect(extractEmbeddedId("")).toBeNull()
    expect(extractEmbeddedId("author:: John")).toBeNull()
  })

  it("extractEmbeddedId finds id:: on last line", () => {
    const content = "TODO Do something\nid:: abc-123"
    expect(extractEmbeddedId(content)).toBe("abc-123")
  })

  it("extractEmbeddedId finds id:: on last line with leading whitespace", () => {
    const content = "TODO Do something\n  id:: abc-123"
    expect(extractEmbeddedId(content)).toBe("abc-123")
  })

  it("extractEmbeddedId skips id:: in middle of text line", () => {
    // Line starting with "id::" must be a standalone property line
    expect(extractEmbeddedId("Consider id:: not-an-id")).toBeNull()
  })

  it("splitMarkdownIntoBlocks uses embedded id:: when present", () => {
    const result = parse("* TODO item\n  id:: my-embedded-id")
    expect(result).toHaveLength(1)
    expect(result[0].id).toBe("my-embedded-id")
    expect(result[0].content).toBe("TODO item\nid:: my-embedded-id")
  })

  it("embedded id:: takes priority over position-based preservation", () => {
    const existing: EditorBlock[] = [{ id: "pos-id", depth: 0, content: "old" }]
    const result = splitMarkdownIntoBlocks(
      "* content\n  id:: embedded-id",
      testId,
      existing,
    )
    expect(result).toHaveLength(1)
    // Embedded id:: wins over position match
    expect(result[0].id).toBe("embedded-id")
  })

  it("stampBlockId adds id:: to content with continuation indent", () => {
    const block: EditorBlock = { id: "my-uuid", depth: 0, content: "TODO item" }
    stampBlockId(block)
    expect(block.content).toBe("TODO item\n  id:: my-uuid")
  })

  it("stampBlockId indents by (depth + 1) * 2 spaces", () => {
    const block: EditorBlock = {
      id: "nested-uuid",
      depth: 2,
      content: "Nested item",
    }
    stampBlockId(block)
    // depth 2 → continuation indent = 6 spaces (3 * 2)
    expect(block.content).toBe("Nested item\n      id:: nested-uuid")
  })

  it("stampBlockId is idempotent", () => {
    const block: EditorBlock = {
      id: "my-uuid",
      depth: 0,
      content: "TODO item\n  id:: my-uuid",
    }
    stampBlockId(block)
    // Content unchanged — already has id:: line
    expect(block.content).toBe("TODO item\n  id:: my-uuid")
  })

  it("stampBlockId matches different embedded id", () => {
    // Edge case: block.id differs from id:: in content (should not re-stamp)
    const block: EditorBlock = {
      id: "different-id",
      depth: 0,
      content: "TODO item\n  id:: existing-id",
    }
    stampBlockId(block)
    expect(block.content).toBe("TODO item\n  id:: existing-id")
  })

  it("serializeBlocks preserves id:: line in content", () => {
    // Content with continuation indentation on the id:: line, matching
    // how the parser produces it from "  id:: ...".
    const blocks: EditorBlock[] = [
      { id: "abc-123", depth: 0, content: "Referenced block\n  id:: abc-123" },
    ]
    expect(serializeBlocks(blocks)).toBe("* Referenced block\n  id:: abc-123")
  })

  it("round-trip preserves embedded id::", () => {
    const md = "* Referenced block\n  id:: abc-123"
    const blocks = parse(md)
    expect(blocks[0].id).toBe("abc-123")
    expect(serializeBlocks(blocks)).toBe(md)
  })
})

describe("block operations", () => {
  it("split: creates a new block after index with given content", () => {
    const blocks = parse("* TODO Alpha\n* DONE Beta")
    const after = blocks[0]
    // Simulate split: current block keeps "TODO", new block gets "Alpha"
    after.content = "TODO"
    const newBlock: EditorBlock = {
      id: "new",
      depth: after.depth,
      content: "Alpha",
    }
    blocks.splice(1, 0, newBlock)
    expect(serializeBlocks(blocks)).toBe("* TODO\n* Alpha\n* DONE Beta")
  })

  it("split: new block inherits depth from split block", () => {
    // Nested via parent-child, not bare indentation
    const blocks = parse("* Parent\n  * Child")
    // The child block (index 1) has depth 1
    const newBlock: EditorBlock = {
      id: "new",
      depth: blocks[1].depth,
      content: "",
    }
    blocks.splice(2, 0, newBlock)
    const result = serializeBlocks(blocks)
    expect(result).toBe("* Parent\n  * Child\n  * ")
    expect(newBlock.depth).toBe(1)
  })

  it("merge: appends current content to previous, join pos at boundary", () => {
    const blocks = parse("* Hello\n* World")
    const prev = blocks[0]
    const current = blocks[1]
    const joinPos = prev.content.length > 0 ? prev.content.length + 1 : 0
    prev.content = `${prev.content}${current.content}`
    blocks.splice(1, 1)
    expect(prev.content).toBe("HelloWorld")
    // PM positions are 1-based: position 6 = after "Hello" (5 chars + 1)
    expect(joinPos).toBe(6)
  })

  it("merge: empty prev block takes current content, join at 0", () => {
    const blocks = parse("* \n* World")
    const prev = blocks[0]
    const current = blocks[1]
    const joinPos = prev.content.length > 0 ? prev.content.length + 1 : 0
    prev.content = `${prev.content}${current.content}`
    blocks.splice(1, 1)
    expect(prev.content).toBe("World")
    expect(joinPos).toBe(0)
  })

  it("merge: preserves depth of previous block", () => {
    const blocks = parse("* Depth 1\n* Depth 0")
    const prev = blocks[0]
    const current = blocks[1]
    const joinPos = prev.content.length > 0 ? prev.content.length + 1 : 0
    prev.content = `${prev.content}${current.content}`
    blocks.splice(1, 1)
    expect(prev.content).toBe("Depth 1Depth 0")
    // PM position after "Depth 1" (7 chars + 1)
    expect(joinPos).toBe(8)
  })

  it("delete: removes block at index", () => {
    const blocks = parse("* Keep\n* Remove\n* Keep")
    blocks.splice(1, 1)
    expect(serializeBlocks(blocks)).toBe("* Keep\n* Keep")
  })

  it("delete: retains last block", () => {
    const blocks = parse("* Solo")
    expect(blocks.length).toBe(1)
    // Simulate delete-if-empty guard: don't delete last block
    if (blocks.length > 1) {
      blocks.splice(0, 1)
    }
    expect(serializeBlocks(blocks)).toBe("* Solo")
  })

  describe("indent/outdent cascade", () => {
    it("indent: cascades depth increase to children", () => {
      const blocks = parse("* Parent\n  * Child\n    * Grandchild\n* Other")
      const parent = blocks[0]
      const child = blocks[1]
      const grandchild = blocks[2]
      const other = blocks[3]
      expect(parent.depth).toBe(0)
      expect(child.depth).toBe(1)
      expect(grandchild.depth).toBe(2)

      const delta = parent.depth < 10 ? 1 : 0
      const children: number[] = []
      for (let i = 1; i < blocks.length; i++) {
        if (blocks[i].depth > parent.depth) children.push(i)
        else break
      }
      const affected = [0, ...children]
      for (const i of affected) {
        blocks[i].depth = Math.min(blocks[i].depth + delta, 10)
      }

      expect(parent.depth).toBe(1)
      expect(child.depth).toBe(2)
      expect(grandchild.depth).toBe(3)
      // Sibling is not affected
      expect(other.depth).toBe(0)
    })

    it("outdent: cascades depth decrease to children", () => {
      const blocks = parse("* Root\n  * Section\n    * Subsection\n* Footer")
      const section = blocks[1]
      const subsection = blocks[2]
      expect(section.depth).toBe(1)
      expect(subsection.depth).toBe(2)

      const delta = section.depth > 0 ? -1 : 0
      const children: number[] = []
      for (let i = 2; i < blocks.length; i++) {
        if (blocks[i].depth > section.depth) children.push(i)
        else break
      }
      const affected = [1, ...children]
      for (const i of affected) {
        blocks[i].depth = Math.max(blocks[i].depth + delta, 0)
      }

      expect(section.depth).toBe(0)
      expect(subsection.depth).toBe(1)
    })

    it("indent: does not cascade to siblings at same depth", () => {
      const blocks = parse("* A\n  * B\n* C")
      const a = blocks[0]
      const b = blocks[1]
      const c = blocks[2]
      expect(a.depth).toBe(0)
      expect(b.depth).toBe(1)

      const delta = a.depth < 10 ? 1 : 0
      const children: number[] = []
      for (let i = 1; i < blocks.length; i++) {
        if (blocks[i].depth > a.depth) children.push(i)
        else break
      }
      for (const i of [0, ...children]) {
        blocks[i].depth = Math.min(blocks[i].depth + delta, 10)
      }

      expect(a.depth).toBe(1)
      expect(b.depth).toBe(2)
      // C is a sibling, not a child — stays at depth 0
      expect(c.depth).toBe(0)
    })

    it("indent: clamped at depth 10", () => {
      const blocks: EditorBlock[] = [{ id: "0", depth: 10, content: "Maxed" }]
      const delta = blocks[0].depth < 10 ? 1 : 0
      expect(delta).toBe(0)
      // No change when at max depth
      blocks[0].depth = Math.min(blocks[0].depth + 1, 10)
      expect(blocks[0].depth).toBe(10)
    })

    it("outdent: does not go below depth 0", () => {
      const blocks: EditorBlock[] = [{ id: "0", depth: 0, content: "Root" }]
      const delta = blocks[0].depth > 0 ? -1 : 0
      expect(delta).toBe(0)
      blocks[0].depth = Math.max(blocks[0].depth + delta, 0)
      expect(blocks[0].depth).toBe(0)
    })
  })
})

describe("serializeBlocks", () => {
  it("single block", () => {
    const md = serializeBlocks([{ id: "0", depth: 0, content: "TODO my item" }])
    expect(md).toBe("* TODO my item")
  })

  it("multiple blocks", () => {
    const md = serializeBlocks([
      { id: "0", depth: 0, content: "Item one" },
      { id: "1", depth: 0, content: "Item two" },
    ])
    expect(md).toBe("* Item one\n* Item two")
  })

  it("nested blocks", () => {
    const md = serializeBlocks([
      { id: "0", depth: 0, content: "Root" },
      { id: "1", depth: 1, content: "Child" },
      { id: "2", depth: 0, content: "Another" },
    ])
    expect(md).toBe("* Root\n  * Child\n* Another")
  })
})

describe("round-trip", () => {
  it("lossless for simple blocks", () => {
    const md = "* Item one\n* Item two"
    expect(serializeBlocks(parse(md))).toBe(md)
  })

  it("lossless for nested blocks", () => {
    const md = "* Root\n  * Child\n    * Grandchild\n  * Another\n* End"
    expect(serializeBlocks(parse(md))).toBe(md)
  })

  it("lossless for block with code fence", () => {
    const md = "* A note\n  ```ts\n  const x = 1\n  ```"
    expect(serializeBlocks(parse(md))).toBe(md)
  })

  it("lossless for continuation lines", () => {
    const md = "* Item with\n  continuation"
    expect(serializeBlocks(parse(md))).toBe(md)
  })

  it("lossless for blank lines inside fence", () => {
    const md = "* A note\n  ```ts\n  const x = 1\n\n  const y = 2\n  ```"
    expect(serializeBlocks(parse(md))).toBe(md)
  })

  it("lossless for two fences in one block", () => {
    const md =
      "* Thoughts\n  ```ts\n  const x = 1\n  ```\n  and also\n  ```css\n  .a { color: red }\n  ```"
    expect(serializeBlocks(parse(md))).toBe(md)
  })

  it("lossless for tilde fence", () => {
    const md = "* Using tilde\n  ~~~\n  code\n  ~~~"
    expect(serializeBlocks(parse(md))).toBe(md)
  })

  it("lossless for nested item with fence", () => {
    const md = "* Root\n  * Child with\n    ```py\n    pass\n    ```\n* Another"
    expect(serializeBlocks(parse(md))).toBe(md)
  })

  it("lossless for task states", () => {
    const md = "* TODO first\n* DONE second\n* LATER third"
    expect(serializeBlocks(parse(md))).toBe(md)
  })

  it("lossless for empty blocks", () => {
    const md = "* \n* Item\n* "
    expect(serializeBlocks(parse(md))).toBe(md)
  })

  it("blank lines between items are normalized away on serialize", () => {
    // Blank lines between items are consumed by the parser — they
    // don't affect block content. The re-serialized output normalizes
    // them away, which is intentional.
    const md = "* One\n\n* Two"
    const result = parse(md)
    expect(result).toEqual([
      { id: "0", depth: 0, content: "One" },
      { id: "1", depth: 0, content: "Two" },
    ])
    expect(serializeBlocks(result)).toBe("* One\n* Two")
  })
})
