// @vitest-environment jsdom
/**
 * test/git-log.test.ts (W3 quick-wins d) — recent-commit list (public/git-log.ts).
 *
 * Pure normalize/render + the mountGitLog wiring with a mocked fetch. Security:
 * commit subjects are attacker-influenced, so a subject containing an <img
 * onerror> payload must appear verbatim as text (no HTML injection). A fetch
 * failure degrades to an inert message (best-effort, never throws).
 */

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { GitLogResult } from '../src/types.js'
import {
  normalizeGitLog,
  renderGitLog,
  fetchGitLog,
  mountGitLog,
} from '../public/git-log.js'

function makeLog(over: Partial<GitLogResult> = {}): GitLogResult {
  return {
    commits: [
      { hash: 'abc1234', at: Date.now() - 3600_000, subject: 'first commit' },
      { hash: 'def5678', at: Date.now() - 7200_000, subject: 'second commit' },
    ],
    truncated: false,
    ...over,
  }
}

function mockFetch(body: unknown, ok = true): ReturnType<typeof vi.fn> {
  const fn = vi.fn(async () => {
    if (body === null) throw new Error('network down')
    return { ok, json: async () => body } as Response
  })
  vi.stubGlobal('fetch', fn)
  return fn
}

afterEach(() => {
  vi.unstubAllGlobals()
})

// ── normalizeGitLog ───────────────────────────────────────────────────────────

describe('normalizeGitLog', () => {
  it('returns null for a non-object / missing commits array', () => {
    expect(normalizeGitLog(null)).toBeNull()
    expect(normalizeGitLog({ truncated: true })).toBeNull()
  })

  it('keeps well-formed commits and drops malformed ones', () => {
    const out = normalizeGitLog({
      commits: [
        { hash: 'h1', at: 1000, subject: 'ok' },
        { hash: 'h2', at: 'nope', subject: 'bad-at' },
        { hash: 5, at: 1, subject: 'bad-hash' },
        { at: 1, subject: 'missing-hash' },
      ],
      truncated: true,
    })
    expect(out?.commits.map((c) => c.hash)).toEqual(['h1'])
    expect(out?.truncated).toBe(true)
  })
})

// ── renderGitLog ──────────────────────────────────────────────────────────────

describe('renderGitLog', () => {
  it('renders one row per commit with hash / time / subject', () => {
    const host = document.createElement('div')
    renderGitLog(host, makeLog())
    const rows = host.querySelectorAll('.proj-commit-row')
    expect(rows).toHaveLength(2)
    expect(rows[0]?.querySelector('.proj-commit-hash')?.textContent).toBe('abc1234')
    expect(rows[0]?.querySelector('.proj-commit-subject')?.textContent).toBe('first commit')
  })

  it('renders an empty note when there are no commits', () => {
    const host = document.createElement('div')
    renderGitLog(host, makeLog({ commits: [] }))
    expect(host.querySelector('.proj-empty')).not.toBeNull()
    expect(host.querySelector('.proj-commit-row')).toBeNull()
  })

  it('shows a truncation note when truncated', () => {
    const host = document.createElement('div')
    renderGitLog(host, makeLog({ truncated: true }))
    expect(host.querySelector('.proj-commit-more')).not.toBeNull()
  })

  it('renders a subject with an HTML payload as inert text (SEC-H5)', () => {
    const host = document.createElement('div')
    const xss = '<img src=x onerror=alert(1)>'
    renderGitLog(host, makeLog({ commits: [{ hash: 'h1', at: Date.now(), subject: xss }] }))
    const subj = host.querySelector('.proj-commit-subject')
    expect(subj?.textContent).toBe(xss) // verbatim text
    expect(host.querySelectorAll('img').length).toBe(0) // no element injected
  })
})

// ── fetchGitLog / mountGitLog ─────────────────────────────────────────────────

describe('fetchGitLog', () => {
  it('requests /projects/log with the encoded repo path', async () => {
    const fetchFn = mockFetch(makeLog())
    await fetchGitLog('/home/u/my repo')
    expect(fetchFn).toHaveBeenCalledWith('/projects/log?path=%2Fhome%2Fu%2Fmy%20repo')
  })

  it('returns null on a fetch failure (best-effort)', async () => {
    mockFetch(null)
    expect(await fetchGitLog('/x')).toBeNull()
  })

  it('returns null on a non-ok response', async () => {
    mockFetch({}, false)
    expect(await fetchGitLog('/x')).toBeNull()
  })
})

describe('mountGitLog', () => {
  it('shows a loading placeholder, then swaps in commit rows', async () => {
    mockFetch(makeLog())
    const host = document.createElement('div')
    mountGitLog(host, '/repo')
    expect(host.querySelector('.proj-commitlog-loading')).not.toBeNull()
    await vi.waitFor(() => {
      expect(host.querySelector('.proj-commit-row')).not.toBeNull()
    })
  })

  it('degrades to an inert message on a fetch failure', async () => {
    mockFetch(null)
    const host = document.createElement('div')
    mountGitLog(host, '/repo')
    await vi.waitFor(() => {
      expect(host.querySelector('.proj-empty')).not.toBeNull()
    })
  })

  it('destroy() clears the container and cancels the swap', async () => {
    mockFetch(makeLog())
    const host = document.createElement('div')
    const handle = mountGitLog(host, '/repo')
    handle.destroy()
    // give the async swap a chance — it must not repopulate after destroy
    await new Promise((r) => setTimeout(r, 0))
    expect(host.querySelector('.proj-commit-row')).toBeNull()
  })
})

/* ── w6/G4 unpushed rows + the upstream boundary ───────────────────────────── */

describe('renderGitLog — unpushed boundary (w6 G4)', () => {
  function entry(hash: string, subject: string, unpushed?: boolean) {
    return { hash, at: Date.now(), subject, ...(unpushed === true ? { unpushed: true } : {}) }
  }

  it('marks unpushed rows and draws the boundary exactly once, after the last one', () => {
    const host = document.createElement('div')
    renderGitLog(host, {
      commits: [
        entry('aaa1111', 'newest local', true),
        entry('bbb2222', 'older local', true),
        entry('ccc3333', 'already pushed'),
        entry('ddd4444', 'also pushed'),
      ],
      truncated: false,
      upstream: 'origin/develop',
    })

    expect(host.querySelectorAll('.proj-commit-unpushed').length).toBe(2)
    const boundaries = host.querySelectorAll('.proj-commit-boundary')
    expect(boundaries.length).toBe(1)
    expect(boundaries[0]!.textContent).toContain('origin/develop')

    const rows = Array.from(host.querySelectorAll('.proj-commit-row, .proj-commit-boundary'))
    expect(rows[2]!.classList.contains('proj-commit-boundary')).toBe(true)
  })

  it('draws no boundary when nothing is unpushed', () => {
    const host = document.createElement('div')
    renderGitLog(host, {
      commits: [entry('aaa1111', 'pushed')],
      truncated: false,
      upstream: 'origin/main',
    })
    expect(host.querySelector('.proj-commit-boundary')).toBeNull()
    expect(host.querySelector('.proj-commit-unpushed')).toBeNull()
  })

  it('draws no boundary without an upstream, even if rows claim to be unpushed', () => {
    const host = document.createElement('div')
    renderGitLog(host, { commits: [entry('aaa1111', 'local', true)], truncated: false })
    expect(host.querySelector('.proj-commit-boundary')).toBeNull()
  })

  it('treats a non-true `unpushed` from the server as pushed', () => {
    const norm = normalizeGitLog({
      commits: [{ hash: 'aaa1111', at: 1, subject: 's', unpushed: 'yes' }],
      truncated: false,
    })
    expect(norm!.commits[0]!.unpushed).toBeUndefined()
  })

  it('keeps a string upstream and drops a junk one', () => {
    expect(normalizeGitLog({ commits: [], truncated: false, upstream: 'origin/x' })!.upstream).toBe(
      'origin/x',
    )
    expect(normalizeGitLog({ commits: [], truncated: false, upstream: 42 })!.upstream).toBeUndefined()
  })
})

describe('renderGitLog — unpushed count beside the heading (w6 design)', () => {
  function entry(hash: string, subject: string, unpushed?: boolean) {
    return { hash, at: Date.now(), subject, ...(unpushed === true ? { unpushed: true } : {}) }
  }

  it('puts the count ON the heading when one is supplied, not on its own line', () => {
    const host = document.createElement('div')
    const label = document.createElement('div')
    label.textContent = 'Recent commits'
    renderGitLog(
      host,
      {
        commits: [entry('a1', 'x', true), entry('b2', 'y', true), entry('c3', 'z')],
        truncated: false,
        upstream: 'origin/develop',
      },
      label,
    )
    expect(label.querySelector('.proj-commitlog-count')!.textContent).toContain('2')
    expect(host.querySelector('.proj-commitlog-count')).toBeNull()
  })

  it('does not stack duplicate counts across re-renders', () => {
    const host = document.createElement('div')
    const label = document.createElement('div')
    const log = {
      commits: [entry('a1', 'x', true)],
      truncated: false,
      upstream: 'origin/develop',
    }
    renderGitLog(host, log, label)
    renderGitLog(host, log, label)
    expect(label.querySelectorAll('.proj-commitlog-count').length).toBe(1)
  })

  it('falls back to the list container when no heading is given', () => {
    const host = document.createElement('div')
    renderGitLog(host, {
      commits: [entry('a1', 'x', true)],
      truncated: false,
      upstream: 'origin/develop',
    })
    expect(host.querySelector('.proj-commitlog-count')).not.toBeNull()
  })

  it('omits the count when nothing is unpushed', () => {
    const host = document.createElement('div')
    renderGitLog(host, { commits: [entry('a1', 'x')], truncated: false, upstream: 'origin/m' })
    expect(host.querySelector('.proj-commitlog-count')).toBeNull()
  })
})
