import re
from unittest.mock import AsyncMock, Mock, PropertyMock, call

import pytest

from upsies import errors, utils
from upsies.trackers import ptp


@pytest.fixture
def tracker():
    tracker = Mock()
    tracker.name = 'ptp'
    return tracker


@pytest.fixture
def imghost():
    class MockImageHost(utils.imghosts.base.ImageHostBase):
        name = 'mock image host'
        default_config = {}
        _upload_image = AsyncMock()

    return MockImageHost()


@pytest.fixture
def ptp_tracker_jobs(imghost, tracker, tmp_path, mocker):
    content_path = tmp_path / 'Foo 2000 1080p BluRay x264-ASDF'

    ptp_tracker_jobs = ptp.PtpTrackerJobs(
        content_path=str(content_path),
        tracker=tracker,
        image_hosts=(imghost,),
        btclient=Mock(),
        torrent_destination=str(tmp_path / 'destination'),
        common_job_args={
            'home_directory': str(tmp_path / 'home_directory'),
            'ignore_cache': True,
        },
        options=None,
    )

    return ptp_tracker_jobs


@pytest.fixture
def mock_job_attributes(mocker):
    def mock_job_attributes(ptp_tracker_jobs):
        job_attrs = (
            # Common background jobs
            'create_torrent_job',
            'mediainfo_job',
            'screenshots_job',
            'upload_screenshots_job',
            'ptp_group_id_job',
            'description_job',
            'trumpable_job',

            # Common interactive jobs
            'type_job',
            'imdb_job',
            'source_job',
            'scene_check_job',

            # Interactive jobs that only run if movie does not exists on PTP yet
            'title_job',
            'year_job',
            'plot_job',
            'tags_job',
            'cover_art_job',
        )
        for job_attr in job_attrs:
            mocker.patch.object(
                type(ptp_tracker_jobs),
                job_attr,
                PropertyMock(return_value=Mock(attr=job_attr)),
            )

    return mock_job_attributes


def test_jobs_before_upload_items(ptp_tracker_jobs, mock_job_attributes, mocker):
    mock_job_attributes(ptp_tracker_jobs)

    assert tuple(job.attr for job in ptp_tracker_jobs.jobs_before_upload) == (
        # Common background jobs
        'create_torrent_job',
        'mediainfo_job',
        'screenshots_job',
        'upload_screenshots_job',
        'ptp_group_id_job',
        'description_job',
        'trumpable_job',

        # Common interactive jobs
        'type_job',
        'imdb_job',
        'source_job',
        'scene_check_job',

        # Interactive jobs that only run if movie does not exists on PTP yet
        'title_job',
        'year_job',
        'plot_job',
        'tags_job',
        'cover_art_job',
    )


def test_isolated_jobs__only_description(ptp_tracker_jobs, mock_job_attributes, mocker):
    mock_job_attributes(ptp_tracker_jobs)
    mocker.patch.object(type(ptp_tracker_jobs), 'options', PropertyMock(return_value={'only_description': True}))
    mocker.patch.object(ptp_tracker_jobs, 'get_job_and_dependencies')
    assert ptp_tracker_jobs.isolated_jobs is ptp_tracker_jobs.get_job_and_dependencies.return_value
    assert ptp_tracker_jobs.get_job_and_dependencies.call_args_list == [
        call(ptp_tracker_jobs.description_job, ptp_tracker_jobs.screenshots_job)
    ]

def test_isolated_jobs__no_isolated_jobs(ptp_tracker_jobs, mock_job_attributes, mocker):
    mock_job_attributes(ptp_tracker_jobs)
    mocker.patch.object(type(ptp_tracker_jobs), 'options', PropertyMock(return_value={}))
    mocker.patch.object(ptp_tracker_jobs, 'get_job_and_dependencies')
    assert ptp_tracker_jobs.isolated_jobs == ()
    assert ptp_tracker_jobs.get_job_and_dependencies.call_args_list == []


def test_type_job(ptp_tracker_jobs, mocker):
    ChoiceJob_mock = mocker.patch('upsies.jobs.dialog.ChoiceJob')
    mocker.patch.object(ptp_tracker_jobs, 'get_job_name')
    mocker.patch.object(ptp_tracker_jobs, 'make_precondition')
    mocker.patch.object(ptp_tracker_jobs, 'common_job_args', return_value={'common_job_arg': 'common job argument'})

    assert ptp_tracker_jobs.type_job is ChoiceJob_mock.return_value
    assert ChoiceJob_mock.call_args_list == [call(
        name=ptp_tracker_jobs.get_job_name.return_value,
        label='Type',
        precondition=ptp_tracker_jobs.make_precondition.return_value,
        autodetect=ptp_tracker_jobs.autodetect_type,
        options=(
            ('Feature Film', 'Feature Film'),
            ('Short Film', 'Short Film'),
            ('Miniseries', 'Miniseries'),
            ('Stand-up Comedy', 'Stand-up Comedy'),
            ('Live Performance', 'Live Performance'),
            ('Movie Collection', 'Movie Collection'),
        ),
        callbacks={
            'finished': ptp_tracker_jobs.update_imdb_query,
        },
        common_job_arg='common job argument',
    )]
    assert ptp_tracker_jobs.get_job_name.call_args_list == [call('type')]
    assert ptp_tracker_jobs.make_precondition.call_args_list == [call('type_job')]
    assert ptp_tracker_jobs.common_job_args.call_args_list == [call()]


@pytest.mark.parametrize(
    argnames='release_type, main_video_duration, exp_return_value, exp_duration_calls',
    argvalues=(
        (utils.release.ReleaseType.season, 123, 'Miniseries', False),
        (utils.release.ReleaseType.movie, 45 * 60, 'Short Film', True),
        (utils.release.ReleaseType.movie, (45 * 60) + 1, None, True),
    ),
    ids=lambda v: str(v),
)
def test_autodetect_type(
        release_type, main_video_duration,
        exp_return_value, exp_duration_calls,
        ptp_tracker_jobs, mocker,
):
    mocker.patch.object(type(ptp_tracker_jobs), 'release_name', PropertyMock(return_value=Mock(
        type=release_type,
    )))

    mocks = Mock()
    mocks.attach_mock(
        mocker.patch('upsies.utils.video.find_videos', return_value=iter(
            ('Foo.S01E01.mkv', 'Foo.S01E02.mkv', 'Foo.S01E03.mkv', 'Foo.sample.mkv'),
        )),
        'find_videos',
    )
    mocks.attach_mock(
        mocker.patch('upsies.utils.video.filter_main_videos', return_value=iter(
            ('Foo.S01E01.mkv', 'Foo.S01E02.mkv', 'Foo.S01E03.mkv'),
        )),
        'filter_main_videos',
    )
    mocks.attach_mock(
        mocker.patch('upsies.utils.video.duration', return_value=main_video_duration),
        'duration',
    )

    return_value = ptp_tracker_jobs.autodetect_type('job_')
    assert return_value == exp_return_value

    if exp_duration_calls:
        assert mocks.mock_calls == [
            call.find_videos(ptp_tracker_jobs.content_path),
            call.filter_main_videos(('Foo.S01E01.mkv', 'Foo.S01E02.mkv', 'Foo.S01E03.mkv', 'Foo.sample.mkv')),
            call.duration('Foo.S01E01.mkv'),
        ]
    else:
        assert mocks.mock_calls == []


@pytest.mark.parametrize(
    argnames='type_job_output, exp_query_type',
    argvalues=(
        ([], '<original query type>'),
        (['Feature Film'], utils.release.ReleaseType.movie),
        (['Miniseries'], utils.release.ReleaseType.season),
        (['<anything else>'], '<original query type>'),
    ),
    ids=lambda v: str(v),
)
def test_update_imdb_query(type_job_output, exp_query_type, ptp_tracker_jobs, mocker):
    mocker.patch.object(type(ptp_tracker_jobs), 'imdb_job', PropertyMock(return_value=Mock(
        query=Mock(
            type='<original query type>',
        ),
    )))
    mocker.patch.object(type(ptp_tracker_jobs), 'type_job', PropertyMock(return_value=Mock(
        is_finished=True,
        output=type_job_output,
    )))

    return_value = ptp_tracker_jobs.update_imdb_query('job_')
    assert return_value is None
    assert ptp_tracker_jobs.imdb_job.query.type == exp_query_type


def test_trumpable_job(ptp_tracker_jobs, mocker):
    CustomJob_mock = mocker.patch('upsies.jobs.custom.CustomJob')
    mocker.patch.object(ptp_tracker_jobs, 'get_job_name')
    mocker.patch.object(ptp_tracker_jobs, 'make_precondition')
    mocker.patch.object(ptp_tracker_jobs, 'common_job_args', return_value={'common_job_arg': 'common job argument'})

    assert ptp_tracker_jobs.trumpable_job is CustomJob_mock.return_value
    assert CustomJob_mock.call_args_list == [call(
        name=ptp_tracker_jobs.get_job_name.return_value,
        label='Trumpable',
        precondition=ptp_tracker_jobs.make_precondition.return_value,
        worker=ptp_tracker_jobs.autodetect_trumpable,
        no_output_is_ok=True,
        catch=(
            errors.ContentError,
        ),
        common_job_arg='common job argument',
    )]
    assert ptp_tracker_jobs.get_job_name.call_args_list == [call('trumpable')]
    assert ptp_tracker_jobs.make_precondition.call_args_list == [call('trumpable_job')]
    assert ptp_tracker_jobs.common_job_args.call_args_list == [call(ignore_cache=True)]


@pytest.mark.parametrize(
    argnames='options, exp_hardcoded_subtitles_from_options, exp_no_english_subtitles_from_options',
    argvalues=(
        ({}, None, None),
        ({'hardcoded_subtitles': None, 'no_english_subtitles': None}, None, None),
        ({'hardcoded_subtitles': None, 'no_english_subtitles': True}, None, True),
        ({'hardcoded_subtitles': None, 'no_english_subtitles': False}, None, False),
        ({'hardcoded_subtitles': True, 'no_english_subtitles': None}, True, None),
        ({'hardcoded_subtitles': False, 'no_english_subtitles': None}, False, None),
        ({'hardcoded_subtitles': False, 'no_english_subtitles': True}, False, True),
        ({'hardcoded_subtitles': True, 'no_english_subtitles': False}, True, False),
        ({'hardcoded_subtitles': True, 'no_english_subtitles': True}, True, True),
        ({'hardcoded_subtitles': False, 'no_english_subtitles': False}, False, False),
    ),
    ids=lambda v: str(v),
)
@pytest.mark.parametrize(
    argnames='audio_languages, subtitle_languages, exp_no_english_subtitles_from_autodetection',
    argvalues=(
        pytest.param(
            None, None, False,
            id='Track detection fails',
        ),
        pytest.param(
            set(), set(), False,
            id='No audio tracks + No subtitle tracks',
        ),
        pytest.param(
            {'se'},
            {},
            True,
            id='No English audio + No subtitles',
        ),
        pytest.param(
            {'se'},
            {'se', 'en', 'dk', 'no'},
            False,
            id='No English audio + English subtitles',
        ),
        pytest.param(
            {'se', 'en'},
            {'se', 'dk', 'no'},
            False,
            id='English audio + No English subtitles',
        ),
        pytest.param(
            {'se', 'en'},
            {'en', 'se', 'dk', 'no'},
            False,
            id='English audio + English subtitles',
        ),
        pytest.param(
            {'se', ''},
            {'se', 'dk', 'no'},
            False,
            id='No English audio + No English subtitles + Unknown audio',
        ),
        pytest.param(
            {'se'},
            {'se', '', 'dk', 'no'},
            False,
            id='No English audio + No English subtitles + Unknown subtitles',
        ),
    ),
    ids=lambda v: str(v),
)
async def test_autodetect_trumpable(
        options,
        exp_hardcoded_subtitles_from_options, exp_no_english_subtitles_from_options,
        audio_languages, subtitle_languages,
        exp_no_english_subtitles_from_autodetection,
        ptp_tracker_jobs, mocker,
):
    mocker.patch.object(type(ptp_tracker_jobs), 'options', PropertyMock(return_value=options))

    languages_mock = mocker.patch('upsies.utils.video.languages', return_value=(
        {'Audio': audio_languages}
        if audio_languages is not None else
        {}
    ))

    mocker.patch.object(type(ptp_tracker_jobs), 'subtitles', PropertyMock(return_value=(
        [
            Mock(language=language)
            for language in subtitle_languages
        ]
        if subtitle_languages is not None else
        []
    )))

    reasons = await ptp_tracker_jobs.autodetect_trumpable('job_')

    exp_hardcoded_subtitles = (
        exp_hardcoded_subtitles_from_options
        if exp_hardcoded_subtitles_from_options is not None else
        False
    )

    exp_no_english_subtitles = (
        exp_no_english_subtitles_from_options
        if exp_no_english_subtitles_from_options is not None else
        exp_no_english_subtitles_from_autodetection
    )

    exp_reasons = []
    if exp_hardcoded_subtitles:
        exp_reasons.append(ptp.metadata.PtpTrumpableReason.HARDCODED_SUBTITLES)
    if exp_no_english_subtitles:
        exp_reasons.append(ptp.metadata.PtpTrumpableReason.NO_ENGLISH_SUBTITLES)

    assert reasons == exp_reasons

    if exp_no_english_subtitles_from_options is None:
        assert languages_mock.call_args_list == [
            call(ptp_tracker_jobs.content_path, default=''),
        ]


def test_description_job(ptp_tracker_jobs, mocker):
    mocker.patch.object(type(ptp_tracker_jobs), 'mediainfo_job', PropertyMock())
    mocker.patch.object(type(ptp_tracker_jobs), 'upload_screenshots_job', PropertyMock())
    mocker.patch.object(ptp_tracker_jobs, 'generate_description')
    TextFieldJob_mock = mocker.patch('upsies.jobs.dialog.TextFieldJob')
    mocker.patch.object(ptp_tracker_jobs, 'get_job_name')
    mocker.patch.object(ptp_tracker_jobs, 'make_precondition')
    mocker.patch.object(ptp_tracker_jobs, 'get_job_and_dependencies')
    mocker.patch.object(ptp_tracker_jobs, 'common_job_args', return_value={'common_job_arg': 'common job argument'})

    assert ptp_tracker_jobs.description_job is TextFieldJob_mock.return_value
    assert TextFieldJob_mock.call_args_list == [call(
        name=ptp_tracker_jobs.get_job_name.return_value,
        label='Description',
        precondition=ptp_tracker_jobs.make_precondition.return_value,
        prejobs=ptp_tracker_jobs.get_job_and_dependencies.return_value,
        text=ptp_tracker_jobs.generate_description,
        read_only=True,
        hidden=True,
        finish_on_success=True,
        common_job_arg='common job argument',
    )]
    assert ptp_tracker_jobs.get_job_name.call_args_list == [call('description')]
    assert ptp_tracker_jobs.make_precondition.call_args_list == [call('description_job')]
    assert ptp_tracker_jobs.get_job_and_dependencies.call_args_list == [call(
        ptp_tracker_jobs.mediainfo_job,
        ptp_tracker_jobs.upload_screenshots_job,
    )]
    assert ptp_tracker_jobs.common_job_args.call_args_list == [call(ignore_cache=True)]


@pytest.mark.parametrize(
    argnames='content_path, exp_original_release_name',
    argvalues=(
        ('path/to/Foo.2002.720p.x264-ASDF.mkv', 'Foo.2002.720p.x264-ASDF'),
        ('path/to/Foo.2002.720p.x264-ASDF', 'Foo.2002.720p.x264-ASDF'),
        ('Foo.2002.720p.x264-ASDF.mkv', 'Foo.2002.720p.x264-ASDF'),
        ('Foo.2002.720p.x264-ASDF', 'Foo.2002.720p.x264-ASDF'),
    ),
    ids=lambda v: str(v),
)
async def test_generate_description(content_path, exp_original_release_name, ptp_tracker_jobs, mocker):
    mocker.patch.object(type(ptp_tracker_jobs), 'content_path', PropertyMock(return_value=content_path))
    mocker.patch.object(type(ptp_tracker_jobs), 'mediainfos_and_screenshots', PropertyMock(return_value={
        'path/to/video1.mkv': {
            'mediainfo': '<mediainfo of path/to/video1.mkv>',
            'screenshot_urls': (
                'http://screenshot1.of.video1.mkv',
                'http://screenshot2.of.video1.mkv',
                'http://screenshot3.of.video1.mkv',
            ),
        },
        'path/to/video2.mkv': {
            'mediainfo': None,
            'screenshot_urls': (
                'http://screenshot1.of.video2.mkv',
                'http://screenshot2.of.video2.mkv',
            ),
        },
        'path/to/video3.mkv': {
            'mediainfo': '<mediainfo of path/to/video3.mkv>',
            'screenshot_urls': (),
        },
        'path/to/video4.mkv': {
            'mediainfo': None,
            'screenshot_urls': (),
        },
    }))

    description = await ptp_tracker_jobs.generate_description()
    assert description == (
        f'[size=4][b]{exp_original_release_name}[/b][/size]'
        '\n\n'
        '[mediainfo]<mediainfo of path/to/video1.mkv>[/mediainfo]'
        '[img=http://screenshot1.of.video1.mkv]\n'
        '[img=http://screenshot2.of.video1.mkv]\n'
        '[img=http://screenshot3.of.video1.mkv]'
        '\n[hr]\n'
        '[img=http://screenshot1.of.video2.mkv]\n'
        '[img=http://screenshot2.of.video2.mkv]'
    )


def test_mediainfo_from_all_videos(ptp_tracker_jobs):
    assert ptp_tracker_jobs.mediainfo_from_all_videos is True


def test_screenshots_from_all_videos(ptp_tracker_jobs):
    assert ptp_tracker_jobs.screenshots_from_all_videos is True


@pytest.mark.parametrize(
    argnames='options, filtered_video_files, exp_screenshots_count, exp_filter_calls',
    argvalues=(
        pytest.param(
            {'screenshots': 123, 'screenshots_from_movie': 456, 'screenshots_from_episode': 789},
            None,
            123,
            False,
            id='User-provided number of screenshots',
        ),
        pytest.param(
            {'screenshots_from_movie': 456, 'screenshots_from_episode': 789},
            ['file1.mkv'],
            456,
            True,
            id='Number of screenshots for movies',
        ),
        pytest.param(
            {'screenshots_from_movie': 456, 'screenshots_from_episode': 789},
            ['foo1.mkv', 'foo2.mkv'],
            789,
            True,
            id='Number of screenshots for episodes',
        ),
    ),
    ids=lambda v: str(v),
)
def test_screenshots_count(
        options, filtered_video_files,
        exp_screenshots_count, exp_filter_calls,
        ptp_tracker_jobs, mocker,
):
    mocks = Mock()
    mocks.attach_mock(mocker.patch('upsies.utils.torrent.filter_files'), 'filter_files')
    mocks.attach_mock(
        mocker.patch('upsies.utils.video.filter_main_videos', return_value=filtered_video_files),
        'filter_main_videos',
    )
    mocker.patch.object(type(ptp_tracker_jobs), 'options', PropertyMock(return_value=options))
    mocker.patch.object(type(ptp_tracker_jobs), 'content_path', PropertyMock())
    mocker.patch.object(type(ptp_tracker_jobs), 'exclude_files', PropertyMock())

    screenshots_count = ptp_tracker_jobs.screenshots_count
    assert screenshots_count == exp_screenshots_count

    if exp_filter_calls:
        assert mocks.mock_calls == [
            call.filter_files(ptp_tracker_jobs.content_path, exclude=ptp_tracker_jobs.exclude_files),
            call.filter_main_videos(mocks.filter_files.return_value),
        ]
    else:
        assert mocks.mock_calls == []


@pytest.mark.parametrize(
    argnames='imdb_job, exp_imdb_id',
    argvalues=(
        (Mock(is_finished=False), None),
        (Mock(is_finished=True, output=['tt123456']), 'tt123456'),
    ),
    ids=lambda v: str(v),
)
def test_imdb_id(imdb_job, exp_imdb_id, ptp_tracker_jobs, mocker):
    mocker.patch.object(type(ptp_tracker_jobs), 'imdb_job', PropertyMock(return_value=imdb_job))
    imdb_id = ptp_tracker_jobs.imdb_id
    assert imdb_id == exp_imdb_id


def test_ptp_group_id_job(ptp_tracker_jobs, mocker):
    mocker.patch.object(type(ptp_tracker_jobs), 'imdb_job', PropertyMock())
    mocker.patch.object(ptp_tracker_jobs, 'fetch_ptp_group_id')
    CustomJob_mock = mocker.patch('upsies.jobs.custom.CustomJob')
    mocker.patch.object(ptp_tracker_jobs, 'get_job_name')
    mocker.patch.object(ptp_tracker_jobs, 'make_precondition')
    mocker.patch.object(ptp_tracker_jobs, 'get_job_and_dependencies')
    mocker.patch.object(ptp_tracker_jobs, 'common_job_args', return_value={'common_job_arg': 'common job argument'})

    assert ptp_tracker_jobs.ptp_group_id_job is CustomJob_mock.return_value
    assert CustomJob_mock.call_args_list == [call(
        name=ptp_tracker_jobs.get_job_name.return_value,
        label='PTP Group ID',
        precondition=ptp_tracker_jobs.make_precondition.return_value,
        prejobs=ptp_tracker_jobs.get_job_and_dependencies.return_value,
        worker=ptp_tracker_jobs.fetch_ptp_group_id,
        catch=(
            errors.RequestError,
        ),
        common_job_arg='common job argument',
    )]
    assert ptp_tracker_jobs.get_job_name.call_args_list == [call('ptp-group-id')]
    assert ptp_tracker_jobs.make_precondition.call_args_list == [call('ptp_group_id_job')]
    assert ptp_tracker_jobs.get_job_and_dependencies.call_args_list == [call(
        ptp_tracker_jobs.imdb_job,
    )]
    assert ptp_tracker_jobs.common_job_args.call_args_list == [call()]


@pytest.mark.parametrize(
    argnames='group_id, exp_group_id',
    argvalues=(
        (None, ''),
        ('123456', '123456'),
    ),
    ids=lambda v: str(v),
)
async def test_fetch_ptp_group_id(group_id, exp_group_id, ptp_tracker_jobs, mocker):
    mocker.patch.object(type(ptp_tracker_jobs), 'imdb_job', PropertyMock(return_value=Mock(is_finished=True)))
    mocker.patch.object(type(ptp_tracker_jobs), 'imdb_id', PropertyMock(return_value='tt123456'))
    mocker.patch.object(ptp_tracker_jobs.tracker, 'get_ptp_group_id_by_imdb_id', AsyncMock(return_value=group_id))

    group_id = await ptp_tracker_jobs.fetch_ptp_group_id('ignored job')
    assert group_id == exp_group_id

    assert ptp_tracker_jobs.tracker.get_ptp_group_id_by_imdb_id.call_args_list == [call(ptp_tracker_jobs.imdb_id)]


@pytest.mark.parametrize(
    argnames='ptp_group_id_job, exp_ptp_group_id',
    argvalues=(
        (Mock(is_finished=False), None),
        (Mock(is_finished=True, output=[]), None),
        (Mock(is_finished=True, output=['123']), '123'),
    ),
    ids=lambda v: str(v),
)
async def test_ptp_group_id(ptp_group_id_job, exp_ptp_group_id, ptp_tracker_jobs, mocker):
    mocker.patch.object(type(ptp_tracker_jobs), 'ptp_group_id_job', PropertyMock(return_value=ptp_group_id_job))
    ptp_group_id = ptp_tracker_jobs.ptp_group_id
    assert ptp_group_id == exp_ptp_group_id


@pytest.mark.parametrize(
    argnames='ptp_group_id_job, exp_ptp_group_does_not_exist',
    argvalues=(
        (Mock(is_finished=False), AssertionError),
        (Mock(is_finished=True, output=[]), True),
        (Mock(is_finished=True, output=['123']), False),
    ),
    ids=lambda v: str(v),
)
async def test_ptp_group_does_not_exist(ptp_group_id_job, exp_ptp_group_does_not_exist, ptp_tracker_jobs, mocker):
    mocker.patch.object(type(ptp_tracker_jobs), 'ptp_group_id_job', PropertyMock(return_value=ptp_group_id_job))
    if (
            type(exp_ptp_group_does_not_exist) is type
            and issubclass(exp_ptp_group_does_not_exist, Exception)
    ):
        with pytest.raises(exp_ptp_group_does_not_exist) as exception_info:
            ptp_tracker_jobs.ptp_group_does_not_exist()
        assert exception_info.value.args[0] is ptp_tracker_jobs.ptp_group_id_job
    else:
        ptp_group_does_not_exist = ptp_tracker_jobs.ptp_group_does_not_exist()
        assert ptp_group_does_not_exist == exp_ptp_group_does_not_exist


def test_title_job(ptp_tracker_jobs, mocker):
    mocker.patch.object(type(ptp_tracker_jobs), 'ptp_group_id_job', PropertyMock())
    mocker.patch.object(type(ptp_tracker_jobs), 'imdb_job', PropertyMock())
    mocker.patch.object(type(ptp_tracker_jobs), 'ptp_group_does_not_exist')
    TextFieldJob_mock = mocker.patch('upsies.jobs.dialog.TextFieldJob')
    mocker.patch.object(ptp_tracker_jobs, 'get_job_name')
    mocker.patch.object(ptp_tracker_jobs, 'make_precondition')
    mocker.patch.object(ptp_tracker_jobs, 'get_job_and_dependencies')
    mocker.patch.object(ptp_tracker_jobs, 'common_job_args', return_value={'common_job_arg': 'common job argument'})

    assert ptp_tracker_jobs.title_job is TextFieldJob_mock.return_value
    assert TextFieldJob_mock.call_args_list == [call(
        name=ptp_tracker_jobs.get_job_name.return_value,
        label='Title',
        precondition=ptp_tracker_jobs.make_precondition.return_value,
        prejobs=ptp_tracker_jobs.get_job_and_dependencies.return_value,
        text=ptp_tracker_jobs.fetch_title,
        normalizer=ptp_tracker_jobs.normalize_title,
        validator=ptp_tracker_jobs.validate_title,
        common_job_arg='common job argument',
    )]
    assert ptp_tracker_jobs.get_job_name.call_args_list == [call('title')]
    assert ptp_tracker_jobs.make_precondition.call_args_list == [call(
        'title_job',
        precondition=ptp_tracker_jobs.ptp_group_does_not_exist,
    )]
    assert ptp_tracker_jobs.get_job_and_dependencies.call_args_list == [call(
        ptp_tracker_jobs.ptp_group_id_job,
    )]
    assert ptp_tracker_jobs.common_job_args.call_args_list == [call()]


@pytest.mark.parametrize(
    argnames='imdb_job, exp_exception',
    argvalues=(
        (Mock(is_finished=False), AssertionError),
        (Mock(is_finished=True, output=['123']), None),
    ),
    ids=lambda v: str(v),
)
async def test_fetch_title(imdb_job, exp_exception, ptp_tracker_jobs, mocker):
    mocker.patch.object(type(ptp_tracker_jobs), 'imdb_job', PropertyMock(return_value=imdb_job))
    mocker.patch.object(ptp_tracker_jobs.tracker, 'get_ptp_metadata', AsyncMock())

    if exp_exception:
        with pytest.raises(exp_exception) as exception_info:
            await ptp_tracker_jobs.fetch_title()
        assert exception_info.value.args[0] is ptp_tracker_jobs.imdb_job
    else:
        return_value = await ptp_tracker_jobs.fetch_title()
        assert return_value == ptp_tracker_jobs.tracker.get_ptp_metadata.return_value
        ptp_tracker_jobs.tracker.get_ptp_metadata.call_args_list == [
            call(ptp_tracker_jobs.imdb_id, key='title'),
        ]


@pytest.mark.parametrize(
    argnames='text, exp_title',
    argvalues=(
        ('The Title', 'The Title'),
        (' The Title', 'The Title'),
        (' The Title  ', 'The Title'),
        ('\nThe Title\t', 'The Title'),
    ),
    ids=lambda v: str(v),
)
def test_normalize_title(text, exp_title, ptp_tracker_jobs, mocker):
    return_value = ptp_tracker_jobs.normalize_title(text)
    assert return_value == exp_title


@pytest.mark.parametrize(
    argnames='text, exp_exception',
    argvalues=(
        ('', ValueError('Title must not be empty.')),
        ('not empty', None),
    ),
    ids=lambda v: str(v),
)
async def test_validate_title(text, exp_exception, ptp_tracker_jobs, mocker):
    if exp_exception:
        with pytest.raises(type(exp_exception), match=rf'^{re.escape(str(exp_exception))}$'):
            ptp_tracker_jobs.validate_title(text)
    else:
        return_value = ptp_tracker_jobs.validate_title(text)
        assert return_value is None


def test_year_job(ptp_tracker_jobs, mocker):
    mocker.patch.object(type(ptp_tracker_jobs), 'ptp_group_id_job', PropertyMock())
    mocker.patch.object(type(ptp_tracker_jobs), 'ptp_group_does_not_exist')
    mocker.patch.object(type(ptp_tracker_jobs), 'imdb_job', PropertyMock())
    TextFieldJob_mock = mocker.patch('upsies.jobs.dialog.TextFieldJob')
    mocker.patch.object(ptp_tracker_jobs, 'get_job_name')
    mocker.patch.object(ptp_tracker_jobs, 'make_precondition')
    mocker.patch.object(ptp_tracker_jobs, 'get_job_and_dependencies')
    mocker.patch.object(ptp_tracker_jobs, 'common_job_args', return_value={'common_job_arg': 'common job argument'})

    assert ptp_tracker_jobs.year_job is TextFieldJob_mock.return_value
    assert TextFieldJob_mock.call_args_list == [call(
        name=ptp_tracker_jobs.get_job_name.return_value,
        label='Year',
        precondition=ptp_tracker_jobs.make_precondition.return_value,
        prejobs=ptp_tracker_jobs.get_job_and_dependencies.return_value,
        text=ptp_tracker_jobs.fetch_year,
        normalizer=ptp_tracker_jobs.normalize_year,
        validator=ptp_tracker_jobs.validate_year,
        common_job_arg='common job argument',
    )]
    assert ptp_tracker_jobs.get_job_name.call_args_list == [call('year')]
    assert ptp_tracker_jobs.make_precondition.call_args_list == [call(
        'year_job',
        precondition=ptp_tracker_jobs.ptp_group_does_not_exist,
    )]
    assert ptp_tracker_jobs.get_job_and_dependencies.call_args_list == [call(
        ptp_tracker_jobs.ptp_group_id_job,
    )]
    assert ptp_tracker_jobs.common_job_args.call_args_list == [call()]


@pytest.mark.parametrize(
    argnames='imdb_job, exp_exception',
    argvalues=(
        (Mock(is_finished=False), AssertionError),
        (Mock(is_finished=True, output=['2012']), None),
    ),
    ids=lambda v: str(v),
)
async def test_fetch_year(imdb_job, exp_exception, ptp_tracker_jobs, mocker):
    mocker.patch.object(type(ptp_tracker_jobs), 'imdb_job', PropertyMock(return_value=imdb_job))
    mocker.patch.object(ptp_tracker_jobs.tracker, 'get_ptp_metadata', AsyncMock())

    if exp_exception:
        with pytest.raises(exp_exception) as exception_info:
            await ptp_tracker_jobs.fetch_year()
        assert exception_info.value.args[0] is ptp_tracker_jobs.imdb_job
    else:
        return_value = await ptp_tracker_jobs.fetch_year()
        assert return_value == ptp_tracker_jobs.tracker.get_ptp_metadata.return_value
        ptp_tracker_jobs.tracker.get_ptp_metadata.call_args_list == [
            call(ptp_tracker_jobs.imdb_id, key='year'),
        ]


@pytest.mark.parametrize(
    argnames='text, exp_year',
    argvalues=(
        ('2012', '2012'),
        (' 2012', '2012'),
        (' 2012  ', '2012'),
        ('\n2012\t', '2012'),
    ),
    ids=lambda v: str(v),
)
def test_normalize_year(text, exp_year, ptp_tracker_jobs, mocker):
    return_value = ptp_tracker_jobs.normalize_year(text)
    assert return_value == exp_year


@pytest.mark.parametrize(
    argnames='text, exp_exception',
    argvalues=(
        ('', ValueError('Year must not be empty.')),
        ('not empty', ValueError('Year is not a number.')),
        ('2012.3', ValueError('Year is not a number.')),
        ('1200', ValueError('Year is not reasonable.')),
        ('2012', None),
    ),
    ids=lambda v: str(v),
)
async def test_validate_year(text, exp_exception, ptp_tracker_jobs, mocker):
    if exp_exception:
        with pytest.raises(type(exp_exception), match=rf'^{re.escape(str(exp_exception))}$'):
            ptp_tracker_jobs.validate_year(text)
    else:
        return_value = ptp_tracker_jobs.validate_year(text)
        assert return_value is None


def test_tags_job(ptp_tracker_jobs, mocker):
    mocker.patch.object(type(ptp_tracker_jobs), 'ptp_group_id_job', PropertyMock())
    mocker.patch.object(type(ptp_tracker_jobs), 'ptp_group_does_not_exist')
    mocker.patch.object(type(ptp_tracker_jobs), 'imdb_job', PropertyMock())
    TextFieldJob_mock = mocker.patch('upsies.jobs.dialog.TextFieldJob')
    mocker.patch.object(ptp_tracker_jobs, 'get_job_name')
    mocker.patch.object(ptp_tracker_jobs, 'make_precondition')
    mocker.patch.object(ptp_tracker_jobs, 'get_job_and_dependencies')
    mocker.patch.object(ptp_tracker_jobs, 'common_job_args', return_value={'common_job_arg': 'common job argument'})

    assert ptp_tracker_jobs.tags_job is TextFieldJob_mock.return_value
    assert TextFieldJob_mock.call_args_list == [call(
        name=ptp_tracker_jobs.get_job_name.return_value,
        label='Tags',
        precondition=ptp_tracker_jobs.make_precondition.return_value,
        prejobs=ptp_tracker_jobs.get_job_and_dependencies.return_value,
        text=ptp_tracker_jobs.fetch_tags,
        normalizer=ptp_tracker_jobs.normalize_tags,
        validator=ptp_tracker_jobs.validate_tags,
        common_job_arg='common job argument',
    )]
    assert ptp_tracker_jobs.get_job_name.call_args_list == [call('tags')]
    assert ptp_tracker_jobs.make_precondition.call_args_list == [call(
        'tags_job',
        precondition=ptp_tracker_jobs.ptp_group_does_not_exist,
    )]
    assert ptp_tracker_jobs.get_job_and_dependencies.call_args_list == [call(
        ptp_tracker_jobs.ptp_group_id_job,
    )]
    assert ptp_tracker_jobs.common_job_args.call_args_list == [call()]


@pytest.mark.parametrize(
    argnames='imdb_job, exp_exception',
    argvalues=(
        (Mock(is_finished=False), AssertionError),
        (Mock(is_finished=True, output=['foo,bar,baz']), None),
    ),
    ids=lambda v: str(v),
)
async def test_fetch_tags(imdb_job, exp_exception, ptp_tracker_jobs, mocker):
    mocker.patch.object(type(ptp_tracker_jobs), 'imdb_job', PropertyMock(return_value=imdb_job))
    mocker.patch.object(ptp_tracker_jobs.tracker, 'get_ptp_metadata', AsyncMock())

    if exp_exception:
        with pytest.raises(exp_exception) as exception_info:
            await ptp_tracker_jobs.fetch_tags()
        assert exception_info.value.args[0] is ptp_tracker_jobs.imdb_job
    else:
        return_value = await ptp_tracker_jobs.fetch_tags()
        assert return_value == ptp_tracker_jobs.tracker.get_ptp_metadata.return_value
        ptp_tracker_jobs.tracker.get_ptp_metadata.call_args_list == [
            call(ptp_tracker_jobs.imdb_id, key='tags'),
        ]


@pytest.mark.parametrize(
    argnames='text, exp_tags',
    argvalues=(
        pytest.param('foo,bar,baz', 'bar, baz, foo', id='Tags are sorted'),
        pytest.param('foo,bar,baz,foo', 'bar, baz, foo', id='Tags are deduplicated'),
        pytest.param(',,,foo,,bar,baz,', 'bar, baz, foo', id='Empty tags are ignored'),
    ),
    ids=lambda v: str(v),
)
def test_normalize_tags(text, exp_tags, ptp_tracker_jobs, mocker):
    return_value = ptp_tracker_jobs.normalize_tags(text)
    assert return_value == exp_tags

    def normalize_tags(self, text):
        tags = [tag.strip() for tag in text.split(',')]
        deduped = list(dict.fromkeys(tags))
        return ', '.join(tag for tag in deduped if tag)


@pytest.mark.parametrize(
    argnames='text, exp_exception',
    argvalues=(
        ('horror,awesome,drama', ValueError('Tag is not valid: awesome')),
        ('horror,drama', None),
        ('', None),
    ),
    ids=lambda v: str(v),
)
async def test_validate_tags(text, exp_exception, ptp_tracker_jobs, mocker):
    if exp_exception:
        with pytest.raises(type(exp_exception), match=rf'^{re.escape(str(exp_exception))}$'):
            ptp_tracker_jobs.validate_tags(text)
    else:
        return_value = ptp_tracker_jobs.validate_tags(text)
        assert return_value is None


def test_cover_art_job(ptp_tracker_jobs, mocker):
    mocker.patch.object(type(ptp_tracker_jobs), 'ptp_group_id_job', PropertyMock())
    mocker.patch.object(type(ptp_tracker_jobs), 'ptp_group_does_not_exist')
    mocker.patch.object(type(ptp_tracker_jobs), 'imdb_job', PropertyMock())
    TextFieldJob_mock = mocker.patch('upsies.jobs.dialog.TextFieldJob')
    mocker.patch.object(ptp_tracker_jobs, 'get_job_name')
    mocker.patch.object(ptp_tracker_jobs, 'make_precondition')
    mocker.patch.object(ptp_tracker_jobs, 'get_job_and_dependencies')
    mocker.patch.object(ptp_tracker_jobs, 'common_job_args', return_value={'common_job_arg': 'common job argument'})

    assert ptp_tracker_jobs.cover_art_job is TextFieldJob_mock.return_value
    assert TextFieldJob_mock.call_args_list == [call(
        name=ptp_tracker_jobs.get_job_name.return_value,
        label='Cover Art',
        precondition=ptp_tracker_jobs.make_precondition.return_value,
        prejobs=ptp_tracker_jobs.get_job_and_dependencies.return_value,
        text=ptp_tracker_jobs.get_cover_art,
        validator=ptp_tracker_jobs.validate_cover_art,
        finish_on_success=True,
        common_job_arg='common job argument',
    )]
    assert ptp_tracker_jobs.get_job_name.call_args_list == [call('cover-art')]
    assert ptp_tracker_jobs.make_precondition.call_args_list == [call(
        'cover_art_job',
        precondition=ptp_tracker_jobs.ptp_group_does_not_exist,
    )]
    assert ptp_tracker_jobs.get_job_and_dependencies.call_args_list == [call(
        ptp_tracker_jobs.ptp_group_id_job,
    )]
    assert ptp_tracker_jobs.common_job_args.call_args_list == [call()]


@pytest.mark.parametrize(
    argnames='text, exp_result',
    argvalues=(
        ('http://foo.jpg', None),
        ('https://foo.jpg', None),
        ('HTTPS://foo.jpg', None),
        ('foo.jpg', ValueError('Must be an http(s):// URL.')),
    ),
    ids=lambda v: str(v),
)
def test_validate_cover_art(text, exp_result, ptp_tracker_jobs):
    if isinstance(exp_result, Exception):
        with pytest.raises(type(exp_result), match=rf'^{re.escape(str(exp_result))}$'):
            ptp_tracker_jobs.validate_cover_art(text)
    else:
        result = ptp_tracker_jobs.validate_cover_art(text)
        assert result is exp_result


@pytest.mark.parametrize(
    argnames='options, exp_method_name',
    argvalues=(
        ({}, 'get_cover_art_from_ptp'),
        ({'cover_art': None}, 'get_cover_art_from_ptp'),
        ({'cover_art': ''}, 'get_cover_art_from_ptp'),
        ({'cover_art': 'user_provided.jpg'}, 'get_cover_art_from_user'),
    ),
    ids=lambda v: str(v),
)
async def test_get_cover_art(options, exp_method_name, ptp_tracker_jobs, mocker):
    mocker.patch.object(type(ptp_tracker_jobs), 'options', PropertyMock(return_value=options))
    mocks = Mock()
    mocks.attach_mock(
        mocker.patch.object(ptp_tracker_jobs, 'get_cover_art_from_user'),
        'get_cover_art_from_user',
    )
    mocks.attach_mock(
        mocker.patch.object(ptp_tracker_jobs, 'get_cover_art_from_ptp'),
        'get_cover_art_from_ptp',
    )

    result = await ptp_tracker_jobs.get_cover_art()
    exp_method = getattr(mocks, exp_method_name)
    assert result is exp_method.return_value
    assert mocks.mock_calls == [getattr(call, exp_method_name)()]


@pytest.mark.parametrize(
    argnames='options, upload_exception, exp_return_value, exp_mock_calls',
    argvalues=(
        ({}, None, '', []),
        ({'cover_art': ''}, None, '', []),
        ({'cover_art': 'http://foo.jpg'}, None, 'http://foo.jpg', []),
        ({'cover_art': 'HTTPS://foo.jpg'}, None, 'HTTPS://foo.jpg', []),
        ({'cover_art': 'foo.jpg'}, None, 'http://foo.jpg', [call.upload('foo.jpg')]),
        (
            {'cover_art': 'foo.jpg'},
            errors.RequestError('Service down'),
            '',
            [
                call.upload('foo.jpg'),
                call.error(errors.RequestError('Service down')),
            ],
        ),
    ),
    ids=lambda v: str(v),
)
async def test_get_cover_art_from_user(options, upload_exception, exp_return_value, exp_mock_calls, ptp_tracker_jobs, mocker):
    mocker.patch.object(type(ptp_tracker_jobs), 'options', PropertyMock(return_value=options))
    mocks = Mock()
    mocks.attach_mock(
        mocker.patch.object(ptp_tracker_jobs.image_hosts[0], 'upload', side_effect=(
            upload_exception
            if upload_exception else
            lambda f: f'http://{f}'
        )),
        'upload',
    )
    mocks.attach_mock(
        mocker.patch.object(ptp_tracker_jobs.cover_art_job, 'error'),
        'error',
    )

    return_value = await ptp_tracker_jobs.get_cover_art_from_user()
    assert return_value == exp_return_value
    assert mocks.mock_calls == exp_mock_calls


async def test_get_cover_art_from_ptp(ptp_tracker_jobs, mocker):
    mocks = Mock()
    mocks.attach_mock(
        mocker.patch.object(type(ptp_tracker_jobs), 'imdb_job', PropertyMock(return_value=Mock(wait=AsyncMock()))),
        'wait',
    )
    mocks.attach_mock(
        mocker.patch.object(ptp_tracker_jobs.tracker, 'get_ptp_metadata', AsyncMock(return_value=Mock())),
        'get_ptp_metadata',
    )
    mocker.patch.object(type(ptp_tracker_jobs), 'imdb_id', PropertyMock(return_value='tt0123'))

    return_value = await ptp_tracker_jobs.get_cover_art_from_ptp()
    assert return_value == ptp_tracker_jobs.tracker.get_ptp_metadata.return_value
    assert mocks.mock_calls == [
        call.wait(),
        call.get_ptp_metadata('tt0123', key='art'),
    ]


def test_plot_job(ptp_tracker_jobs, mocker):
    mocker.patch.object(type(ptp_tracker_jobs), 'ptp_group_id_job', PropertyMock())
    mocker.patch.object(type(ptp_tracker_jobs), 'ptp_group_does_not_exist')
    mocker.patch.object(type(ptp_tracker_jobs), 'imdb_job', PropertyMock())
    TextFieldJob_mock = mocker.patch('upsies.jobs.dialog.TextFieldJob')
    mocker.patch.object(ptp_tracker_jobs, 'get_job_name')
    mocker.patch.object(ptp_tracker_jobs, 'make_precondition')
    mocker.patch.object(ptp_tracker_jobs, 'get_job_and_dependencies')
    mocker.patch.object(ptp_tracker_jobs, 'common_job_args', return_value={'common_job_arg': 'common job argument'})

    assert ptp_tracker_jobs.plot_job is TextFieldJob_mock.return_value
    assert TextFieldJob_mock.call_args_list == [call(
        name=ptp_tracker_jobs.get_job_name.return_value,
        label='Plot',
        precondition=ptp_tracker_jobs.make_precondition.return_value,
        prejobs=ptp_tracker_jobs.get_job_and_dependencies.return_value,
        text=ptp_tracker_jobs.fetch_plot,
        normalizer=ptp_tracker_jobs.normalize_plot,
        validator=ptp_tracker_jobs.validate_plot,
        finish_on_success=True,
        common_job_arg='common job argument',
    )]
    assert ptp_tracker_jobs.get_job_name.call_args_list == [call('plot')]
    assert ptp_tracker_jobs.make_precondition.call_args_list == [call(
        'plot_job',
        precondition=ptp_tracker_jobs.ptp_group_does_not_exist,
    )]
    assert ptp_tracker_jobs.get_job_and_dependencies.call_args_list == [call(
        ptp_tracker_jobs.ptp_group_id_job,
    )]
    assert ptp_tracker_jobs.common_job_args.call_args_list == [call()]


@pytest.mark.parametrize(
    argnames='imdb_job, exp_exception',
    argvalues=(
        (Mock(is_finished=False), AssertionError),
        (Mock(is_finished=True, output=['123']), None),
    ),
    ids=lambda v: str(v),
)
async def test_fetch_plot(imdb_job, exp_exception, ptp_tracker_jobs, mocker):
    mocker.patch.object(type(ptp_tracker_jobs), 'imdb_job', PropertyMock(return_value=imdb_job))
    mocker.patch.object(ptp_tracker_jobs.tracker, 'get_ptp_metadata', AsyncMock())

    if exp_exception:
        with pytest.raises(exp_exception) as exception_info:
            await ptp_tracker_jobs.fetch_plot()
        assert exception_info.value.args[0] is ptp_tracker_jobs.imdb_job
    else:
        return_value = await ptp_tracker_jobs.fetch_plot()
        assert return_value == ptp_tracker_jobs.tracker.get_ptp_metadata.return_value
        ptp_tracker_jobs.tracker.get_ptp_metadata.call_args_list == [
            call(ptp_tracker_jobs.imdb_id, key='plot'),
        ]


@pytest.mark.parametrize(
    argnames='text, exp_plot',
    argvalues=(
        ('The plot.', 'The plot.'),
        (' The plot.', 'The plot.'),
        (' The plot.  ', 'The plot.'),
        ('\nThe plot.\t', 'The plot.'),
    ),
    ids=lambda v: str(v),
)
def test_normalize_plot(text, exp_plot, ptp_tracker_jobs, mocker):
    return_value = ptp_tracker_jobs.normalize_plot(text)
    assert return_value == exp_plot


@pytest.mark.parametrize(
    argnames='text, exp_exception',
    argvalues=(
        ('', ValueError('Plot must not be empty.')),
        ('not empty', None),
    ),
    ids=lambda v: str(v),
)
async def test_validate_plot(text, exp_exception, ptp_tracker_jobs, mocker):
    if exp_exception:
        with pytest.raises(type(exp_exception), match=rf'^{re.escape(str(exp_exception))}$'):
            ptp_tracker_jobs.validate_plot(text)
    else:
        return_value = ptp_tracker_jobs.validate_plot(text)
        assert return_value is None


def test_source_job(ptp_tracker_jobs, mocker):
    TextFieldJob_mock = mocker.patch('upsies.jobs.dialog.TextFieldJob')
    mocker.patch.object(ptp_tracker_jobs, 'get_job_name')
    mocker.patch.object(ptp_tracker_jobs, 'make_precondition')
    mocker.patch.object(ptp_tracker_jobs, 'common_job_args', return_value={'common_job_arg': 'common job argument'})

    assert ptp_tracker_jobs.source_job is TextFieldJob_mock.return_value
    assert TextFieldJob_mock.call_args_list == [call(
        name=ptp_tracker_jobs.get_job_name.return_value,
        label='Source',
        precondition=ptp_tracker_jobs.make_precondition.return_value,
        text=ptp_tracker_jobs.autodetect_source,
        normalizer=ptp_tracker_jobs.normalize_source,
        validator=ptp_tracker_jobs.validate_source,
        finish_on_success=True,
        common_job_arg='common job argument',
    )]
    assert ptp_tracker_jobs.get_job_name.call_args_list == [call('source')]
    assert ptp_tracker_jobs.make_precondition.call_args_list == [call('source_job')]
    assert ptp_tracker_jobs.common_job_args.call_args_list == [call()]


@pytest.mark.parametrize(
    argnames='release_name_source, exp_source, exp_source_job_text',
    argvalues=(
        ('BluRay', 'Blu-ray', None),
        ('DVD', 'DVD', None),
        ('DVDRip', 'DVD', None),
        ('WEB', 'WEB', None),
        ('WEB-DL', 'WEB', None),
        ('WEBDL', 'WEB', None),
        ('WEBRip', 'WEB', None),
        ('HD-DVD', 'HD-DVD', None),
        ('HDDVD', 'HD-DVD', None),
        ('HD-TV', 'HDTV', None),
        ('HDTV', 'HDTV', None),
        ('TV', 'TV', None),
        ('VHS', 'VHS', None),
        ('VHSRip', 'VHS', None),
        ('YerMom', None, 'YerMom'),
    ),
    ids=lambda v: str(v),
)
def test_autodetect_source(release_name_source, exp_source, exp_source_job_text, ptp_tracker_jobs, mocker):
    mocker.patch.object(type(ptp_tracker_jobs), 'release_name', PropertyMock(return_value=Mock(
        source=release_name_source,
    )))
    mocker.patch.object(type(ptp_tracker_jobs), 'source_job', PropertyMock(return_value=Mock(
        text='not set',
    )))

    return_value = ptp_tracker_jobs.autodetect_source()
    assert return_value == exp_source
    if exp_source_job_text is not None:
        assert ptp_tracker_jobs.source_job.text == exp_source_job_text
    else:
        assert ptp_tracker_jobs.source_job.text == 'not set'

    def autodetect_source(self):
        # Find autodetected source in valid PTP sources
        source = self.release_name.source
        for known_source, regex in self._known_sources.items():
            if regex.match(source):
                # Finish the job without prompting the user
                return known_source

        # Let the user fix the autodetected source
        self.source_job.text = source


@pytest.mark.parametrize(
    argnames='text, exp_source',
    argvalues=(
        ('blu-ray', 'Blu-ray'),
        ('Blu-ray', 'Blu-ray'),
        ('BLU-RAY', 'Blu-ray'),
        ('YerMom', 'YerMom'),
    ),
    ids=lambda v: str(v),
)
def test_normalize_source(text, exp_source, ptp_tracker_jobs, mocker):
    return_value = ptp_tracker_jobs.normalize_source(text)
    assert return_value == exp_source


@pytest.mark.parametrize(
    argnames='text, exp_exception',
    argvalues=(
        ('', ValueError('You must provide a source.')),
        ('UNKNOWN_SOURCE', ValueError('You must provide a source.')),
        ('KNOWN_SOURCE', ValueError('Source is not valid: KNOWN_SOURCE')),
        ('Blu-ray', None),
    ),
    ids=lambda v: str(v),
)
async def test_validate_source(text, exp_exception, ptp_tracker_jobs, mocker):
    if exp_exception:
        with pytest.raises(type(exp_exception), match=rf'^{re.escape(str(exp_exception))}$'):
            ptp_tracker_jobs.validate_source(text)
    else:
        return_value = ptp_tracker_jobs.validate_source(text)
        assert return_value is None


@pytest.mark.parametrize(
    argnames='ptp_group_id, post_data_common, post_data_add_movie_format, post_data_add_new_movie, exp_post_data',
    argvalues=(
        (
            None,
            {'common': 'data', 'more': 'common values'},
            {'add': 'format', 'common': 'data for adding movie format'},
            {'new': 'movie', 'common': 'data for adding new movie'},
            {'new': 'movie', 'common': 'data for adding new movie', 'more': 'common values'},
        ),
        (
            '123456',
            {'common': 'data', 'more': 'common values'},
            {'add': 'format', 'common': 'data for adding movie format'},
            {'new': 'movie', 'common': 'data for adding new movie'},
            {'add': 'format', 'common': 'data for adding movie format', 'more': 'common values'},
        ),
    ),
    ids=lambda v: str(v),
)
async def test_post_data(
        ptp_group_id,
        post_data_common, post_data_add_movie_format, post_data_add_new_movie,
        exp_post_data,
        ptp_tracker_jobs, mocker,
):
    mocker.patch.object(type(ptp_tracker_jobs), 'ptp_group_id', PropertyMock(return_value=ptp_group_id))
    mocker.patch.object(type(ptp_tracker_jobs), '_post_data_common', PropertyMock(return_value=post_data_common))
    mocker.patch.object(type(ptp_tracker_jobs), '_post_data_add_movie_format', PropertyMock(return_value=post_data_add_movie_format))
    mocker.patch.object(type(ptp_tracker_jobs), '_post_data_add_new_movie', PropertyMock(return_value=post_data_add_new_movie))
    assert ptp_tracker_jobs.post_data == exp_post_data


@pytest.mark.parametrize('not_main_movie, exp_special', ((True, '1'), (False, None)))
@pytest.mark.parametrize('personal_rip, exp_internalrip', ((True, '1'), (False, None)))
@pytest.mark.parametrize('is_scene_release, exp_scene', ((True, '1'), (False, None)))
def test__post_data_common(
        not_main_movie, exp_special,
        personal_rip, exp_internalrip,
        is_scene_release, exp_scene,
        ptp_tracker_jobs, mock_job_attributes, mocker,
):
    mock_job_attributes(ptp_tracker_jobs)

    mocks = Mock()
    mocks.attach_mock(
        mocker.patch.object(ptp_tracker_jobs, 'get_job_attribute', side_effect=(
            '<type>',
            is_scene_release,
        )),
        'get_job_attribute',
    )
    mocks.attach_mock(
        mocker.patch.object(ptp_tracker_jobs, 'get_job_output', side_effect=(
            '<description>',
        )),
        'get_job_output',
    )
    mocker.patch.object(ptp_tracker_jobs, 'read_nfo', Mock(return_value='<nfo text>'))
    mocker.patch.object(type(ptp_tracker_jobs), 'options', PropertyMock(return_value={
        'not_main_movie': not_main_movie,
        'personal_rip': personal_rip,
        'upload_token': '<upload token>',
    }))
    for name in ('source', 'codec', 'container', 'resolution', 'remaster', 'subtitles', 'trumpable'):
        mocker.patch.object(
            type(ptp_tracker_jobs),
            f'_post_data_common_{name}',
            PropertyMock(return_value={name: 'post data'}),
        )

    exp_post_data = {
        'type': '<type>',
        'release_desc': '<description>',
        'special': exp_special,
        'internalrip': exp_internalrip,
        'scene': exp_scene,
        'nfo_text': '<nfo text>',
        'uploadtoken': '<upload token>',
        'source': 'post data',
        'codec': 'post data',
        'container': 'post data',
        'resolution': 'post data',
        'remaster': 'post data',
        'subtitles': 'post data',
        'trumpable': 'post data',
    }

    assert ptp_tracker_jobs._post_data_common == exp_post_data

    assert mocks.mock_calls == [
        call.get_job_attribute(ptp_tracker_jobs.type_job, 'choice'),
        call.get_job_output(ptp_tracker_jobs.description_job, slice=0),
        call.get_job_attribute(ptp_tracker_jobs.scene_check_job, 'is_scene_release'),
    ]


def test__post_data_common_source(ptp_tracker_jobs, mocker):
    mocker.patch.object(ptp_tracker_jobs, 'get_job_output')
    assert ptp_tracker_jobs._post_data_common_source == {
        'source': 'Other',
        'other_source': ptp_tracker_jobs.get_job_output.return_value,
    }
    assert ptp_tracker_jobs.get_job_output.call_args_list == [
        call(ptp_tracker_jobs.source_job, slice=0),
    ]


def test__post_data_common_codec(ptp_tracker_jobs, mocker):
    assert ptp_tracker_jobs._post_data_common_codec == {
        'codec': '* Auto-detect',
        'other_codec': '',
    }


def test__post_data_common_container(ptp_tracker_jobs, mocker):
    assert ptp_tracker_jobs._post_data_common_container == {
        'container': '* Auto-detect',
        'other_container': ''
    }


def test__post_data_common_resolution(ptp_tracker_jobs, mocker):
    assert ptp_tracker_jobs._post_data_common_resolution == {
        'resolution': '* Auto-detect',
        'other_resolution': ''
    }


@pytest.mark.parametrize(
    argnames='subtitles, ptp_subtitles, exp_post_data',
    argvalues=(
        pytest.param(
            [],
            {
                'No Subtitles': 0,
                'la-ta (forced)': 1,
                'la-ta': 2,
                'la (forced)': 3,
                'la': 4,
            },
            {'subtitles[]': [0]},
            id='No subtitles'
        ),
        pytest.param(
            [
                # Subtitles that have PTP codes
                Mock(language='la', territory='ta', forced=True),
                Mock(language='la', territory='ta', forced=False),
                Mock(language='la', territory='', forced=True),
                Mock(language='la', territory='', forced=False),

                # Subtitles that do not have PTP codes
                Mock(language='lx', territory='ta', forced=True),
                Mock(language='lx', territory='ta', forced=False),
                Mock(language='lx', territory='', forced=True),
                Mock(language='lx', territory='', forced=False),
            ],
            {
                'No Subtitles': 0,
                'la-ta (forced)': 1,
                'la-ta': 2,
                'la (forced)': 3,
                'la': 4,
            },
            {'subtitles[]': [1, 2, 3, 4]},
            id='Supported and unsupported subtitles'
        ),
        pytest.param(
            [
                # Subtitles that do not have PTP codes
                Mock(language='lx', territory='ta', forced=True),
                Mock(language='lx', territory='ta', forced=False),
                Mock(language='lx', territory='', forced=True),
                Mock(language='lx', territory='', forced=False),
            ],
            {
                'No Subtitles': 0,
                'la-ta (forced)': 1,
                'la-ta': 2,
                'la (forced)': 3,
                'la': 4,
            },
            {'subtitles[]': []},
            id='Only unsupported subtitles'
        ),
    ),
    ids=lambda v: str(v),
)
def test__post_data_common_subtitles(subtitles, ptp_subtitles, exp_post_data, ptp_tracker_jobs, mocker):
    mocker.patch.object(type(ptp_tracker_jobs), 'subtitles', PropertyMock(return_value=subtitles))
    mocker.patch.dict(ptp.metadata.ptp_subtitles, ptp_subtitles, clear=True)
    assert ptp_tracker_jobs._post_data_common_subtitles == exp_post_data


def test__post_data_common_remaster(ptp_tracker_jobs, mocker):
    assert ptp_tracker_jobs._post_data_common_remaster == {}


@pytest.mark.parametrize(
    argnames='trumpable_job_output, exp_post_data',
    argvalues=(
        ((), {'trumpable[]': []}),
        (
            (
                str(ptp.metadata.PtpTrumpableReason.HARDCODED_SUBTITLES),
            ),
            {
                'trumpable[]': [
                    ptp.metadata.PtpTrumpableReason.HARDCODED_SUBTITLES.value,
                ],
            },
        ),
        (
            (
                str(ptp.metadata.PtpTrumpableReason.NO_ENGLISH_SUBTITLES),
            ),
            {
                'trumpable[]': [
                    ptp.metadata.PtpTrumpableReason.NO_ENGLISH_SUBTITLES.value,
                ],
            },
        ),
        (
            (
                str(ptp.metadata.PtpTrumpableReason.HARDCODED_SUBTITLES),
                str(ptp.metadata.PtpTrumpableReason.NO_ENGLISH_SUBTITLES),
            ),
            {
                'trumpable[]': [
                    ptp.metadata.PtpTrumpableReason.HARDCODED_SUBTITLES.value,
                    ptp.metadata.PtpTrumpableReason.NO_ENGLISH_SUBTITLES.value,
                ],
            },
        ),
    ),
    ids=lambda v: str(v),
)
def test__post_data_common_trumpable(trumpable_job_output, exp_post_data, ptp_tracker_jobs, mocker):
    mocker.patch.object(type(ptp_tracker_jobs), 'trumpable_job', PropertyMock(return_value=Mock(
        output=trumpable_job_output,
    )))
    assert ptp_tracker_jobs._post_data_common_trumpable == exp_post_data


def test__post_data_add_movie_format(ptp_tracker_jobs, mocker):
    mocker.patch.object(type(ptp_tracker_jobs), 'ptp_group_id', PropertyMock(return_value='123456'))
    assert ptp_tracker_jobs._post_data_add_movie_format == {
        'groupid': ptp_tracker_jobs.ptp_group_id,
    }


def test__post_data_add_new_movie(ptp_tracker_jobs, mock_job_attributes, mocker):
    mock_job_attributes(ptp_tracker_jobs)

    mocker.patch.object(type(ptp_tracker_jobs), 'imdb_id', PropertyMock(return_value='tt123456'))

    mocks = Mock()
    mocks.attach_mock(
        mocker.patch.object(ptp_tracker_jobs.tracker, 'normalize_imdb_id', side_effect=lambda id: f'<{id}>'),
        'normalize_imdb_id',
    )
    mocks.attach_mock(
        mocker.patch.object(ptp_tracker_jobs, 'get_job_output', side_effect=(
            '<title>',
            '<year>',
            '<plot>',
            '<tags>',
            '<cover art>',
        )),
        'get_job_output',
    )

    exp_post_data = {
        'imdb': f'<{ptp_tracker_jobs.imdb_id}>',
        'title': '<title>',
        'year': '<year>',
        'album_desc': '<plot>',
        'tags': '<tags>',
        'image': '<cover art>',
    }
    assert ptp_tracker_jobs._post_data_add_new_movie == exp_post_data
    assert mocks.mock_calls == [
        call.normalize_imdb_id(ptp_tracker_jobs.imdb_id),
        call.get_job_output(ptp_tracker_jobs.title_job, slice=0),
        call.get_job_output(ptp_tracker_jobs.year_job, slice=0),
        call.get_job_output(ptp_tracker_jobs.plot_job, slice=0),
        call.get_job_output(ptp_tracker_jobs.tags_job, slice=0),
        call.get_job_output(ptp_tracker_jobs.cover_art_job, slice=0),
    ]
