import { beforeEach, describe, expect, it, vi } from 'vitest'

// `cmsApi` is the only I/O dependency; mock it so the service is tested
// hermetically. Responses follow the SDK `{ data, error }` envelope.
const cmsApiMock = vi.fn()
vi.mock('../../lib/api.js', () => ({
  cmsApi: (...args: unknown[]) => cmsApiMock(...args),
}))

const {
  validatePost,
  validateTemplate,
  emptyPostFromTemplate,
  fetchPostForEditor,
  fetchTemplateForEditor,
  missingPostFields,
  savePostDraft,
} = await import('../../lib/post-editor-service.js')

type EditorPostT = Awaited<ReturnType<typeof emptyPostFromTemplate>>

function basePost(over: Partial<EditorPostT> = {}): EditorPostT {
  return {
    id: 'p1',
    postType: 'blog',
    title: 'Hello',
    slug: 'hello',
    excerpt: '',
    featuredImage: '',
    body: '',
    category: '',
    tags: [],
    status: 'DRAFT',
    publishDate: null,
    sections: [],
    seoTitle: '',
    seoDescription: '',
    publishedAt: null,
    updatedAt: null,
    ...over,
  }
}

beforeEach(() => {
  cmsApiMock.mockReset()
})

describe('validatePost', () => {
  it('requires title + slug', () => {
    const r = validatePost(basePost({ title: '', slug: '' }), false)
    expect(r.errors).toContain('Post title is required.')
    expect(r.errors).toContain('Post slug is required.')
  })

  it('rejects an invalid or reserved slug (mirrors the server publish gate)', () => {
    expect(
      validatePost(basePost({ slug: 'Hello World' }), false).errors.some((e) => /slug/i.test(e)),
    ).toBe(true)
    expect(
      validatePost(basePost({ slug: 'api' }), false).errors.some((e) => /reserved/i.test(e)),
    ).toBe(true)
  })

  it('does not run section validation for draft saves', () => {
    const r = validatePost(
      basePost({
        sections: [
          {
            id: 's1',
            sectionType: 'quote',
            name: 'Quote',
            visible: true,
            content: {},
            settings: {},
          },
        ],
      }),
      false,
    )
    expect(r.errors).toEqual([])
  })

  it('runs section + SEO checks at publish time', () => {
    const r = validatePost(
      basePost({
        sections: [
          // missing required `quote` field → blocking section error
          {
            id: 's1',
            sectionType: 'quote',
            name: 'Quote',
            visible: true,
            content: {},
            settings: {},
          },
        ],
      }),
      true,
    )
    expect(r.errors.some((e) => e.startsWith('Quote:'))).toBe(true)
    expect(r.warnings.some((w) => w.includes('SEO title'))).toBe(true)
  })

  it('does not block publish on unmanaged (externally-managed) sections', () => {
    const r = validatePost(
      basePost({
        seoTitle: 'T',
        seoDescription: 'D',
        sections: [
          // No registered definition for `opal-hero` → validateSectionContent
          // would report "Unknown section type"; the unmanaged flag must skip it.
          {
            id: 's1',
            sectionType: 'opal-hero',
            name: 'Opal Hero',
            visible: true,
            content: { heading: 'Hi' },
            settings: {},
            unmanaged: true,
          },
        ],
      }),
      true,
    )
    expect(r.errors).toEqual([])
  })

  it('skips hidden sections during publish validation', () => {
    const r = validatePost(
      basePost({
        seoTitle: 'T',
        seoDescription: 'D',
        sections: [
          {
            id: 's1',
            sectionType: 'quote',
            name: 'Quote',
            visible: false,
            content: {},
            settings: {},
          },
        ],
      }),
      true,
    )
    expect(r.errors).toEqual([])
  })
})

describe('validateTemplate', () => {
  it('validates only visible section content', () => {
    const r = validateTemplate({
      docId: null,
      postType: 'blog',
      header: {
        layout: 'centered',
        showFeaturedImage: true,
        showExcerpt: true,
        showAuthor: true,
        showDate: true,
        showCategory: true,
        showReadingTime: false,
      },
      sections: [
        { id: 's1', sectionType: 'quote', name: 'Quote', visible: true, content: {}, settings: {} },
      ],
    })
    expect(r.errors.some((e) => e.startsWith('Quote:'))).toBe(true)
  })
})

describe('emptyPostFromTemplate', () => {
  it('seeds sections from the type template with fresh ids', async () => {
    // fetchTemplateForEditor reads the dedicated /post-templates/:type endpoint,
    // whose `{ data }` envelope is unwrapped by cmsApi to { postType, docId, header, sections }.
    cmsApiMock.mockResolvedValueOnce({
      data: {
        postType: 'blog',
        docId: 'tmpl-1',
        header: { layout: 'left' },
        sections: [
          {
            id: 'orig',
            sectionType: 'article-body',
            name: 'Body',
            visible: true,
            content: {},
            settings: {},
          },
        ],
      },
    })
    const post = await emptyPostFromTemplate('blog')
    expect(post.id).toBeNull()
    expect(post.postType).toBe('blog')
    expect(post.sections).toHaveLength(1)
    expect(post.sections[0]!.sectionType).toBe('article-body')
    // cloned: a fresh id, not the template's section id
    expect(post.sections[0]!.id).not.toBe('orig')
    // the endpoint was addressed by post type, not the raw collection API
    expect(cmsApiMock.mock.calls[0]![0]).toBe('/post-templates/blog')
  })

  it('seeds the default starting template when the type has no saved template', async () => {
    // Unsaved type → endpoint returns docId null + empty sections; the service
    // backfills the default (article-body + related-posts) so the post is never blank.
    cmsApiMock.mockResolvedValueOnce({ data: { postType: 'news', docId: null, sections: [] } })
    const post = await emptyPostFromTemplate('news')
    expect(post.sections.map((s) => s.sectionType)).toEqual(['article-body', 'related-posts'])
    // cloned with fresh, distinct ids
    expect(post.sections[0]!.id).not.toBe(post.sections[1]!.id)
  })
})

describe('fetchTemplateForEditor', () => {
  it('maps a saved template from the dedicated endpoint', async () => {
    cmsApiMock.mockResolvedValueOnce({
      data: {
        postType: 'blog',
        docId: 'tmpl-9',
        header: { layout: 'overlay' },
        sections: [
          { id: 's1', sectionType: 'quote', name: 'Q', visible: true, content: {}, settings: {} },
        ],
      },
    })
    const t = await fetchTemplateForEditor('blog')
    expect(t.docId).toBe('tmpl-9')
    expect(t.header.layout).toBe('overlay')
    expect(t.sections).toHaveLength(1)
  })

  it('seeds the default starting template when the type has no saved template', async () => {
    cmsApiMock.mockResolvedValueOnce({ data: { postType: 'blog', docId: null, sections: [] } })
    const t = await fetchTemplateForEditor('blog')
    expect(t.docId).toBeNull()
    expect(t.header.layout).toBe('centered')
    expect(t.sections.map((s) => s.sectionType)).toEqual(['article-body', 'related-posts'])
  })

  it('falls back to the default template when the request fails', async () => {
    cmsApiMock.mockResolvedValueOnce({ error: 'network down' })
    const t = await fetchTemplateForEditor('blog')
    expect(t.docId).toBeNull()
    expect(t.sections.map((s) => s.sectionType)).toEqual(['article-body', 'related-posts'])
  })
})

describe('saveTemplate', () => {
  it('PUTs header + sections to the dedicated endpoint', async () => {
    cmsApiMock.mockResolvedValueOnce({
      data: { postType: 'blog', docId: 'tmpl-1', header: { layout: 'left' }, sections: [] },
    })
    const { saveTemplate } = await import('../../lib/post-editor-service.js')
    const out = await saveTemplate({
      docId: null,
      postType: 'blog',
      header: {
        layout: 'left',
        showFeaturedImage: true,
        showExcerpt: true,
        showAuthor: true,
        showDate: true,
        showCategory: true,
        showReadingTime: false,
      },
      sections: [],
    })
    const [endpoint, init] = cmsApiMock.mock.calls[0]!
    expect(endpoint).toBe('/post-templates/blog')
    expect(init.method).toBe('PUT')
    const body = JSON.parse(init.body as string)
    expect(body.header.layout).toBe('left')
    expect(Array.isArray(body.sections)).toBe(true)
    expect(out.docId).toBe('tmpl-1')
  })
})

describe('unmanaged section round-trip (data-loss regression)', () => {
  // The old service coerced sections with the default `'drop'` strategy, so a
  // post containing a section type unknown to the admin bundle (e.g. a custom
  // `opal-*` type missing from config) lost those sections on open + save.
  const OPAL_SECTION = {
    id: 'op1',
    sectionType: 'opal-prose-band',
    name: 'Prose Band',
    visible: true,
    content: { html: '<p>kept</p>' },
    settings: { paddingY: 'lg' as const },
  }

  it('fetchPostForEditor preserves unknown section types as unmanaged', async () => {
    cmsApiMock.mockResolvedValueOnce({
      data: {
        id: 'p1',
        title: 'Logan',
        slug: 'logan',
        status: 'PUBLISHED',
        data: {
          sections: [
            OPAL_SECTION,
            { id: 'q1', sectionType: 'quote', name: 'Q', visible: true, content: {}, settings: {} },
          ],
        },
      },
    })
    const post = await fetchPostForEditor('locations', 'p1')
    expect(post.sections.map((s) => s.sectionType)).toEqual(['opal-prose-band', 'quote'])
    const opal = post.sections[0]!
    expect(opal.unmanaged).toBe(true)
    // content/settings survive verbatim
    expect(opal.content).toEqual({ html: '<p>kept</p>' })
  })

  it('savePostDraft round-trips unmanaged sections, stripping the derived marker', async () => {
    cmsApiMock.mockResolvedValueOnce({
      data: { id: 'p1', title: 'Logan', slug: 'logan', status: 'DRAFT', data: { sections: [] } },
    })
    await savePostDraft(
      basePost({
        postType: 'locations',
        sections: [{ ...OPAL_SECTION, unmanaged: true }],
      }),
    )
    const [, init] = cmsApiMock.mock.calls[0]!
    const body = JSON.parse(init.body as string)
    expect(body.sections).toHaveLength(1)
    expect(body.sections[0].sectionType).toBe('opal-prose-band')
    expect(body.sections[0].content).toEqual({ html: '<p>kept</p>' })
    // The marker is derived from the registry on read — never persisted.
    expect('unmanaged' in body.sections[0]).toBe(false)
  })

  it('fetchTemplateForEditor preserves unknown section types in templates', async () => {
    cmsApiMock.mockResolvedValueOnce({
      data: { postType: 'locations', docId: 'tmpl-1', header: {}, sections: [OPAL_SECTION] },
    })
    const t = await fetchTemplateForEditor('locations')
    expect(t.sections).toHaveLength(1)
    expect(t.sections[0]!.unmanaged).toBe(true)
  })

  it('emptyPostFromTemplate clones unmanaged template sections without throwing', async () => {
    // `createSection` throws for unregistered types — the clone must not use it.
    cmsApiMock.mockResolvedValueOnce({
      data: { postType: 'locations', docId: 'tmpl-1', header: {}, sections: [OPAL_SECTION] },
    })
    const post = await emptyPostFromTemplate('locations')
    expect(post.sections).toHaveLength(1)
    expect(post.sections[0]!.sectionType).toBe('opal-prose-band')
    expect(post.sections[0]!.id).not.toBe('op1')
  })
})

describe('missingPostFields (schema guard)', () => {
  const FULL_FIELDS = {
    title: { type: 'text' },
    slug: { type: 'slug' },
    sections: { type: 'json' },
    excerpt: { type: 'text' },
    featuredImage: { type: 'media' },
    body: { type: 'richText' },
    category: { type: 'text' },
    publishDate: { type: 'date' },
    metaTitle: { type: 'text' },
    metaDescription: { type: 'text' },
  }

  it('returns [] for a fully-declared post-type collection', () => {
    const config = { collections: { blog: { slug: 'blog', fields: FULL_FIELDS } } }
    expect(missingPostFields(config, 'blog')).toEqual([])
  })

  it('reports fields the editor writes that the collection does not declare', () => {
    const config = {
      collections: { blog: { slug: 'blog', fields: { title: { type: 'text' } } } },
    }
    const missing = missingPostFields(config, 'blog')
    expect(missing).toContain('sections')
    expect(missing).toContain('metaTitle')
  })

  it('trusts the consumer when config or the collection is absent', () => {
    expect(missingPostFields(undefined, 'blog')).toEqual([])
    expect(missingPostFields({ collections: {} }, 'blog')).toEqual([])
  })
})

describe('savePostDraft', () => {
  it('PUTs the post fields + sections for an existing post', async () => {
    cmsApiMock.mockResolvedValueOnce({
      data: { id: 'p1', title: 'Hello', slug: 'hello', status: 'DRAFT', data: { sections: [] } },
    })
    await savePostDraft(basePost({ body: 'Body copy', category: 'News' }))
    expect(cmsApiMock).toHaveBeenCalledTimes(1)
    const [endpoint, init] = cmsApiMock.mock.calls[0]!
    expect(endpoint).toBe('/collections/blog/p1')
    expect(init.method).toBe('PUT')
    const body = JSON.parse(init.body as string)
    expect(body.title).toBe('Hello')
    expect(body.body).toBe('Body copy')
    expect(body.category).toBe('News')
    expect(Array.isArray(body.sections)).toBe(true)
  })

  it('POSTs to create when the post has no id', async () => {
    cmsApiMock.mockResolvedValueOnce({
      data: { id: 'new1', title: 'Hello', slug: 'hello', status: 'DRAFT', data: {} },
    })
    const out = await savePostDraft(basePost({ id: null }))
    const [endpoint, init] = cmsApiMock.mock.calls[0]!
    expect(endpoint).toBe('/collections/blog')
    expect(init.method).toBe('POST')
    expect(out.id).toBe('new1')
  })
})

describe('tags round-trip', () => {
  it('writes tags in the canonical `[{ tag }]` row shape', async () => {
    cmsApiMock.mockResolvedValueOnce({
      data: { id: 'p1', title: 'Hello', slug: 'hello', status: 'DRAFT', data: {} },
    })
    await savePostDraft(basePost({ tags: ['AI', 'CMS'] }))
    const [, init] = cmsApiMock.mock.calls[0]!
    const body = JSON.parse(init.body as string)
    expect(body.tags).toEqual([{ tag: 'AI' }, { tag: 'CMS' }])
  })

  it('reads stored tags from row objects AND plain strings', async () => {
    cmsApiMock.mockResolvedValueOnce({
      data: {
        id: 'p1',
        title: 'Hello',
        slug: 'hello',
        status: 'DRAFT',
        // Mixed legacy shapes must both surface in the editor.
        data: { tags: [{ tag: 'AI' }, 'Nextjs', { tag: '  ' }, 42] },
      },
    })
    const post = await fetchPostForEditor('blog', 'p1')
    expect(post.tags).toEqual(['AI', 'Nextjs'])
  })
})
