import { expect, test, type Locator, type Page } from "@playwright/test"
import { mockNovaClawServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"

const directory = "C:/NovaClaw/TimelineStateRegression"
const projectID = "proj_timeline_state_regression"
const sessionID = "ses_timeline_state_regression"
const userMessageID = "msg_user_regression"
const assistantMessageID = "msg_assistant_regression"
const editPartID = "prt_0001_edit"
const textPartID = "prt_9999_text"
const title = "Timeline collapse state regression"
const model = { providerID: "novaclaw", modelID: "claude-opus-4-6", variant: "max" }

type EventPayload = {
  directory: string
  payload: Record<string, unknown>
}

declare global {
  interface Window {
    __timelineDiffProbe: {
      reset: () => void
      shadowRoots: () => number
    }
  }
}

const userMessage = {
  info: {
    id: userMessageID,
    sessionID,
    role: "user",
    time: { created: 1700000000000 },
    summary: { diffs: [] },
    agent: "build",
    model,
  },
  parts: [
    {
      id: "prt_user_text",
      sessionID,
      messageID: userMessageID,
      type: "text",
      text: "Please edit the file.",
    },
  ],
}

const editPart = {
  id: editPartID,
  sessionID,
  messageID: assistantMessageID,
  type: "tool",
  callID: "call_edit_regression",
  tool: "edit",
  state: {
    status: "completed",
    input: { filePath: "src/regression.ts" },
    output: "Edited src/regression.ts",
    title: "src/regression.ts",
    metadata: {
      filediff: {
        file: "src/regression.ts",
        additions: 1,
        deletions: 1,
        before: "export const value = 'before'\n",
        after: "export const value = 'after'\n",
      },
      diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
    },
    time: { start: 1700000001000, end: 1700000002000 },
  },
}

const streamedTextPart = {
  id: textPartID,
  sessionID,
  messageID: assistantMessageID,
  type: "text",
  text: "Streaming added a later assistant text part.",
}

const assistantMessage = {
  info: {
    id: assistantMessageID,
    sessionID,
    role: "assistant",
    time: { created: 1700000001000 },
    parentID: userMessageID,
    modelID: model.modelID,
    providerID: model.providerID,
    mode: "build",
    agent: "build",
    path: { cwd: directory, root: directory },
    cost: 0.01,
    tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
    variant: "max",
  },
  parts: [editPart],
}

test.describe("regression: session timeline local row state", () => {
  test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => {
    const events: EventPayload[] = []
    await mockServer(page, events)
    await configurePage(page)

    await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
    await expectSessionTitle(page, title)

    const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
    await expectAppVisible(wrapper)
    await expectExpanded(wrapper, true)

    await wrapper.evaluate((element) => {
      ;(element as HTMLElement).dataset.regressionMarker = "before-stream"
    })
    await wrapper.locator('[data-slot="collapsible-trigger"]').first().click()
    await expectExpanded(wrapper, false)

    events.push({
      directory,
      payload: {
        type: "message.part.updated",
        properties: { part: streamedTextPart },
      },
    })

    await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })

    expect(await readToolState(page)).toEqual({
      expanded: false,
      row: "AssistantPart",
      streamedTextVisible: true,
    })
  })

  test("does not remount an edit diff when sibling parts or diff counts update", async ({ page }) => {
    const events: EventPayload[] = []
    await installDiffProbe(page)
    await mockServer(page, events)
    await configurePage(page)

    await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
    await expectSessionTitle(page, title)

    const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
    await expectAppVisible(wrapper)
    const file = wrapper.locator('[data-component="file"][data-mode="diff"]').first()
    await expectAppVisible(file)
    await markDiffProbe(page)

    events.push({
      directory,
      payload: {
        type: "message.part.updated",
        properties: { part: streamedTextPart },
      },
    })

    await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
    const siblingProbe = await readDiffProbe(page)
    expect(siblingProbe).toEqual({
      fileMarker: "before",
      frameMarker: "before",
      rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
      rowMarker: "before",
      shadowRoots: 0,
      toolMarker: "before",
    })

    await markDiffProbe(page)
    events.push({
      directory,
      payload: {
        type: "message.part.updated",
        properties: { part: editPartWithAdditions(2) },
      },
    })

    await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible(
      { timeout: 10_000 },
    )
    expect(await readDiffProbe(page)).toEqual({
      fileMarker: "before",
      frameMarker: "before",
      rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
      rowMarker: "before",
      shadowRoots: 0,
      toolMarker: "before",
    })
  })

  test("keeps a sticky edit header aligned with a multi-hunk diff", async ({ page }) => {
    const events: EventPayload[] = []
    const lines = Array.from({ length: 1_000 }, (_, index) => `export const value${index} = ${index}\n`).join("")
    const after = [100, 300, 500, 700, 900].reduce(
      (result, index) =>
        result.replace(`export const value${index} = ${index}`, `export const value${index} = compute(${index})`),
      lines,
    )
    const part = {
      ...editPart,
      state: {
        ...editPart.state,
        metadata: {
          ...editPart.state.metadata,
          filediff: {
            file: "src/regression.ts",
            additions: 1,
            deletions: 1,
            before: lines,
            after,
          },
        },
      },
    }
    await mockServer(page, events, [userMessage, { ...assistantMessage, parts: [part] }])
    await configurePage(page)

    await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
    await expectSessionTitle(page, title)

    const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
    const trigger = wrapper.locator('[data-slot="collapsible-trigger"]').first()
    const diff = wrapper.locator('[data-component="edit-content"]').first()
    await expectAppVisible(diff)
    await expect.poll(() => wrapper.evaluate((element) => element.getBoundingClientRect().height)).toBeGreaterThan(500)
    const samples = await wrapper.evaluate(async (element) => {
      const root = element.closest<HTMLElement>(".scroll-view__viewport")!
      element.scrollIntoView({ block: "start" })
      const result = []
      for (const offset of [0, 120, 240, 360, 480]) {
        root.scrollBy(0, offset - (result.at(-1)?.offset ?? 0))
        await new Promise(requestAnimationFrame)
        const trigger = element.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')!
        const diff = element.querySelector<HTMLElement>('[data-component="edit-content"]')!
        result.push({
          offset,
          trigger: trigger.getBoundingClientRect().y,
          diff: diff.getBoundingClientRect().y,
          bottom: element.getBoundingClientRect().bottom,
        })
      }
      return result
    })

    expect(samples[0]!.trigger).toBeLessThan(samples[0]!.diff)
    expect(samples.every((sample) => Math.abs(sample.trigger - samples[0]!.trigger) <= 1)).toBe(true)
    expect(samples.every((sample) => sample.trigger < sample.bottom)).toBe(true)
  })
})

async function configurePage(page: Page) {
  await page.addInitScript(() => {
    localStorage.setItem(
      "settings.v3",
      JSON.stringify({
        general: {
          editToolPartsExpanded: true,
          shellToolPartsExpanded: true,
          showReasoningSummaries: true,
        },
      }),
    )
  })
}

async function expectExpanded(locator: Locator, expected: boolean) {
  await expect.poll(() => locator.evaluate(readExpanded)).toBe(expected)
}

async function readToolState(page: Page) {
  return page
    .locator(`[data-timeline-part-id="${editPartID}"]`)
    .first()
    .evaluate(
      (element, textPartID) => ({
        expanded: (() => {
          const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
          const aria = trigger?.getAttribute("aria-expanded")
          if (aria === "true") return true
          if (aria === "false") return false

          const root = element.querySelector('[data-component="collapsible"]')
          if (root?.hasAttribute("data-expanded")) return true
          if (root?.hasAttribute("data-closed")) return false

          const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
          return !!content && content.getBoundingClientRect().height > 0
        })(),
        row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"),
        streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`),
      }),
      textPartID,
    )
}

async function installDiffProbe(page: Page) {
  await page.addInitScript(() => {
    let shadowRootCount = 0
    const attachShadow = Element.prototype.attachShadow
    Element.prototype.attachShadow = function (init) {
      shadowRootCount += 1
      return attachShadow.call(this, init)
    }
    window.__timelineDiffProbe = {
      reset: () => {
        shadowRootCount = 0
      },
      shadowRoots: () => shadowRootCount,
    }
  })
}

async function markDiffProbe(page: Page) {
  await page
    .locator(`[data-timeline-part-id="${editPartID}"]`)
    .first()
    .evaluate((element) => {
      const tool = element as HTMLElement
      const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
      const row = tool.closest<HTMLElement>("[data-timeline-key]")
      const frame = tool.closest<HTMLElement>("[data-timeline-row]")
      if (!file) throw new Error("missing edit diff file")
      if (!row) throw new Error("missing virtual timeline row")
      if (!frame) throw new Error("missing timeline row frame")

      tool.dataset.timelineProbe = "before"
      file.dataset.timelineProbe = "before"
      row.dataset.timelineProbe = "before"
      frame.dataset.timelineProbe = "before"
      window.__timelineDiffProbe.reset()
    })
}

async function readDiffProbe(page: Page) {
  return page
    .locator(`[data-timeline-part-id="${editPartID}"]`)
    .first()
    .evaluate((element) => {
      const tool = element as HTMLElement
      const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
      const row = tool.closest<HTMLElement>("[data-timeline-key]")
      const frame = tool.closest<HTMLElement>("[data-timeline-row]")
      return {
        fileMarker: file?.dataset.timelineProbe,
        shadowRoots: window.__timelineDiffProbe.shadowRoots(),
        toolMarker: tool.dataset.timelineProbe,
        rowMarker: row?.dataset.timelineProbe,
        rowKey: row?.dataset.timelineKey,
        frameMarker: frame?.dataset.timelineProbe,
      }
    })
}

function editPartWithAdditions(additions: number) {
  return {
    ...editPart,
    state: {
      ...editPart.state,
      metadata: {
        ...editPart.state.metadata,
        filediff: {
          ...editPart.state.metadata.filediff,
          additions,
        },
      },
    },
  }
}

function readExpanded(element: Element) {
  const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
  const aria = trigger?.getAttribute("aria-expanded")
  if (aria === "true") return true
  if (aria === "false") return false

  const root = element.querySelector('[data-component="collapsible"]')
  if (root?.hasAttribute("data-expanded")) return true
  if (root?.hasAttribute("data-closed")) return false

  const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
  return !!content && content.getBoundingClientRect().height > 0
}

async function mockServer(page: Page, events: EventPayload[], messages = [userMessage, assistantMessage]) {
  await mockNovaClawServer(page, {
    directory,
    project: project(),
    provider: provider(),
    sessions: [session()],
    pageMessages: () => ({ items: messages }),
    events: () => events.splice(0, 1),
    eventRetry: 16,
  })
}

function project() {
  return {
    id: projectID,
    worktree: directory,
    vcs: "git",
    name: "timeline-state-regression",
    time: { created: 1700000000000, updated: 1700000000000 },
    sandboxes: [],
  }
}

function session() {
  return {
    id: sessionID,
    slug: "timeline-state-regression",
    projectID,
    directory,
    title,
    version: "dev",
    time: { created: 1700000000000, updated: 1700000000000 },
  }
}

function provider() {
  return {
    all: [
      {
        id: "novaclaw",
        name: "NovaClaw",
        models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
      },
    ],
    connected: ["novaclaw"],
    default: { providerID: "novaclaw", modelID: "claude-opus-4-6" },
  }
}

function base64Encode(value: string) {
  return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
