import * as fs from 'node:fs'
import {
  http,
  HttpResponse,
  graphql,
  type HttpResponseResolver,
  type GraphQLResponseResolver,
} from 'msw'
import { log } from '#/src/logger.js'
import { Publish } from '#/src/commands/publish.js'
import type { GitHubRelease } from '#/src/utils/github/get-github-release.js'
import { testEnvironment } from '#/test/env.js'
import { execAsync } from '#/src/utils/exec-async.js'

const { setup, reset, cleanup, api, createRepository } = testEnvironment({
  fileSystemPath: './publish',
})

beforeAll(async () => {
  await setup()
})

afterEach(async () => {
  await reset()
})

afterAll(async () => {
  await cleanup()
})

const githubLatestReleaseHandler = http.get<never, never, GitHubRelease>(
  `https://api.github.com/repos/:owner/:name/releases/latest`,
  () => {
    return new HttpResponse(null, { status: 404 })
  },
)

it('publishes the next minor version', async () => {
  const repo = await createRepository('version-next-minor')

  api.use(
    graphql.query('GetCommitAuthors', () => {
      return HttpResponse.json({ data: {} })
    }),
    githubLatestReleaseHandler,
    http.post<never, never, GitHubRelease>(
      'https://api.github.com/repos/:owner/:repo/releases',
      () => {
        return HttpResponse.json(
          {
            tag_name: 'v1.0.0',
            html_url: '/releases/1',
          },
          { status: 201 },
        )
      },
    ),
  )

  await repo.fs.create({
    'package.json': JSON.stringify({
      name: 'test',
      version: '0.0.0',
    }),
  })
  await repo.fs.exec(`git add . && git commit -m 'feat: new things'`)

  const publish = new Publish(
    {
      profiles: [
        {
          name: 'latest',
          use: 'echo "release script input: $RELEASE_VERSION"',
        },
      ],
    },
    {
      _: [],
      profile: 'latest',
    },
  )
  await publish.run()

  expect(log.error).not.toHaveBeenCalled()

  expect(log.info).toHaveBeenCalledWith(
    expect.stringContaining('found 2 new commits:'),
  )

  // Must notify about the next version.
  expect(log.info).toHaveBeenCalledWith('release type "minor": 0.0.0 -> 0.1.0')

  // The release script is provided with the environmental variables.
  expect(process.stdout.write).toHaveBeenCalledWith(
    'release script input: 0.1.0\n',
  )
  expect(log.info).toHaveBeenCalledWith(
    expect.stringContaining('bumped version in package.json to: 0.1.0'),
  )

  // Must bump the "version" in package.json.
  expect(
    JSON.parse(await repo.fs.readFile('package.json', 'utf8')),
  ).toHaveProperty('version', '0.1.0')

  expect(await repo.fs.exec('git log')).toHaveProperty(
    'stdout',
    expect.stringContaining('chore(release): v0.1.0'),
  )

  // Must create a new tag for the release.
  expect(await repo.fs.exec('git tag')).toHaveProperty(
    'stdout',
    expect.stringContaining('0.1.0'),
  )

  expect(log.info).toHaveBeenCalledWith('created release: /releases/1')
  expect(log.info).toHaveBeenCalledWith('release "v0.1.0" completed!')
})

it('releases a new version after an existing version', async () => {
  const repo = await createRepository('version-new-after-existing')

  api.use(
    graphql.query('GetCommitAuthors', () => {
      return HttpResponse.json({ data: {} })
    }),
    githubLatestReleaseHandler,
    http.post<never, never, GitHubRelease>(
      'https://api.github.com/repos/:owner/:repo/releases',
      () => {
        return HttpResponse.json(
          {
            tag_name: 'v1.0.0',
            html_url: '/releases/1',
          },
          { status: 201 },
        )
      },
    ),
  )

  await repo.fs.create({
    'package.json': JSON.stringify({
      name: 'test',
      version: '1.2.3',
    }),
  })
  await execAsync(`git commit -m 'chore(release): v1.2.3' --allow-empty`)
  await execAsync('git tag v1.2.3')
  await execAsync(`git commit -m 'fix: stuff' --allow-empty`)
  await execAsync(`git commit -m 'feat: stuff' --allow-empty`)

  const publish = new Publish(
    {
      profiles: [
        {
          name: 'latest',
          use: 'echo "release script input: $RELEASE_VERSION"',
        },
      ],
    },
    {
      _: [],
      profile: 'latest',
    },
  )
  await publish.run()

  expect(log.error).not.toHaveBeenCalled()
  expect(log.info).toHaveBeenCalledWith(
    expect.stringContaining('found 2 new commits:'),
  )

  expect(log.info).toHaveBeenCalledWith(
    expect.stringContaining('found latest release: v1.2.3'),
  )

  // Must notify about the next version.
  expect(log.info).toHaveBeenCalledWith('release type "minor": 1.2.3 -> 1.3.0')

  // The release script is provided with the environmental variables.
  expect(process.stdout.write).toHaveBeenCalledWith(
    'release script input: 1.3.0\n',
  )
  expect(log.info).toHaveBeenCalledWith(
    expect.stringContaining('bumped version in package.json to: 1.3.0'),
  )

  // Must bump the "version" in package.json.
  expect(
    JSON.parse(await repo.fs.readFile('package.json', 'utf8')),
  ).toHaveProperty('version', '1.3.0')

  expect(await repo.fs.exec('git log')).toHaveProperty(
    'stdout',
    expect.stringContaining('chore(release): v1.3.0'),
  )

  // Must create a new tag for the release.
  expect(await repo.fs.exec('git tag')).toHaveProperty(
    'stdout',
    expect.stringContaining('v1.3.0'),
  )

  expect(log.info).toHaveBeenCalledWith('created release: /releases/1')
  expect(log.info).toHaveBeenCalledWith('release "v1.3.0" completed!')
})

it('comments on relevant github issues', async () => {
  const repo = await createRepository('issue-comments')

  const commentsCreated = new Map<string, string>()

  api.use(
    graphql.query('GetCommitAuthors', () => {
      return HttpResponse.json({
        data: {
          repository: {
            pullRequest: {
              author: { login: 'octocat' },
              commits: {
                nodes: [],
              },
            },
          },
        },
      })
    }),
    githubLatestReleaseHandler,
    http.post<never, never, GitHubRelease>(
      'https://api.github.com/repos/:owner/:repo/releases',
      () => {
        return HttpResponse.json(
          {
            tag_name: 'v1.0.0',
            html_url: '/releases/1',
          },
          { status: 201 },
        )
      },
    ),
    http.get('https://api.github.com/repos/:owner/:repo/issues/:id', () => {
      return HttpResponse.json({})
    }),
    http.post<{ id: string }, string>(
      'https://api.github.com/repos/:owner/:repo/issues/:id/comments',
      async ({ params, request }) => {
        commentsCreated.set(params.id, await request.text())
        return new HttpResponse(null, { status: 201 })
      },
    ),
  )

  await repo.fs.create({
    'package.json': JSON.stringify({
      name: 'test',
      version: '0.0.0',
    }),
  })
  await repo.fs.exec(
    `git commit -m 'feat: supports graphql (#10)' --allow-empty`,
  )

  const publish = new Publish(
    {
      profiles: [
        {
          name: 'latest',
          use: 'echo "release script input: $RELEASE_VERSION"',
        },
      ],
    },
    {
      _: [],
      profile: 'latest',
    },
  )
  await publish.run()

  expect(log.info).toHaveBeenCalledWith('commenting on 1 GitHub issue:\n  - 10')
  expect(commentsCreated).toEqual(
    new Map([['10', expect.stringContaining('## Released: v0.1.0 🎉')]]),
  )

  expect(log.info).toHaveBeenCalledWith('release "v0.1.0" completed!')
})

it('supports dry-run mode', async () => {
  const repo = await createRepository('dry-mode')

  const getReleaseContributorsResolver = vi.fn<GraphQLResponseResolver>(() => {
    return new HttpResponse(null, { status: 500 })
  })
  const createGitHubReleaseResolver = vi.fn<HttpResponseResolver>(() => {
    return new HttpResponse(null, { status: 500 })
  })

  api.use(
    graphql.query('GetCommitAuthors', getReleaseContributorsResolver),
    githubLatestReleaseHandler,
    http.post(
      'https://api.github.com/repos/:owner/:repo/releases',
      createGitHubReleaseResolver,
    ),
    http.get('https://api.github.com/repos/:owner/:repo/issues/:id', () => {
      return HttpResponse.json({})
    }),
  )

  await repo.fs.create({
    'package.json': JSON.stringify({
      name: 'test',
      version: '1.2.3',
    }),
  })
  await execAsync(`git commit -m 'chore(release): v1.2.3' --allow-empty`)
  await execAsync('git tag v1.2.3')
  await execAsync(`git commit -m 'fix: stuff (#2)' --allow-empty`)
  await execAsync(`git commit -m 'feat: stuff' --allow-empty`)

  const publish = new Publish(
    {
      profiles: [
        {
          name: 'latest',
          use: 'touch release.script.artifact',
        },
      ],
    },
    {
      _: [],
      profile: 'latest',
      dryRun: true,
    },
  )
  await publish.run()

  expect(log.info).toHaveBeenCalledWith(
    'preparing release for "octocat/dry-mode" from branch "main"...',
  )
  expect(log.info).toHaveBeenCalledWith(
    expect.stringContaining('found 2 new commits:'),
  )

  // Package.json version bump.
  expect(log.info).toHaveBeenCalledWith('release type "minor": 1.2.3 -> 1.3.0')
  expect(log.warn).toHaveBeenCalledWith(
    'skip version bump in package.json in dry-run mode (next: 1.3.0)',
  )
  expect(
    JSON.parse(await repo.fs.readFile('package.json', 'utf8')),
  ).toHaveProperty('version', '1.2.3')

  // Publishing script.
  expect(log.warn).toHaveBeenCalledWith(
    'skip executing publishing script in dry-run mode',
  )
  expect(fs.existsSync(repo.fs.resolve('release.script.artifact'))).toBe(false)

  // No release commit must be created.
  expect(log.warn).toHaveBeenCalledWith(
    'skip creating a release commit in dry-run mode: "chore(release): v1.3.0"',
  )
  expect(log.info).not.toHaveBeenCalledWith('created release commit!')

  // No release tag must be created.
  expect(log.warn).toHaveBeenCalledWith(
    'skip creating a release tag in dry-run mode: v1.3.0',
  )
  expect(log.info).not.toHaveBeenCalledWith('created release tag "v1.3.0"!')
  expect(await execAsync('git tag')).toEqual({
    stderr: '',
    stdout: 'v1.2.3\n',
  })

  // Release notes must still be generated.
  expect(log.info).toHaveBeenCalledWith(
    expect.stringContaining('generated release notes:\n\n## v1.3.0'),
  )

  expect(createGitHubReleaseResolver).not.toHaveBeenCalled()

  // The actual GitHub release must not be created.
  expect(log.warn).toHaveBeenCalledWith(
    'skip creating a GitHub release in dry-run mode',
  )

  // Dry mode still gets all release contributors because
  // it's a read operation.
  expect(getReleaseContributorsResolver).toHaveBeenCalledTimes(1)

  expect(log.warn).toHaveBeenCalledWith(
    'release "v1.3.0" completed in dry-run mode!',
  )
})

it('streams the release script stdout to the main process', async () => {
  const repo = await createRepository('stream-stdout')

  api.use(
    graphql.query('GetCommitAuthors', () => {
      return HttpResponse.json({ data: {} })
    }),
    githubLatestReleaseHandler,
    http.post<never, never, GitHubRelease>(
      'https://api.github.com/repos/:owner/:repo/releases',
      () => {
        return HttpResponse.json(
          {
            tag_name: 'v1.0.0',
            html_url: '/releases/1',
          },
          { status: 201 },
        )
      },
    ),
  )

  await repo.fs.create({
    'package.json': JSON.stringify({
      name: 'publish-stream',
      version: '0.0.0',
    }),
    'stream-stdout.js': `
console.log('hello')
setTimeout(() => console.log('world'), 100)
setTimeout(() => process.exit(0), 150)
      `,
  })
  await execAsync(
    `git commit -m 'feat: stream release script stdout' --allow-empty`,
  )

  const publish = new Publish(
    {
      profiles: [
        {
          name: 'latest',
          use: 'node stream-stdout.js',
        },
      ],
    },
    {
      _: [],
      profile: 'latest',
    },
  )

  await publish.run()

  // Must log the release script stdout.
  expect(process.stdout.write).toHaveBeenCalledWith('hello\n')
  expect(process.stdout.write).toHaveBeenCalledWith('world\n')

  // Must report a successful release.
  expect(log.info).toHaveBeenCalledWith('release type "minor": 0.0.0 -> 0.1.0')
  expect(log.info).toHaveBeenCalledWith('release "v0.1.0" completed!')
})

it('streams the release script stderr to the main process', async () => {
  api.use(
    graphql.query('GetCommitAuthors', () => {
      return HttpResponse.json({ data: {} })
    }),
    githubLatestReleaseHandler,
    http.post<never, never, GitHubRelease>(
      'https://api.github.com/repos/:owner/:repo/releases',
      () => {
        return HttpResponse.json(
          {
            tag_name: 'v1.0.0',
            html_url: '/releases/1',
          },
          { status: 201 },
        )
      },
    ),
  )

  const repo = await createRepository('stream-stderr')
  await repo.fs.create({
    'package.json': JSON.stringify({
      name: 'publish-stream',
      version: '0.0.0',
      main: './index.js',
    }),
    'index.js': '',
    'stream-stderr.js': `
console.error('something')
setTimeout(() => console.error('went wrong'), 100)
setTimeout(() => process.exit(0), 150)
      `,
  })
  await execAsync(
    `git commit -m 'feat: stream release script stderr' --allow-empty`,
  )

  const publish = new Publish(
    {
      profiles: [
        {
          name: 'latest',
          use: 'node stream-stderr.js',
        },
      ],
    },
    {
      _: [],
      profile: 'latest',
    },
  )

  await publish.run()

  // Must log the release script stderr.
  expect(process.stderr.write).toHaveBeenCalledWith('something\n')
  expect(process.stderr.write).toHaveBeenCalledWith('went wrong\n')

  // Must report a successful release.
  // As long as the publish script doesn't exit, it is successful.
  expect(log.info).toHaveBeenCalledWith('release type "minor": 0.0.0 -> 0.1.0')
  expect(log.info).toHaveBeenCalledWith('release "v0.1.0" completed!')
})

it('only pushes the newly created release tag to the remote', async () => {
  const repo = await createRepository('push-release-tag')

  await repo.fs.create({
    'package.json': JSON.stringify({ name: 'push-tag', version: '1.0.0' }),
  })

  api.use(
    graphql.query('GetCommitAuthors', () => {
      return HttpResponse.json({ data: {} })
    }),
    githubLatestReleaseHandler,
    http.post<never, never, GitHubRelease>(
      'https://api.github.com/repos/:owner/:repo/releases',
      () => {
        return HttpResponse.json(
          {
            tag_name: 'v1.0.0',
            html_url: '/releases/1',
          },
          { status: 201 },
        )
      },
    ),
  )

  // Create an existing tag
  await execAsync(`git tag v1.0.0`)
  await execAsync(`git push origin v1.0.0`)

  // Create a new commit.
  await execAsync(`git commit -m 'feat: new feature' --allow-empty`)

  const publish = new Publish(
    {
      profiles: [
        {
          name: 'latest',
          use: 'exit 0',
        },
      ],
    },
    {
      _: [],
      profile: 'latest',
    },
  )
  await publish.run()

  expect(log.info).toHaveBeenCalledWith('release type "minor": 1.0.0 -> 1.1.0')
  expect(log.info).toHaveBeenCalledWith('release "v1.1.0" completed!')
})

it('treats breaking changes as minor versions when "prerelease" is set to true', async () => {
  const repo = await createRepository('prerelease-major-as-minor')

  api.use(
    graphql.query('GetCommitAuthors', () => {
      return HttpResponse.json({ data: {} })
    }),
    githubLatestReleaseHandler,
    http.post<never, never, GitHubRelease>(
      'https://api.github.com/repos/:owner/:repo/releases',
      () => {
        return HttpResponse.json(
          {
            tag_name: 'v1.0.0',
            html_url: '/releases/1',
          },
          { status: 201 },
        )
      },
    ),
  )

  await repo.fs.create({
    'package.json': JSON.stringify({
      name: 'test',
      version: '0.1.2',
    }),
  })
  await execAsync(`git commit -m 'chore(release): v0.1.2' --allow-empty`)
  await execAsync('git tag v0.1.2')
  await repo.fs.exec(
    `git add . && git commit -m 'feat: new things' -m 'BREAKING CHANGE: beware'`,
  )

  const publish = new Publish(
    {
      profiles: [
        {
          name: 'latest',
          use: 'echo "release script input: $RELEASE_VERSION"',
          // This forces breaking changes to result in a minor
          // version bump.
          prerelease: true,
        },
      ],
    },
    {
      _: [],
      profile: 'latest',
    },
  )
  await publish.run()

  expect(log.error).not.toHaveBeenCalled()

  // Must bump the minor version upon breaking change
  // due to the "prerelease" configuration set.
  expect(log.info).toHaveBeenCalledWith('release type "minor": 0.1.2 -> 0.2.0')

  // Must expose the correct environment variable
  // to the publish script.
  expect(process.stdout.write).toHaveBeenCalledWith(
    'release script input: 0.2.0\n',
  )

  // Must bump the "version" in package.json.
  expect(
    JSON.parse(await repo.fs.readFile('package.json', 'utf8')),
  ).toHaveProperty('version', '0.2.0')

  expect(await repo.fs.exec('git log')).toHaveProperty(
    'stdout',
    expect.stringContaining('chore(release): v0.2.0'),
  )

  expect(log.info).toHaveBeenCalledWith('release "v0.2.0" completed!')
})

it('treats minor bumps as minor versions when "prerelease" is set to true', async () => {
  const repo = await createRepository('prerelease-major-as-minor')

  api.use(
    graphql.query('GetCommitAuthors', () => {
      return HttpResponse.json({ data: {} })
    }),
    githubLatestReleaseHandler,
    http.post<never, never, GitHubRelease>(
      'https://api.github.com/repos/:owner/:repo/releases',
      () => {
        return HttpResponse.json(
          {
            tag_name: 'v1.0.0',
            html_url: '/releases/1',
          },
          { status: 201 },
        )
      },
    ),
  )

  await repo.fs.create({
    'package.json': JSON.stringify({
      name: 'test',
      version: '0.0.0',
    }),
  })
  await repo.fs.exec(`git add . && git commit -m 'feat: new things'`)

  const publish = new Publish(
    {
      profiles: [
        {
          name: 'latest',
          use: 'echo "release script input: $RELEASE_VERSION"',
          // This forces breaking changes to result in a minor
          // version bump.
          prerelease: true,
        },
      ],
    },
    {
      _: [],
      profile: 'latest',
    },
  )
  await publish.run()

  expect(log.error).not.toHaveBeenCalled()

  // Must bump the minor version upon breaking change
  // due to the "prerelease" configuration set.
  expect(log.info).toHaveBeenCalledWith('release type "minor": 0.0.0 -> 0.1.0')

  // Must expose the correct environment variable
  // to the publish script.
  expect(process.stdout.write).toHaveBeenCalledWith(
    'release script input: 0.1.0\n',
  )

  // Must bump the "version" in package.json.
  expect(
    JSON.parse(await repo.fs.readFile('package.json', 'utf8')),
  ).toHaveProperty('version', '0.1.0')

  expect(await repo.fs.exec('git log')).toHaveProperty(
    'stdout',
    expect.stringContaining('chore(release): v0.1.0'),
  )

  expect(log.info).toHaveBeenCalledWith('release "v0.1.0" completed!')
})

it('aborts the release if the package does not pass publint', async () => {
  api.use(
    graphql.query('GetCommitAuthors', () => {
      return HttpResponse.json({ data: {} })
    }),
    githubLatestReleaseHandler,
    http.post<never, never, GitHubRelease>(
      'https://api.github.com/repos/:owner/:repo/releases',
      () => {
        return HttpResponse.json(
          {
            tag_name: 'v1.0.0',
            html_url: '/releases/1',
          },
          { status: 201 },
        )
      },
    ),
  )

  const repo = await createRepository('publish--lint-error')
  await repo.fs.create({
    'package.json': JSON.stringify({
      name: 'test',
      version: '0.0.0',
      main: './non-existing-file.js',
    }),
  })

  await repo.fs.exec(`git add . && git commit -m 'feat: new things'`)

  const publish = new Publish(
    {
      profiles: [
        {
          name: 'latest',
          use: 'echo "release script input: $RELEASE_VERSION"',
        },
      ],
    },
    {
      _: [],
      profile: 'latest',
    },
  )

  await publish.run()

  expect(process.exit).toHaveBeenCalledWith(1)
  expect(log.error, 'Must print linting outcome').toHaveBeenCalledWith(
    `Failed to lint the package at "${repo.fs.resolve()}": the package contains issues that can potentially produce a broken release. Please resolve the issues above and retry the release.`,
  )

  expect(
    log.info,
    'Must not log a successful release',
  ).not.toHaveBeenCalledWith('release "v0.1.0" completed!')
})
