import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'
import type {
  ContentEntry,
  ContentInput,
  ContentVisibility,
  EntityRef,
  ListQuery,
} from '@platform-modules/content'
import {
  ContentEditorForm,
  ContentEntryNotSavedError,
  isContentEntryNotSavedError,
  useAutosave,
  useContentEntry,
  useContentList,
  type ContentClient,
} from './index'

const FIXED = new Date('2024-06-01T12:00:00.000Z')

function makeEntry(overrides: Partial<ContentEntry> = {}): ContentEntry {
  return {
    id: 'entry-1',
    slug: 'hello-world',
    type: 'post',
    title: 'Hello',
    body: '<p>hi</p>',
    status: 'draft',
    visibility: 'public',
    publishedAt: null,
    author: 'author-1',
    terms: [],
    createdAt: FIXED,
    updatedAt: FIXED,
    parentId: null,
    menuOrder: 0,
    templateKey: null,
    excerpt: '',
    featuredMedia: null,
    commentStatus: 'open',
    pingStatus: 'open',
    passwordProtected: false,
    sticky: false,
    format: null,
    deletedAt: null,
    lastEditedBy: 'author-1',
    typeDefinitionRevision: 1,
    statusDefinitionRevision: 1,
    ...overrides,
  }
}

function toRef(entry: ContentEntry): EntityRef {
  return { id: entry.id, slug: entry.slug, type: entry.type }
}

type FakeClient = ContentClient & {
  store: Map<string, ContentEntry>
  listReject?: Error
}

function createFakeClient(seed: ContentEntry[] = []): FakeClient {
  const store = new Map(seed.map((e) => [e.id, { ...e }]))
  let nextId = 1

  return {
    store,
    async list(query?: ListQuery) {
      if (this.listReject) throw this.listReject
      let rows = [...store.values()]
      if (query?.type) rows = rows.filter((e) => e.type === query.type)
      if (query?.status) rows = rows.filter((e) => e.status === query.status)
      return rows
    },
    async getBySlug(type: string, slug: string) {
      return [...store.values()].find((e) => e.type === type && e.slug === slug) ?? null
    },
    async put(input: ContentInput) {
      const id = input.id ?? `new-${nextId++}`
      const existing = store.get(id)
      const entry = makeEntry({
        ...existing,
        id,
        slug: input.slug,
        type: input.type,
        title: input.title,
        body: input.body,
        visibility: input.visibility ?? existing?.visibility ?? 'public',
        terms:
          input.termIds !== undefined
            ? input.termIds.map(
                (id): ContentEntry['terms'][number] => ({
                  id,
                  taxonomy: 'category',
                  slug: id,
                  name: id,
                  parentId: null,
                  depth: 0,
                }),
              )
            : (existing?.terms ?? []),
        updatedAt: new Date(FIXED.getTime() + store.size),
      })
      store.set(id, entry)
      return entry
    },
    async publish(id: string) {
      const row = store.get(id)
      if (!row) throw new Error('not found')
      const updated = { ...row, status: 'published' as const, publishedAt: FIXED, updatedAt: FIXED }
      store.set(id, updated)
      return toRef(updated)
    },
    async schedule(id: string, at: string) {
      const row = store.get(id)
      if (!row) throw new Error('not found')
      const updated = {
        ...row,
        status: 'scheduled' as const,
        publishedAt: new Date(at),
        updatedAt: FIXED,
      }
      store.set(id, updated)
      return toRef(updated)
    },
    async unpublish(id: string) {
      const row = store.get(id)
      if (!row) throw new Error('not found')
      const updated = { ...row, status: 'draft' as const, publishedAt: null, updatedAt: FIXED }
      store.set(id, updated)
      return toRef(updated)
    },
    async setVisibility(id: string, visibility: ContentVisibility) {
      const row = store.get(id)
      if (!row) throw new Error('not found')
      const updated = { ...row, visibility, updatedAt: FIXED }
      store.set(id, updated)
      return toRef(updated)
    },
    async trash(id: string) {
      const row = store.get(id)
      if (!row) throw new Error('not found')
      const updated = { ...row, status: 'trashed' as const, updatedAt: FIXED }
      store.set(id, updated)
      return toRef(updated)
    },
    async restore(id: string) {
      const row = store.get(id)
      if (!row) throw new Error('not found')
      const updated = { ...row, status: 'draft' as const, updatedAt: FIXED }
      store.set(id, updated)
      return toRef(updated)
    },
    async remove(id: string) {
      const row = store.get(id)
      if (!row) throw new Error('not found')
      store.delete(id)
      return toRef(row)
    },
  }
}

describe('useContentList', () => {
  it('loads entries from the fake client on mount', async () => {
    const client = createFakeClient([makeEntry(), makeEntry({ id: 'entry-2', slug: 'second' })])
    const { result } = renderHook(() => useContentList(client))

    expect(result.current.loading).toBe(true)
    await waitFor(() => expect(result.current.loading).toBe(false))
    expect(result.current.entries).toHaveLength(2)
    expect(result.current.error).toBeNull()
  })

  it('reload() refetches after a mutation', async () => {
    const client = createFakeClient([makeEntry()])
    const { result } = renderHook(() => useContentList(client))
    await waitFor(() => expect(result.current.loading).toBe(false))
    expect(result.current.entries).toHaveLength(1)

    await act(async () => {
      await client.put({ slug: 'added', type: 'post', title: 'Added', body: '' })
    })

    act(() => result.current.reload())
    await waitFor(() => expect(result.current.entries).toHaveLength(2))
  })

  it('captures a rejecting client.list into error without throwing', async () => {
    const client = createFakeClient()
    client.listReject = new Error('network down')
    const { result } = renderHook(() => useContentList(client))

    await waitFor(() => expect(result.current.loading).toBe(false))
    expect(result.current.error?.message).toBe('network down')
    expect(result.current.entries).toEqual([])
  })
})

describe('useContentEntry', () => {
  it('save() calls client.put with the draft', async () => {
    const client = createFakeClient()
    const putSpy = vi.spyOn(client, 'put')
    const { result } = renderHook(() => useContentEntry(client, null))

    await act(async () => {
      await result.current.save({
        slug: 'draft-post',
        type: 'post',
        title: 'Draft',
        body: '<p>x</p>',
      })
    })

    expect(putSpy).toHaveBeenCalledWith({
      slug: 'draft-post',
      type: 'post',
      title: 'Draft',
      body: '<p>x</p>',
    })
    expect(result.current.entry?.slug).toBe('draft-post')
  })

  it('publish() and setVisibility() call the matching client method with the entry id', async () => {
    const seed = makeEntry()
    const client = createFakeClient([seed])
    const publishSpy = vi.spyOn(client, 'publish')
    const visibilitySpy = vi.spyOn(client, 'setVisibility')
    const { result } = renderHook(() => useContentEntry(client, seed))

    await act(async () => {
      await result.current.publish()
    })
    expect(publishSpy).toHaveBeenCalledWith('entry-1')

    await act(async () => {
      await result.current.setVisibility('private')
    })
    expect(visibilitySpy).toHaveBeenCalledWith('entry-1', 'private')
  })

  it('trash() calls client.trash with the entry id', async () => {
    const seed = makeEntry()
    const client = createFakeClient([seed])
    const trashSpy = vi.spyOn(client, 'trash')
    const { result } = renderHook(() => useContentEntry(client, seed))

    await act(async () => {
      await result.current.trash()
    })
    expect(trashSpy).toHaveBeenCalledWith('entry-1')
  })

  it('restore() calls client.restore with the entry id', async () => {
    const seed = makeEntry({ status: 'trashed' })
    const client = createFakeClient([seed])
    const restoreSpy = vi.spyOn(client, 'restore')
    const { result } = renderHook(() => useContentEntry(client, seed))

    await act(async () => {
      await result.current.restore()
    })
    expect(restoreSpy).toHaveBeenCalledWith('entry-1')
  })

  it('publish() before first save on a new entry yields a typed error', async () => {
    const client = createFakeClient()
    const { result } = renderHook(() => useContentEntry(client, null))

    let caught: unknown
    await act(async () => {
      try {
        await result.current.publish()
      } catch (e) {
        caught = e
      }
    })

    expect(caught).toBeInstanceOf(ContentEntryNotSavedError)
    expect(isContentEntryNotSavedError(caught)).toBe(true)
    expect((caught as ContentEntryNotSavedError).action).toBe('publish')
    expect(result.current.error).toBeInstanceOf(ContentEntryNotSavedError)
  })
})

describe('useAutosave', () => {
  beforeEach(() => {
    vi.useFakeTimers()
  })
  afterEach(() => {
    vi.useRealTimers()
  })

  it('debounces value changes through idle → saving → saved', async () => {
    const save = vi.fn().mockResolvedValue(undefined)
    const { result, rerender } = renderHook(
      ({ value }) => useAutosave(save, value, { delayMs: 1000 }),
      { initialProps: { value: 'seed' } },
    )

    expect(result.current.state).toBe('idle')
    vi.advanceTimersByTime(2000)
    expect(save).not.toHaveBeenCalled()

    rerender({ value: 'changed' })
    expect(result.current.state).toBe('idle')

    await act(async () => {
      vi.advanceTimersByTime(1000)
      await Promise.resolve()
    })

    expect(save).toHaveBeenCalledWith('changed')
    expect(result.current.state).toBe('saved')
  })

  it('sets error when save rejects', async () => {
    const save = vi.fn().mockRejectedValue(new Error('fail'))
    const { result, rerender } = renderHook(
      ({ value }) => useAutosave(save, value, { delayMs: 500 }),
      { initialProps: { value: 'start' } },
    )

    rerender({ value: 'next' })
    await act(async () => {
      vi.advanceTimersByTime(500)
      await Promise.resolve()
    })

    expect(result.current.state).toBe('error')
  })

  it('cancels a pending save on unmount', async () => {
    const save = vi.fn().mockResolvedValue(undefined)
    const { rerender, unmount } = renderHook(
      ({ value }) => useAutosave(save, value, { delayMs: 1000 }),
      { initialProps: { value: 'seed' } },
    )

    rerender({ value: 'pending' })
    vi.advanceTimersByTime(500)
    unmount()
    await act(async () => {
      vi.advanceTimersByTime(2000)
      await Promise.resolve()
    })

    expect(save).not.toHaveBeenCalled()
  })
})

describe('ContentEditorForm', () => {
  it('renders labeled fields and a RichTextEditor with aria-label', async () => {
    const client = createFakeClient()
    render(<ContentEditorForm client={client} entry={null} />)

    expect(screen.getByLabelText('Title')).toBeTruthy()
    expect(screen.getByLabelText('Slug')).toBeTruthy()
    expect(screen.getByLabelText('Type')).toBeTruthy()
    expect(screen.getByLabelText('Visibility')).toBeTruthy()
    expect(screen.getByLabelText('Body')).toBeTruthy()

    await waitFor(() => {
      expect(screen.getByRole('textbox', { name: 'Content body' })).toBeTruthy()
    })
  })

  it('save assembles ContentInput, calls client.put, and fires onSaved', async () => {
    const client = createFakeClient()
    const putSpy = vi.spyOn(client, 'put')
    const onSaved = vi.fn()
    render(<ContentEditorForm client={client} entry={null} onSaved={onSaved} />)

    fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'My title' } })
    fireEvent.change(screen.getByLabelText('Slug'), { target: { value: 'my-title' } })
    fireEvent.change(screen.getByLabelText('Type'), { target: { value: 'page' } })
    fireEvent.change(screen.getByLabelText('Visibility'), { target: { value: 'members' } })

    fireEvent.click(screen.getByRole('button', { name: 'Save' }))

    await waitFor(() => expect(putSpy).toHaveBeenCalled())
    expect(putSpy).toHaveBeenCalledWith({
      slug: 'my-title',
      type: 'page',
      title: 'My title',
      body: '',
      visibility: 'members',
    })
    await waitFor(() => expect(onSaved).toHaveBeenCalled())
    expect(onSaved.mock.calls[0]?.[0]?.title).toBe('My title')
  })

  it('preserves an untouched empty controlled body instead of synthesizing editor markup', async () => {
    const client = createFakeClient()
    const putSpy = vi.spyOn(client, 'put')
    render(<ContentEditorForm client={client} entry={null} />)

    fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Empty body' } })
    fireEvent.change(screen.getByLabelText('Slug'), { target: { value: 'empty-body' } })
    fireEvent.change(screen.getByLabelText('Type'), { target: { value: 'page' } })
    fireEvent.click(screen.getByRole('button', { name: 'Save' }))

    await waitFor(() => expect(putSpy).toHaveBeenCalled())
    expect(putSpy.mock.calls[0]?.[0]?.body).toBe('')
  })
})
