import { getTextParts, type Part, parseText } from 'src/components/common/AutolinkText'

// Link Parts now carry derived `display` + `host` fields (used by the inline
// favicon + Mastodon-shortened visible text in AutolinkText). The strict-
// equality tests below predate those fields and assert structure, not display
// — strip them before comparing so the existing intent stays crisp. The
// "link Part carries display and host" test exercises the new shape directly.
function stripLinkMeta(parts: Part[]): Part[] {
  return parts.map((p) => {
    if (p.type !== 'link') return p
    const { display: _d, host: _h, ...rest } = p
    return rest
  })
}

function parse(text: string): Part[] {
  const matches = parseText(text)
  return stripLinkMeta(getTextParts(text, matches))
}

function parseRaw(text: string): Part[] {
  const matches = parseText(text)
  return getTextParts(text, matches)
}

function tokenTypes(parts: Part[]): string[] {
  return parts.filter((p) => p.type !== 'text').map((p) => p.type)
}

describe('Autolinker', () => {
  test('parse mention (without explicit homeserver)', () => {
    expect(parse('@pixelfed')).toStrictEqual([
      { type: 'mention', value: '@pixelfed' },
    ] satisfies Part[])
    expect(parse('@pixelfed is cool')).toStrictEqual([
      { type: 'mention', value: '@pixelfed' },
      { type: 'text', value: ' is cool' },
    ] satisfies Part[])
  })

  test('parse mention (without explicit homeserver and dot in the username)', () => {
    expect(parse('@moonwatch.band')).toStrictEqual([
      { type: 'mention', value: '@moonwatch.band' },
    ] satisfies Part[])
    expect(parse('I listen to @moonwatch.band')).toStrictEqual([
      { type: 'text', value: 'I listen to ' },
      { type: 'mention', value: '@moonwatch.band' },
    ] satisfies Part[])
  })

  test('parse mention (with homeserver)', () => {
    expect(parse('@pixelfed@pixelfed.social')).toStrictEqual([
      { type: 'mention', value: '@pixelfed@pixelfed.social' },
    ] satisfies Part[])
    expect(parse('@pixelfed@pixelfed.social is cool')).toStrictEqual([
      { type: 'mention', value: '@pixelfed@pixelfed.social' },
      { type: 'text', value: ' is cool' },
    ] satisfies Part[])
  })

  test('mention after non-whitespace punctuation', () => {
    // Regression — previous regex only allowed mentions after `' ' | '\n'`,
    // so the same `@user` after `(`, `[`, `>`, em-dash, etc. was dead.
    expect(tokenTypes(parse('(@alice)'))).toStrictEqual(['mention'])
    expect(tokenTypes(parse('[@alice]'))).toStrictEqual(['mention'])
    expect(tokenTypes(parse('— @alice'))).toStrictEqual(['mention'])
  })

  test('mention boundary rejects email-like fragments', () => {
    // `foo@bar.com` is an email, not a mention. The lookbehind drops it.
    expect(tokenTypes(parse('email me at foo@bar.com'))).toStrictEqual([])
  })

  test('parse hashtag', () => {
    expect(parse('#pixelfed')).toStrictEqual([
      { type: 'hashtag', value: '#pixelfed' },
    ] satisfies Part[])
    expect(parse('#pixelfed is awesome')).toStrictEqual([
      { type: 'hashtag', value: '#pixelfed' },
      { type: 'text', value: ' is awesome' },
    ] satisfies Part[])
  })

  test('hashtag boundary rejects mid-word fragments', () => {
    // `foo#bar` is not a hashtag — the lookbehind drops it.
    expect(tokenTypes(parse('a foo#bar mid-word'))).toStrictEqual([])
  })

  test('hashtag accepts Unicode letters', () => {
    expect(tokenTypes(parse('Café in #파리 is good'))).toStrictEqual(['hashtag'])
    expect(tokenTypes(parse('See #café tonight'))).toStrictEqual(['hashtag'])
  })

  test('hashtag after non-whitespace punctuation', () => {
    expect(tokenTypes(parse('Try (#streetphoto) tonight'))).toStrictEqual(['hashtag'])
  })

  test('hashtag keeps trailing digits (regression)', () => {
    // The body previously forced the final char to be a letter
    // (`set{0,123}[letter]`), so `#test123` truncated to `#test`. The server
    // lexer is `set* letter set*` — a letter must appear somewhere, but
    // digits are allowed at the start AND the end.
    expect(parse('#test123')).toStrictEqual([
      { type: 'hashtag', value: '#test123' },
    ] satisfies Part[])
    expect(parse('#100DaysOfCode')).toStrictEqual([
      { type: 'hashtag', value: '#100DaysOfCode' },
    ] satisfies Part[])
    expect(parse('#a1')).toStrictEqual([
      { type: 'hashtag', value: '#a1' },
    ] satisfies Part[])
    expect(parse('#2nd')).toStrictEqual([
      { type: 'hashtag', value: '#2nd' },
    ] satisfies Part[])
    // Inline, mid-sentence — the whole tag including trailing digits links.
    expect(parse('loving #100DaysOfCode lately')).toStrictEqual([
      { type: 'text', value: 'loving ' },
      { type: 'hashtag', value: '#100DaysOfCode' },
      { type: 'text', value: ' lately' },
    ] satisfies Part[])
  })

  test('hashtag rejects pure-numeric', () => {
    // No letter anywhere → not a hashtag (`#1`, `#123`).
    expect(tokenTypes(parse('#1'))).toStrictEqual([])
    expect(tokenTypes(parse('a #123 b'))).toStrictEqual([])
  })

  test('parse link', () => {
    expect(parse('http://pixelfed.org')).toStrictEqual([
      { type: 'link', value: 'http://pixelfed.org' },
    ] satisfies Part[])

    expect(parse('https://pixelfed.org')).toStrictEqual([
      { type: 'link', value: 'https://pixelfed.org' },
    ] satisfies Part[])
    expect(parse('Learn more about it at https://pixelfed.org')).toStrictEqual([
      { type: 'text', value: 'Learn more about it at ' },
      { type: 'link', value: 'https://pixelfed.org' },
    ] satisfies Part[])
  })

  test('parse link with path', () => {
    expect(parse('https://pixelfed.org/imaginary/index.html')).toStrictEqual([
      { type: 'link', value: 'https://pixelfed.org/imaginary/index.html' },
    ] satisfies Part[])
  })

  test('parse link with @ sign', () => {
    expect(parse('https://pixelfed.org/@user')).toStrictEqual([
      { type: 'link', value: 'https://pixelfed.org/@user' },
    ] satisfies Part[])
  })

  test('parse link with #fragment', () => {
    expect(parse('https://pixelfed.org#help')).toStrictEqual([
      { type: 'link', value: 'https://pixelfed.org#help' },
    ] satisfies Part[])
    expect(parse('https://pixelfed.org/#help')).toStrictEqual([
      { type: 'link', value: 'https://pixelfed.org/#help' },
    ] satisfies Part[])
    expect(parse('https://pixelfed.org/docs/#help')).toStrictEqual([
      { type: 'link', value: 'https://pixelfed.org/docs/#help' },
    ] satisfies Part[])
  })

  test('parse link with ?query', () => {
    expect(parse('https://pixelfed.org?q=test')).toStrictEqual([
      { type: 'link', value: 'https://pixelfed.org?q=test' },
    ] satisfies Part[])
    expect(parse('https://pixelfed.org/?q=test')).toStrictEqual([
      { type: 'link', value: 'https://pixelfed.org/?q=test' },
    ] satisfies Part[])
    expect(parse('https://pixelfed.org/search/?q=test')).toStrictEqual([
      { type: 'link', value: 'https://pixelfed.org/search/?q=test' },
    ] satisfies Part[])
  })

  test('link with long TLD (regression — previous cap was 6 chars)', () => {
    // `.community` is 9 chars. Previous `[a-zA-Z0-9()]{1,6}` cap left these
    // entirely unlinked.
    expect(tokenTypes(parse('See https://example.community'))).toStrictEqual(['link'])
    expect(tokenTypes(parse('https://example.photography/posts/1'))).toStrictEqual([
      'link',
    ])
    expect(tokenTypes(parse('https://example.amsterdam'))).toStrictEqual(['link'])
  })

  test('link with path sub-delims', () => {
    expect(tokenTypes(parse('https://example.com/path;jsessionid=abc'))).toStrictEqual([
      'link',
    ])
    expect(tokenTypes(parse('https://example.com/foo!bar'))).toStrictEqual(['link'])
    expect(tokenTypes(parse('https://example.com/foo,bar'))).toStrictEqual(['link'])
  })

  test('link with balanced parens (Wikipedia case)', () => {
    const parts = parse('See https://en.wikipedia.org/wiki/Primer_(film) tonight')
    const link = parts.find((p) => p.type === 'link')
    expect(link?.value).toBe('https://en.wikipedia.org/wiki/Primer_(film)')
  })

  test('link strips trailing sentence punctuation', () => {
    const cases = [
      ['See https://pixelfed.org.', 'https://pixelfed.org'],
      ['See https://pixelfed.org,', 'https://pixelfed.org'],
      ['See https://pixelfed.org!', 'https://pixelfed.org'],
      ['See https://pixelfed.org?', 'https://pixelfed.org'],
    ] as const
    for (const [input, expected] of cases) {
      const link = parse(input).find((p) => p.type === 'link')
      expect(link?.value).toBe(expected)
    }
  })

  test('mention inside URL path does not double-match', () => {
    // `https://github.com/@alice` parses as a single link — the `@alice`
    // portion is inside the link range and the mention pass skips it.
    const parts = parse('https://github.com/@alice')
    expect(parts).toStrictEqual([
      { type: 'link', value: 'https://github.com/@alice' },
    ] satisfies Part[])
  })

  test('parseText reassembles to original text', () => {
    // Property test: every (text, matches) pair the parser produces should
    // sum back to the original input length. Guards against off-by-one
    // bugs in match indices when regexes overlap or backtrack.
    const inputs = [
      'plain text with no tokens',
      '@alice posted #photography at https://example.community/foo',
      'See https://en.wikipedia.org/wiki/Primer_(film) and @bob@host.tld',
      '#tag1 #tag2 https://example.com/path;a=b',
      'tag #100DaysOfCode and #test123 end',
    ]
    for (const text of inputs) {
      const parts = parse(text)
      const reassembled = parts.map((p) => p.value).join('')
      expect(reassembled).toBe(text)
    }
  })

  test('link Part carries display and host', () => {
    const parts = parseRaw('Check https://www.example.com/foo/bar?utm=x today')
    const link = parts.find((p) => p.type === 'link')
    expect(link?.value).toBe('https://www.example.com/foo/bar?utm=x')
    expect(link?.display).toBe('example.com/foo/bar')
    expect(link?.host).toBe('example.com')
  })

  test('link display trims query + fragment but href stays full', () => {
    const link = parseRaw('See https://pixelfed.org/docs/#help next').find(
      (p) => p.type === 'link'
    )
    expect(link?.value).toBe('https://pixelfed.org/docs/#help')
    expect(link?.display).toBe('pixelfed.org/docs/')
    expect(link?.host).toBe('pixelfed.org')
  })

  test('link display ellipsizes long paths', () => {
    const link = parseRaw(
      'https://example.com/very/long/path/here/that/keeps/going'
    ).find((p) => p.type === 'link')
    expect(link?.display).toMatch(/^example\.com\/.{1,30}…$/)
  })
})
