"""ATS tests: CV reading, matching accuracy properties, and view security."""
import io
import zipfile
from types import SimpleNamespace

from django.core.files.base import ContentFile
from django.test import TestCase
from django.urls import reverse

from accounts.models import User
from ats.cv_parser import extract_skills_from_text, extract_text_from_cv
from ats.matching_engine import (
    candidate_years, dated_years, education_level, find_skills, keywords, score_candidate, stated_years,
)
from ats.services import explain_match_score, process_application_scoring
from companies.models import Company
from jobs.models import Job, JobApplication, JobCategory
from seekers.models import SeekerProfile


def make_job(**kw):
    defaults = dict(title='Accountant', requirements='', responsibilities='', nice_to_have='', description='')
    defaults.update(kw)
    return SimpleNamespace(experience_years_min=defaults.pop('experience_years_min', None),
                           experience_required='', **defaults)


ACCOUNTANT_JOB = make_job(
    title='Accountant',
    requirements='Bachelor degree in Accounting or Finance\nProficiency in Sage Pastel and Excel\n'
                 '3+ years experience in bookkeeping and payroll\nKnowledge of IFRS and taxation',
    responsibilities='Prepare monthly financial reports\nReconcile bank accounts',
)
STRONG_CV = (
    'Jane Banda - Accountant\nBSc Accounting, University of Zambia\n'
    '5 years of experience in bookkeeping, payroll and taxation.\n'
    'Skills: Sage Pastel, Excel, IFRS, financial reporting, bank reconciliation.'
)
WEAK_CV = 'John Phiri - Electrician\nDiploma in Electrical Installation\n6 years of experience wiring buildings.'


class EngineAccuracyTests(TestCase):
    def test_relevant_cv_scores_far_above_irrelevant_cv(self):
        strong = score_candidate(ACCOUNTANT_JOB, STRONG_CV)['score']
        weak = score_candidate(ACCOUNTANT_JOB, WEAK_CV)['score']
        self.assertGreaterEqual(strong, 75)
        self.assertLessEqual(weak, 30)
        self.assertGreater(strong - weak, 45)

    def test_listing_extra_skills_never_lowers_score(self):
        base = score_candidate(ACCOUNTANT_JOB, STRONG_CV)['score']
        more = score_candidate(ACCOUNTANT_JOB, STRONG_CV + '\nPython, Docker, Kubernetes, Figma, Photoshop')['score']
        self.assertGreaterEqual(more, base)

    def test_more_matching_evidence_never_lowers_score(self):
        partial = 'Bookkeeping and payroll for 4 years. Excel.'
        fuller = partial + ' Sage Pastel, IFRS, taxation. BSc Accounting.'
        self.assertGreater(score_candidate(ACCOUNTANT_JOB, fuller)['score'],
                           score_candidate(ACCOUNTANT_JOB, partial)['score'])

    def test_score_is_bounded_and_deterministic(self):
        results = {score_candidate(ACCOUNTANT_JOB, STRONG_CV)['score'] for _ in range(5)}
        self.assertEqual(len(results), 1)
        for text in ('', 'x', STRONG_CV * 50, WEAK_CV):
            self.assertTrue(0 <= score_candidate(ACCOUNTANT_JOB, text)['score'] <= 100)

    def test_no_text_gives_zero_not_a_fake_baseline(self):
        result = score_candidate(ACCOUNTANT_JOB, '')
        self.assertEqual(result['score'], 0)
        self.assertTrue(result['is_baseline'])

    def test_unmeasurable_components_do_not_penalise(self):
        job = make_job(title='Cleaner', requirements='Honest and hardworking')
        result = score_candidate(job, 'Honest and hardworking cleaner with references.')
        keys = {c['key'] for c in result['components']}
        self.assertNotIn('experience', keys)
        self.assertNotIn('education', keys)
        self.assertGreaterEqual(result['score'], 60)

    def test_experience_shortfall_reduces_score_proportionally(self):
        job = make_job(title='Sales Manager', requirements='Sales experience', experience_years_min=10)
        low = score_candidate(job, 'Sales manager. 2 years of experience in sales.')
        high = score_candidate(job, 'Sales manager. 12 years of experience in sales.')
        self.assertGreater(high['score'], low['score'])
        exp = next(c for c in low['components'] if c['key'] == 'experience')
        self.assertEqual(exp['score'], 20)

    def test_education_gap_scoring(self):
        job = make_job(title='Analyst', requirements='Bachelor degree required. Data analysis.')
        has = score_candidate(job, 'BSc in Statistics. Data analysis.')
        lacks = score_candidate(job, 'Certificate in computing. Data analysis.')
        edu_has = next(c for c in has['components'] if c['key'] == 'education')['score']
        edu_lacks = next(c for c in lacks['components'] if c['key'] == 'education')['score']
        self.assertEqual(edu_has, 100)
        self.assertLess(edu_lacks, 100)


class ExtractionTests(TestCase):
    def test_special_character_skills_are_detected(self):
        skills = extract_skills_from_text('Built services in C++, C# and .NET with CI/CD and Node.js')
        for expected in ('C++', 'C#', '.NET', 'CI/CD', 'Node.js'):
            self.assertIn(expected, skills)

    def test_short_abbreviations_need_capitals(self):
        self.assertNotIn('HR', extract_skills_from_text('the hr of this wall'))
        self.assertIn('HR', extract_skills_from_text('Worked in HR for 4 years'))
        self.assertNotIn('SAP', extract_skills_from_text('the sap of the tree'))

    def test_skill_names_do_not_match_inside_other_words(self):
        self.assertNotIn('Java', find_skills('javascript only') and [])  # sanity
        self.assertEqual(find_skills('I like javanese food'), [])
        self.assertEqual(find_skills('Salesforce'), ['crm'])

    def test_stemming_matches_word_forms(self):
        self.assertEqual(keywords('managing accounts') & keywords('Account Manager'), {'account', 'leadx'})

    def test_years_detection(self):
        self.assertEqual(stated_years('over 5+ years of experience in sales'), 5)
        self.assertEqual(stated_years('experience of 3 years'), 3)
        self.assertEqual(stated_years('the year 2020 was long'), 0)
        self.assertEqual(dated_years('Grade 10 - 12 | 2012 - 2014\nCashier Jan 2018 - Mar 2020'), 2)
        self.assertEqual(dated_years('Rustenburg | September 2021 to December 2024 (3 years, 3 months)'), 3)
        self.assertEqual(dated_years('Analyst 2015 - 2019\nBSc University 2010-2014\nManager 2019 - Present'), 12)
        self.assertEqual(candidate_years('nothing here', profile_years=4), 4)

    def test_education_detection(self):
        self.assertEqual(education_level('MBA from UNZA'), 4)
        self.assertEqual(education_level('Diploma in HR'), 2)
        self.assertEqual(education_level('BSc Accounting'), 3)
        self.assertEqual(education_level('Grade 12 certificate'), 1)
        self.assertEqual(education_level('PhD in Physics'), 5)

    def test_file_formats_are_read(self):
        from docx import Document
        buf = io.BytesIO()
        doc = Document()
        doc.add_paragraph('Accountant with Sage Pastel experience')
        table = doc.add_table(rows=1, cols=2)
        table.rows[0].cells[0].text = 'Skills'
        table.rows[0].cells[1].text = 'IFRS and payroll'
        doc.save(buf)

        txt = ContentFile(b'Payroll and IFRS specialist', name='cv.txt')
        docx = ContentFile(buf.getvalue(), name='cv.docx')
        mislabeled = ContentFile(buf.getvalue(), name='cv.pdf')  # a .docx wearing a .pdf name
        self.assertIn('IFRS', extract_text_from_cv(txt))
        text = extract_text_from_cv(docx)
        self.assertIn('Sage Pastel', text)
        self.assertIn('IFRS and payroll', text)
        self.assertIn('Sage Pastel', extract_text_from_cv(mislabeled))

    def test_pdf_is_read(self):
        content = b'BT /F1 12 Tf 72 720 Td (Senior Accountant with IFRS and Payroll) Tj ET'
        objs = [b'<< /Type /Catalog /Pages 2 0 R >>', b'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
                b'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R '
                b'/Resources << /Font << /F1 5 0 R >> >> >>',
                b'<< /Length %d >>\nstream\n' % len(content) + content + b'\nendstream',
                b'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>']
        pdf, offsets = b'%PDF-1.4\n', []
        for i, obj in enumerate(objs, 1):
            offsets.append(len(pdf))
            pdf += b'%d 0 obj\n' % i + obj + b'\nendobj\n'
        xref = len(pdf)
        pdf += b'xref\n0 %d\n0000000000 65535 f \n' % (len(objs) + 1)
        for off in offsets:
            pdf += b'%010d 00000 n \n' % off
        pdf += b'trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF' % (len(objs) + 1, xref)
        text = extract_text_from_cv(ContentFile(pdf, name='cv.pdf'))
        self.assertIn('IFRS', text)

    def test_unreadable_files_return_empty_not_error(self):
        self.assertEqual(extract_text_from_cv(ContentFile(b'', name='empty.pdf')), '')
        self.assertEqual(extract_text_from_cv(ContentFile(b'%PDF-broken', name='bad.pdf')), '')
        buf = io.BytesIO()
        with zipfile.ZipFile(buf, 'w') as z:
            z.writestr('random.txt', 'x')
        self.assertEqual(extract_text_from_cv(ContentFile(buf.getvalue(), name='odd.docx')).strip(), '')


class ApplicationScoringTests(TestCase):
    def setUp(self):
        self.employer = User.objects.create_user(username='emp', email='emp@x.com', password='pw12345!', role=User.Role.COMPANY)
        self.other_employer = User.objects.create_user(username='emp2', email='emp2@x.com', password='pw12345!', role=User.Role.COMPANY)
        Company.objects.create(slug='c1', name='C1', initials='C', industry='Finance', industry_slug='finance',
                               location='Lusaka', owner=self.employer)
        cat = JobCategory.objects.create(name='Finance', slug='finance')
        self.job = Job.objects.create(
            title='Accountant', slug='accountant-x', company_name='C1', company_slug='c1', company_initials='C',
            location='Lusaka', category=cat, description='Accountant needed', posted_by=self.employer, is_active=True,
            requirements=ACCOUNTANT_JOB.requirements, responsibilities=ACCOUNTANT_JOB.responsibilities,
        )
        self.seeker = User.objects.create_user(username='sk', email='sk@x.com', password='pw12345!', role=User.Role.SEEKER,
                                               first_name='Jane', last_name='Banda')
        self.profile = SeekerProfile.objects.create(
            user=self.seeker, slug='jane-banda', full_name='Jane Banda', headline='Accountant', category='Finance',
            category_slug='finance', location='Lusaka', skills='Sage Pastel\nExcel\nIFRS', years_experience=5,
            qualification='BSc Accounting', is_public=True,
        )

    def _apply(self, cv_bytes=None, name='cv.txt', cover=''):
        app = JobApplication(job=self.job, applicant=self.seeker, cover_letter=cover)
        if cv_bytes is not None:
            app.cv_file.save(name, ContentFile(cv_bytes), save=False)
        app.save()
        app.refresh_from_db()
        return app

    def test_end_to_end_score_uses_cv_content(self):
        good = self._apply(STRONG_CV.encode())
        self.assertGreaterEqual(good.match_score, 70)
        self.assertIn('Sage Pastel', good.cv_extracted_skills)

    def test_irrelevant_cv_scores_low_even_with_relevant_profile_removed(self):
        self.profile.skills = ''
        self.profile.headline = 'Electrician'
        self.profile.qualification = ''
        self.profile.years_experience = 0
        self.profile.save()
        bad = self._apply(WEAK_CV.encode())
        self.assertLessEqual(bad.match_score, 35)

    def test_stored_score_equals_displayed_score(self):
        app = self._apply(STRONG_CV.encode())
        self.assertEqual(app.match_score, explain_match_score(app)['score'])
        process_application_scoring(app, parse_content=True)
        app.refresh_from_db()
        self.assertEqual(app.match_score, explain_match_score(app)['score'])

    def test_unreadable_cv_is_flagged(self):
        self.profile.skills = ''
        self.profile.save()
        app = self._apply(b'%PDF-1.4 not really a pdf', name='scan.pdf')
        self.assertTrue(explain_match_score(app)['cv_unreadable'])

    def test_profile_update_rescores_applications(self):
        self.profile.skills = ''
        self.profile.headline = 'Driver'
        self.profile.qualification = ''
        self.profile.years_experience = 0
        self.profile.save()
        app = self._apply(cover='I drive trucks.')
        before = app.match_score
        self.profile.skills = 'Sage Pastel\nExcel\nIFRS\nPayroll\nBookkeeping\nTaxation'
        self.profile.headline = 'Accountant'
        self.profile.save()
        app.refresh_from_db()
        self.assertGreater(app.match_score, before)

    def test_ats_detail_page_shows_breakdown(self):
        app = self._apply(STRONG_CV.encode())
        self.client.force_login(self.employer)
        html = self.client.get(reverse('ats:detail', args=[app.pk])).content.decode()
        self.assertIn('Match breakdown', html)
        self.assertIn('Skills', html)
        self.assertIn('Sage Pastel', html)

    def test_employer_cannot_see_other_employers_applications(self):
        app = self._apply(STRONG_CV.encode())
        self.client.force_login(self.other_employer)
        self.assertEqual(self.client.get(reverse('ats:detail', args=[app.pk])).status_code, 404)
        self.assertEqual(self.client.post(reverse('ats:quick_status', args=[app.pk]), {'status': 'shortlisted'}).status_code, 404)

    def test_csv_export_neutralises_formula_injection(self):
        self.seeker.first_name = '=HYPERLINK("http://evil","x")'
        self.seeker.save()
        self._apply(STRONG_CV.encode())
        self.client.force_login(self.employer)
        csv_text = self.client.get(reverse('ats:export')).content.decode()
        self.assertNotIn(',=HYPERLINK', csv_text)
        self.assertIn("'=HYPERLINK", csv_text)

    def test_quick_status_rejects_offsite_redirect(self):
        app = self._apply(STRONG_CV.encode())
        self.client.force_login(self.employer)
        response = self.client.post(reverse('ats:quick_status', args=[app.pk]),
                                    {'status': 'shortlisted', 'next': 'https://evil.example/steal'})
        self.assertTrue(response.url.startswith('/ats/') or response.url.startswith(reverse('ats:detail', args=[app.pk])))
        response = self.client.post(reverse('ats:quick_status', args=[app.pk]),
                                    {'status': 'shortlisted', 'next': '//evil.example'})
        self.assertNotIn('evil', response.url)

    def test_bulk_status_only_touches_own_applications(self):
        app = self._apply(STRONG_CV.encode())
        self.client.force_login(self.other_employer)
        self.client.post(reverse('ats:bulk_update'), {'application_ids': str(app.pk), 'status': 'rejected'})
        app.refresh_from_db()
        self.assertNotEqual(app.status, 'rejected')


class PipelineJobBoardTests(ApplicationScoringTests):
    def _second_job(self):
        cat = JobCategory.objects.get(slug='finance')
        return Job.objects.create(
            title='Payroll Clerk', slug='payroll-clerk-x', company_name='C1', company_slug='c1', company_initials='C',
            location='Lusaka', category=cat, description='Payroll clerk', requirements='Payroll and Excel',
            posted_by=self.employer, is_active=True,
        )

    def test_board_groups_applicants_per_job_with_stats(self):
        self._apply(STRONG_CV.encode())
        other = self._second_job()
        second_seeker = User.objects.create_user(username='sk2', email='sk2@x.com', password='pw12345!', role=User.Role.SEEKER)
        SeekerProfile.objects.create(user=second_seeker, slug='p2', full_name='Pat Two', headline='Driver', category='x',
                                     category_slug='x', location='L', skills='Driving', is_public=True)
        JobApplication.objects.create(job=other, applicant=second_seeker, cover_letter='I drive trucks')
        self.client.force_login(self.employer)
        response = self.client.get(reverse('ats:pipeline'))
        self.assertTrue(response.context['show_board'])
        boards = {b.slug: b for b in response.context['job_boards']}
        self.assertEqual(set(boards), {self.job.slug, other.slug})
        self.assertEqual(boards[self.job.slug].applicant_count, 1)
        self.assertEqual(boards[other.slug].applicant_count, 1)
        self.assertGreater(boards[self.job.slug].avg_score, boards[other.slug].avg_score)

    def test_opening_a_job_lists_only_its_applicants_best_first(self):
        first = self._apply(STRONG_CV.encode())
        other_seeker = User.objects.create_user(username='sk3', email='sk3@x.com', password='pw12345!', role=User.Role.SEEKER)
        SeekerProfile.objects.create(user=other_seeker, slug='p3', full_name='Low Score', headline='Electrician',
                                     category='x', category_slug='x', location='L', skills='Wiring', is_public=True)
        low = JobApplication.objects.create(job=self.job, applicant=other_seeker, cover_letter='Electrician. Wiring.')
        elsewhere = self._second_job()
        JobApplication.objects.create(job=elsewhere, applicant=self.seeker, cover_letter='Payroll and Excel')
        self.client.force_login(self.employer)
        response = self.client.get(reverse('ats:pipeline'), {'job': self.job.slug})
        self.assertFalse(response.context['show_board'])
        self.assertEqual(response.context['selected_board'].slug, self.job.slug)
        ids = [a.pk for a in response.context['applications']]
        self.assertEqual(ids, [first.pk, low.pk])

    def test_other_employers_jobs_never_appear_on_board(self):
        self._apply(STRONG_CV.encode())
        self.client.force_login(self.other_employer)
        response = self.client.get(reverse('ats:pipeline'))
        self.assertEqual(response.context['job_boards'], [])

    def test_view_all_shows_mixed_list(self):
        self._apply(STRONG_CV.encode())
        self.client.force_login(self.employer)
        response = self.client.get(reverse('ats:pipeline'), {'view': 'all'})
        self.assertFalse(response.context['show_board'])
        self.assertEqual(len(response.context['applications']), 1)


class RealWorldCvRegressionTests(TestCase):
    def test_diploma_or_degree_means_diploma_is_enough(self):
        from ats.matching_engine import _required_education
        self.assertEqual(_required_education('Diploma or degree in business, retail or hospitality'), 2)
        self.assertEqual(_required_education('Bachelor degree required'), 3)
        self.assertEqual(_required_education("Master's degree required, degree in law"), 4)

    def test_numeric_month_date_ranges_are_counted_without_double_counting(self):
        text = 'HEAD OF TELLER 01/2011 - 12/2017\nCUSTOMER SERVICE REP 01/2015 - 12/2019\nSales Agent 08/2022 - Present'
        years = dated_years(text)
        self.assertGreaterEqual(years, 12)
        self.assertLessEqual(years, 14)

    def test_retail_manager_outranks_customer_service_and_unrelated_cv(self):
        job = make_job(
            title='Retail Store Manager',
            requirements='Minimum 3 years experience in retail store management\nDiploma or degree in business, retail or hospitality\n'
                         'Proven ability to lead and motivate a sales team\nStock control and inventory management experience\n'
                         'Budget control and cost management\nKnowledge of merchandising and POS systems',
            experience_years_min=3)
        manager = ('Retail Manager with over 8 years of experience. Branch Manager, Jack N Jill, October 2023 - September 2025. '
                   'Led and motivated sales teams. Stock control and inventory management. Budget control and cost reduction. '
                   'Merchandising and POS. Diploma in Home Economics and Hospitality.')
        service = ('Customer Service Rep 01/2015 - 12/2019. Rental Sales Agent 08/2022 - Present. Diploma in Social Work. '
                   'Handled calls, cash reconciliation and customer queries.')
        electrician = 'Electrician. Diploma in Electrical Installation. 6 years of experience wiring buildings.'
        scores = [score_candidate(job, cv)['score'] for cv in (manager, service, electrician)]
        self.assertGreater(scores[0], scores[1])
        self.assertGreater(scores[1], scores[2])
        self.assertGreaterEqual(scores[0], 75)
        self.assertLessEqual(scores[2], 35)


class MessyCvTypeTests(TestCase):
    def test_referee_blocks_are_not_counted_as_experience(self):
        from ats.matching_engine import strip_noise
        cv = ('Work\nPick n Pay Supervisor\nREFERENCES\nMr. Anthony Chilufya,\nStore Manager,\nPick n Pay,\nLUSAKA.\n'
              '+260 971 588 444\nHOBBIES\nReading')
        cleaned = strip_noise(cv)
        self.assertIn('Supervisor', cleaned)
        self.assertIn('Reading', cleaned)
        self.assertNotIn('Store Manager', cleaned)
        self.assertNotIn('971', cleaned)

    def test_referee_title_does_not_create_a_match(self):
        job = make_job(title='Store Manager', requirements='Store manager experience')
        cv = 'Cleaner at Acme.\nREFEREES\nMR JAKARAN SAIGN\nCHEERS HYPER MARKET\nMANAGER\nCALL: 0971 255385'
        with_refs = score_candidate(job, cv)['score']
        without = score_candidate(job, 'Cleaner at Acme.')['score']
        self.assertEqual(with_refs, without)

    def test_column_layout_text_after_referees_is_kept(self):
        from ats.matching_engine import strip_noise
        cv = ('REFERENCES\nPURITY LUPUPULALA\nMANAGER\nKFC\n+260968996007\n'
              'Apply all rates as per work instructions and procedures or under the supervision of the manager\n'
              'SOCIAL MEDIA PERSONEL 08/2021 - 01/2022')
        cleaned = strip_noise(cv)
        self.assertIn('Apply all rates', cleaned)
        self.assertIn('SOCIAL MEDIA', cleaned)

    def test_years_split_by_pdf_layout_are_repaired(self):
        from ats.matching_engine import normalize_text
        self.assertEqual(normalize_text('FEB 20 13 SEP 2014 JUNE 201 5 - 20 17 JUNE 2 018'),
                         'FEB 2013 SEP 2014 JUNE 2015 - 2017 JUNE 2018')
        self.assertIn('0971 600 300', normalize_text('CALL: 0971 600 300'))

    def test_retail_workers_match_retail_and_leadership_wording(self):
        job = make_job(title='Trainee Manager', requirements='Retail experience preferred\nStrong leadership potential')
        cashier = 'Cashier at Cheers Hyper Market. Merchandiser. Supervisor at Meat Paradise.'
        driver = 'Truck driver. Delivered goods across the Copperbelt.'
        self.assertGreater(score_candidate(job, cashier)['score'], score_candidate(job, driver)['score'] + 30)

    def test_photographed_cv_is_flagged_not_guessed(self):
        from ats.services import explain_match_score as explain
        emp = User.objects.create_user(username='e9', email='e9@x.com', password='pw12345!', role=User.Role.COMPANY)
        Company.objects.create(slug='c9', name='C9', initials='C', industry='Retail', industry_slug='retail',
                               location='Lusaka', owner=emp)
        cat = JobCategory.objects.create(name='Retail9', slug='retail9')
        job = Job.objects.create(title='Trainee Manager', slug='tm9', company_name='C9', company_slug='c9', company_initials='C',
                                 location='Lusaka', category=cat, description='Retail', requirements='Retail experience',
                                 posted_by=emp, is_active=True)
        seeker = User.objects.create_user(username='s9', email='s9@x.com', password='pw12345!', role=User.Role.SEEKER)
        SeekerProfile.objects.create(user=seeker, slug='s9', full_name='S Nine', headline='Baker', category='x',
                                     category_slug='x', location='L', skills='', is_public=True)
        blank_pdf = (b'%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\n'
                     b'endobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF')
        app = JobApplication(job=job, applicant=seeker)
        app.cv_file.save('photo.pdf', ContentFile(blank_pdf), save=False)
        app.save()
        result = explain(app)
        self.assertTrue(result['cv_unreadable'])
        self.assertTrue(result['cv_issue'])


class AdminPipelineJobBoardTests(ApplicationScoringTests):
    def test_admin_board_and_job_list_are_separated_and_ranked(self):
        admin = User.objects.create_user(username='adm', email='adm@x.com', password='pw12345!', role=User.Role.ADMIN)
        first = self._apply(STRONG_CV.encode())
        other_seeker = User.objects.create_user(username='sk4', email='sk4@x.com', password='pw12345!', role=User.Role.SEEKER)
        SeekerProfile.objects.create(user=other_seeker, slug='p4', full_name='Low', headline='Driver', category='x',
                                     category_slug='x', location='L', skills='Driving', is_public=True)
        low = JobApplication.objects.create(job=self.job, applicant=other_seeker, cover_letter='I drive trucks')
        self.client.force_login(admin)
        board = self.client.get(reverse('platform_admin:ats'))
        self.assertTrue(board.context['show_board'])
        self.assertEqual([b.slug for b in board.context['job_boards']], [self.job.slug])
        self.assertEqual(board.context['job_boards'][0].applicant_count, 2)
        listing = self.client.get(reverse('platform_admin:ats'), {'job': self.job.slug})
        self.assertFalse(listing.context['show_board'])
        self.assertEqual([a.pk for a in listing.context['page_obj']], [first.pk, low.pk])
        everyone = self.client.get(reverse('platform_admin:ats'), {'view': 'all'})
        self.assertFalse(everyone.context['show_board'])


class JobBoardPaginationTests(ApplicationScoringTests):
    def test_board_is_paginated_nine_per_page_in_both_pipelines(self):
        cat = JobCategory.objects.get(slug='finance')
        for i in range(10):
            job = Job.objects.create(
                title=f'Role {i}', slug=f'role-{i}', company_name='C1', company_slug='c1', company_initials='C',
                location='Lusaka', category=cat, description='d', requirements='Excel', posted_by=self.employer, is_active=True)
            JobApplication.objects.create(job=job, applicant=self.seeker, cover_letter='Excel')
        self._apply(STRONG_CV.encode())
        admin = User.objects.create_user(username='adm2', email='adm2@x.com', password='pw12345!', role=User.Role.ADMIN)
        for user, url in ((self.employer, reverse('ats:pipeline')), (admin, reverse('platform_admin:ats'))):
            self.client.force_login(user)
            first = self.client.get(url)
            self.assertEqual(len(first.context['board_page']), 9)
            self.assertEqual(first.context['board_page'].paginator.num_pages, 2)
            self.assertContains(first, '?page=2')
            second = self.client.get(url, {'page': 2})
            self.assertEqual(len(second.context['board_page']), 2)
            self.assertTrue(second.context['show_board'])


class AtsActionFlowTests(ApplicationScoringTests):
    def test_full_review_flow_note_rating_status_interview_and_bulk(self):
        import datetime

        from django.utils import timezone

        from accounts.models import UserNotification

        app = self._apply(STRONG_CV.encode())
        self.client.force_login(self.employer)
        self.client.post(reverse('ats:add_note', args=[app.pk]), {'body': 'Strong candidate'})
        self.assertEqual(app.notes.count(), 1)
        self.client.post(reverse('ats:update_status', args=[app.pk]), {'status': 'review', 'rating': '4'})
        app.refresh_from_db()
        self.assertEqual((app.status, app.rating), ('review', 4))
        when = (timezone.now() + datetime.timedelta(days=2)).strftime('%Y-%m-%dT%H:%M')
        response = self.client.post(reverse('ats:schedule_interview', args=[app.pk]),
                                    {'scheduled_at': when, 'location': 'Head office', 'meeting_url': '', 'notes': '', 'status': 'scheduled'})
        self.assertEqual(response.status_code, 302)
        app.refresh_from_db()
        self.assertEqual(app.interviews.count(), 1)
        self.assertEqual(app.status, 'interview')
        self.assertTrue(UserNotification.objects.filter(recipient=self.seeker, notification_type='interview').exists())
        self.client.post(reverse('ats:bulk_update'), {'application_ids': str(app.pk), 'status': 'hired'})
        app.refresh_from_db()
        self.assertEqual(app.status, 'hired')
        self.assertGreaterEqual(app.events.count(), 5)
        html = self.client.get(reverse('ats:detail', args=[app.pk])).content.decode()
        self.assertIn('Strong candidate', html)
        self.assertIn('Head office', html)


class AdminStatusUpdateTests(ApplicationScoringTests):
    def test_admin_can_change_status_and_rating_from_the_application_page(self):
        admin = User.objects.create_user(username='sadm', email='sadm@x.com', password='pw12345!', role=User.Role.ADMIN)
        app = self._apply(STRONG_CV.encode())
        self.client.force_login(admin)
        self.client.post(reverse('platform_admin:application_detail', args=[app.pk]), {'action': 'status', 'status': 'shortlisted', 'rating': '5'})
        app.refresh_from_db()
        self.assertEqual((app.status, app.rating), ('shortlisted', 5))
        self.assertTrue(app.events.filter(event_type='status_changed', new_value='shortlisted').exists())


class ScannedCvOcrTests(TestCase):
    """Scanned PDFs and photographed CVs are read with OCR."""

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        from PIL import Image, ImageDraw, ImageFont

        img = Image.new('RGB', (1240, 900), 'white')
        draw = ImageDraw.Draw(img)
        font = ImageFont.load_default(size=40)
        for i, line in enumerate([
            'Accountant with 6 years experience',
            'Skills: bookkeeping, payroll, taxation, auditing',
            'Email: jane.phiri@example.com',
        ]):
            draw.text((80, 100 + i * 80), line, font=font, fill='black')
        from io import BytesIO
        buf = BytesIO()
        img.save(buf, 'JPEG', quality=80)
        cls.jpg = buf.getvalue()
        import pymupdf
        doc = pymupdf.open()
        page = doc.new_page(width=595, height=420)
        page.insert_image(page.rect, stream=cls.jpg)
        cls.pdf = doc.tobytes()

    def _field(self, name, data):
        from django.core.files.base import ContentFile
        from django.core.files.storage import default_storage
        saved = default_storage.save('test_ocr/' + name, ContentFile(data))
        self.addCleanup(default_storage.delete, saved)
        return default_storage.open(saved)

    def test_scanned_pdf_is_read(self):
        from ats.cv_parser import extract_text_from_cv
        text = extract_text_from_cv(self._field('scan.pdf', self.pdf)).lower()
        self.assertIn('payroll', text)
        self.assertIn('jane.phiri@example.com', text)

    def test_photo_cv_is_read_even_with_wrong_extension(self):
        from ats.cv_parser import extract_text_from_cv
        text = extract_text_from_cv(self._field('scan.pdf.txt', self.jpg)).lower()
        self.assertIn('bookkeeping', text)

    def test_text_pdf_does_not_use_ocr(self):
        from unittest import mock
        from ats import ocr
        with mock.patch.object(ocr, 'ocr_bytes') as fake:
            import pymupdf
            doc = pymupdf.open()
            doc.new_page().insert_text((72, 100), 'Experienced accountant skilled in payroll, taxation, auditing and budgeting for many years.')
            from ats.cv_parser import extract_text_from_cv
            extract_text_from_cv(self._field('text.pdf', doc.tobytes()))
            fake.assert_not_called()
