"""Deterministic, explainable CV-to-job matching engine.

Every score is built from checkable evidence found in the CV/profile text:
  skills       - job skills (dictionary-based) that the candidate demonstrably has
  requirements - how much of each requirement line's meaningful vocabulary appears in the CV
  title        - overlap between the job title and the candidate's CV/headline
  experience   - candidate years vs. years the job asks for
  education    - candidate qualification level vs. level the job asks for
Components that cannot be evaluated (e.g. the job states no experience) are left out and the
remaining weights are re-normalised, so a candidate is never penalised for missing job data.
"""
import re
import unicodedata

# ── Skill dictionary ────────────────────────────────────────────────────────
# canonical -> (display, aliases, case_sensitive)
_SKILL_SPECS = [
    ('python', 'Python', ['python']), ('java', 'Java', ['java']),
    ('javascript', 'JavaScript', ['javascript', 'js', 'ecmascript']),
    ('typescript', 'TypeScript', ['typescript']),
    ('react', 'React', ['react', 'reactjs', 'react.js']), ('angular', 'Angular', ['angular', 'angularjs']),
    ('vue', 'Vue', ['vue', 'vuejs', 'vue.js']), ('django', 'Django', ['django']), ('flask', 'Flask', ['flask']),
    ('node', 'Node.js', ['node.js', 'nodejs', 'node js']),
    ('sql', 'SQL', ['sql', 'structured query language']), ('mysql', 'MySQL', ['mysql']),
    ('postgresql', 'PostgreSQL', ['postgresql', 'postgres']), ('mongodb', 'MongoDB', ['mongodb']),
    ('aws', 'AWS', ['aws', 'amazon web services']), ('azure', 'Azure', ['azure']),
    ('docker', 'Docker', ['docker']), ('kubernetes', 'Kubernetes', ['kubernetes', 'k8s']),
    ('git', 'Git', ['git', 'github', 'gitlab']), ('linux', 'Linux', ['linux', 'ubuntu']),
    ('excel', 'Excel', ['excel', 'ms excel', 'microsoft excel']),
    ('word', 'Microsoft Word', ['ms word', 'microsoft word']),
    ('powerpoint', 'PowerPoint', ['powerpoint', 'ms powerpoint', 'microsoft powerpoint']),
    ('msoffice', 'Microsoft Office', ['microsoft office', 'ms office', 'office 365', 'ms-office']),
    ('project management', 'Project Management', ['project management', 'project manager', 'pmp', 'prince2']),
    ('communication', 'Communication', ['communication', 'communication skills']),
    ('leadership', 'Leadership', ['leadership', 'team leadership']),
    ('teamwork', 'Teamwork', ['teamwork', 'team work', 'team player']),
    ('problem solving', 'Problem Solving', ['problem solving', 'problem-solving']),
    ('customer service', 'Customer Service', ['customer service', 'customer care', 'client service']),
    ('sales', 'Sales', ['sales', 'selling']), ('marketing', 'Marketing', ['marketing']),
    ('accounting', 'Accounting', ['accounting', 'accountancy', 'accounts']),
    ('finance', 'Finance', ['finance', 'financial']),
    ('bookkeeping', 'Bookkeeping', ['bookkeeping', 'book keeping', 'book-keeping']),
    ('payroll', 'Payroll', ['payroll']), ('recruitment', 'Recruitment', ['recruitment', 'recruiting', 'talent acquisition']),
    ('hr', 'HR', ['hr', 'human resources', 'human resource', 'human resource management']),
    ('data analysis', 'Data Analysis', ['data analysis', 'data analytics', 'analytics', 'data analyst']),
    ('machine learning', 'Machine Learning', ['machine learning', 'ml']),
    ('ai', 'AI', ['ai', 'artificial intelligence']),
    ('html', 'HTML', ['html', 'html5']), ('css', 'CSS', ['css', 'css3']), ('php', 'PHP', ['php']),
    ('laravel', 'Laravel', ['laravel']), ('spring', 'Spring', ['spring boot', 'spring framework']),
    ('c++', 'C++', ['c++']), ('c#', 'C#', ['c#', 'c sharp']), ('.net', '.NET', ['.net', 'dotnet', 'asp.net', 'dot net']),
    ('ruby', 'Ruby', ['ruby', 'ruby on rails']), ('swift', 'Swift', ['swift ios', 'swiftui']),
    ('kotlin', 'Kotlin', ['kotlin']), ('android', 'Android', ['android']), ('ios', 'iOS', ['ios']),
    ('figma', 'Figma', ['figma']), ('photoshop', 'Photoshop', ['photoshop']),
    ('illustrator', 'Illustrator', ['illustrator']), ('seo', 'SEO', ['seo', 'search engine optimization', 'search engine optimisation']),
    ('content writing', 'Content Writing', ['content writing', 'copywriting', 'content creation']),
    ('negotiation', 'Negotiation', ['negotiation', 'negotiating']), ('budgeting', 'Budgeting', ['budgeting', 'budget management']),
    ('forecasting', 'Forecasting', ['forecasting']), ('audit', 'Audit', ['audit', 'auditing', 'internal audit']),
    ('taxation', 'Taxation', ['taxation', 'tax compliance']), ('ifrs', 'IFRS', ['ifrs']),
    ('supply chain', 'Supply Chain', ['supply chain']), ('logistics', 'Logistics', ['logistics']),
    ('procurement', 'Procurement', ['procurement', 'purchasing']),
    ('quality assurance', 'Quality Assurance', ['quality assurance', 'qa', 'quality control']),
    ('agile', 'Agile', ['agile']), ('scrum', 'Scrum', ['scrum']), ('jira', 'Jira', ['jira']),
    ('confluence', 'Confluence', ['confluence']), ('sap', 'SAP', ['sap']), ('oracle', 'Oracle', ['oracle']),
    ('quickbooks', 'QuickBooks', ['quickbooks']), ('sage', 'Sage', ['sage', 'sage pastel', 'pastel']),
    ('networking', 'Networking', ['networking', 'computer networks', 'lan/wan']),
    ('cybersecurity', 'Cybersecurity', ['cybersecurity', 'cyber security', 'information security']),
    ('cloud computing', 'Cloud Computing', ['cloud computing', 'cloud']), ('devops', 'DevOps', ['devops']),
    ('ci/cd', 'CI/CD', ['ci/cd', 'ci cd', 'continuous integration']),
    ('civil engineering', 'Civil Engineering', ['civil engineering']),
    ('electrical engineering', 'Electrical Engineering', ['electrical engineering']),
    ('mechanical engineering', 'Mechanical Engineering', ['mechanical engineering']),
    ('autocad', 'AutoCAD', ['autocad']), ('nursing', 'Nursing', ['nursing', 'registered nurse']),
    ('teaching', 'Teaching', ['teaching', 'lecturing']), ('research', 'Research', ['research']),
    ('public speaking', 'Public Speaking', ['public speaking']), ('presentation', 'Presentation', ['presentation', 'presentations']),
    ('stakeholder management', 'Stakeholder Management', ['stakeholder management', 'stakeholder engagement']),
    ('business development', 'Business Development', ['business development']),
    ('crm', 'CRM', ['crm', 'salesforce']), ('erp', 'ERP', ['erp']),
    ('monitoring and evaluation', 'Monitoring & Evaluation', ['monitoring and evaluation', 'm&e', 'monitoring & evaluation']),
    ('report writing', 'Report Writing', ['report writing']), ('time management', 'Time Management', ['time management']),
    ('data entry', 'Data Entry', ['data entry']), ('social media', 'Social Media', ['social media']),
    ('administration', 'Administration', ['administration', 'administrative']),
    ('driving', 'Driving', ['driving licence', 'driving license', 'driver']),
]
_CASE_SENSITIVE = {'hr', 'ai', 'sap', 'javascript', 'machine learning', 'quality assurance'}


def _alias_regex(alias):
    return r'(?<![A-Za-z0-9+#])' + re.escape(alias) + r'(?![A-Za-z+#])'


_SKILL_PATTERNS = []
for _canon, _display, _aliases in _SKILL_SPECS:
    if _canon in _CASE_SENSITIVE:
        # short ambiguous abbreviations (HR, AI, SAP...) only count when written in capitals
        _short = [a.upper() for a in _aliases if len(a) <= 3]
        _long = [a for a in _aliases if len(a) > 3]
        _regexes = []
        if _short:
            _regexes.append(re.compile('|'.join(_alias_regex(a) for a in _short)))
        if _long:
            _regexes.append(re.compile('|'.join(_alias_regex(a) for a in _long), re.IGNORECASE))
    else:
        _pat = '|'.join(_alias_regex(a) for a in sorted(_aliases, key=len, reverse=True))
        _regexes = [re.compile(_pat, re.IGNORECASE)]
    _SKILL_PATTERNS.append((_canon, _display, _regexes))
SKILL_DISPLAY = {c: d for c, d, _ in _SKILL_PATTERNS}


def normalize_text(text):
    """Clean text pulled out of PDFs/Word files so matching is reliable."""
    if not text:
        return ''
    text = unicodedata.normalize('NFKC', str(text))
    text = text.replace('­', '').replace(' ', ' ')
    text = re.sub(r'(\w)-\n(\w)', r'\1\2', text)           # re-join hyphenated line breaks
    text = re.sub(r'[•●▪‣◦⁃]', '\n', text)  # bullets
    text = re.sub(r'[ \t]+', ' ', text)
    # PDF timelines often split years by spaces: "20 13", "201 5", "2 018"
    text = re.sub(r'(?<![\d/])(19|20) (\d\d)(?!\d)', r'\1\2', text)
    text = re.sub(r'(?<![\d/])(20\d) (\d)(?!\d)', r'\1\2', text)
    text = re.sub(r'(?<![\d/])(2) (0\d\d)(?!\d)', r'\1\2', text)
    return text.strip()


_PHONE = re.compile(r'(?:\+?260|\b0)\s?[79]\d[\d\s-]{6,}')
_TITLE = re.compile(r'^(?:mr|mrs|ms|miss|dr|prof)\.?\s', re.I)
_REF_HEAD = re.compile(r'^\W*(?:references?|referees?)\W*$', re.I)
_SECTION_HEAD = re.compile(
    r'^\W*(?:work|employment|experience|education|skills?|hobbies|interests|abilities|summary|profile|objective|'
    r'professional|personal|additional|languages?|achievements?|certificat\w*|training|projects?|declaration)\b', re.I)
_ID_LINE = re.compile(r'\b(?:nrc|n\.r\.c|passport|tpin|nrc no)\b', re.I)


def strip_noise(text):
    """Drop referee blocks, phone numbers and ID lines: their job titles are not the candidate's experience."""
    out, skipping = [], False
    for line in (text or '').split('\n'):
        stripped = line.strip()
        if _REF_HEAD.match(stripped):
            skipping = True
            continue
        if skipping:
            if _SECTION_HEAD.match(stripped):
                skipping = False
            elif len(stripped) <= 50 or _PHONE.search(stripped) or _TITLE.match(stripped):
                continue
            else:
                skipping = False
        if _PHONE.search(stripped) or _ID_LINE.search(stripped) or _TITLE.match(stripped) and len(stripped) <= 60:
            continue
        out.append(line)
    return '\n'.join(out)


def find_skills(text):
    """Return list of canonical skill keys found in text."""
    text = normalize_text(text)
    if not text:
        return []
    return [canon for canon, _display, regexes in _SKILL_PATTERNS if any(r.search(text) for r in regexes)]


# ── Vocabulary helpers ──────────────────────────────────────────────────────
_STOP = set('''a an the and or of in on at to for with by from as is are was were be been being this that these those
it its into over under about across per via than then also etc including include includes ability able strong good
excellent knowledge skill skills experience experienced work working works required requirement requirements must
should will can may have has had having minimum least years year plus proven demonstrated relevant related similar
preferably preferred desirable candidate candidates applicant applicants position positions role roles job jobs
vacancy vacancies multiple wanted urgent needed hiring invitation register various opportunity available company
team member members duties responsibilities responsible ensure ensuring support supporting within our your their
you we they who which where when while other others any all each every well both new use using used make making
demonstrate hold holds holder level basic advanced sound solid high higher degree'''.split())


# Words that mean the same thing for hiring purposes. Both the job and the CV go through this map,
# so a "supermarket cashier" matches "retail experience" and a "supervisor" shows "leadership".
_SYNONYM_GROUPS = {
    'retailx': 'retail supermarket hypermarket grocery groceries shoprite spar checkers choppies cashier merchandiser merchandising shelf shelves till',
    'leadx': 'leadership leader lead supervisor supervise supervised supervising supervision manager management managed managing coordinate coordinated coordinator head headteacher',
    'displayx': 'display displays presentation placement shelving merchandising planogram',
    'fifox': 'fifo perishable perishables expiry expiries expired rotation',
    'fraudx': 'fraud theft shrinkage loss losses suspicious',
    'escalatex': 'escalation escalations complaint complaints queries query disputes',
    'literacyx': 'literacy literate computer computers ict software',
    'detailx': 'detail accuracy accurate precise careful meticulous',
    'communicatex': 'communication communications communicate communicating communicator',
}
_SYNONYM = {word: key for key, words in _SYNONYM_GROUPS.items() for word in words.split()}
_PHRASES = (('pick n pay', 'supermarket'), ('point of sale', 'pos'), ('hyper market', 'hypermarket'),
            ('super market', 'supermarket'), ('customer service', 'communicatex'), ('front line', 'frontline'))
SOFT_SKILLS = {'communication', 'leadership', 'teamwork', 'problem solving', 'time management', 'presentation',
               'negotiation', 'public speaking', 'stakeholder management'}
_STOP.update('conduct regular correct handle implement reduce potential ideal ambitious professionals professional results driven seeking fill grow industry client clients '
             'good great strong skills skill abilities ability experience experienced'.split())


def _stem(word):
    w = word.lower()
    if len(w) > 4 and w.endswith('ies'):
        w = w[:-3] + 'y'
    elif len(w) > 5 and w.endswith('ing'):
        w = w[:-3]
    elif len(w) > 4 and w.endswith('ed'):
        w = w[:-2]
    elif len(w) > 4 and w.endswith('es'):
        w = w[:-2]
    elif len(w) > 3 and w.endswith('s') and not w.endswith('ss'):
        w = w[:-1]
    for suffix in ('ment', 'ation', 'ant', 'er', 'or', 'ion', 'ity', 'ive', 'ial', 'al'):
        if len(w) - len(suffix) >= 5 and w.endswith(suffix):
            w = w[:-len(suffix)]
            break
    if len(w) > 4 and w[-1] in 'ei':
        w = w[:-1]
    return w


def keywords(text):
    """Set of stemmed, meaningful words in text."""
    out = set()
    lowered = normalize_text(text).lower()
    for phrase, token in _PHRASES:
        lowered = lowered.replace(phrase, ' ' + token + ' ')
    for token in re.findall(r"[a-z][a-z+#.\-]*", lowered):
        token = token.strip('.-')
        if len(token) < 3 or token in _STOP:
            continue
        out.add(_SYNONYM.get(token) or _stem(token))
    return out


# ── Experience & education detection ───────────────────────────────────────
_EDU_LEVELS = [
    (5, 'PhD / Doctorate', r'\b(ph\.?d|doctorate|doctoral)\b'),
    (4, "Master's degree", r"\b(master'?s?|msc|m\.sc|mba|m\.?a\.?\b|postgraduate|post-graduate)\b"),
    (3, "Bachelor's degree", r"\b(bachelor'?s?|bsc|b\.sc|b\.?a\.?\b|beng|b\.eng|degree|honours|undergraduate)\b"),
    (2, 'Diploma', r'\b(diploma|advanced diploma|higher national|hnd)\b'),
    (1, 'Certificate / secondary', r'\b(certificate|grade 12|grade twelve|o[- ]?level|a[- ]?level|matric|secondary school|high school|gce)\b'),
]
_EDU_WORDS = re.compile(r'\b(univ\w*|college|school|bachelor\w*|bsc|msc|diploma|degree|certificate|graduat\w*|gpa|studied|student|grade|primary|secondary|form \d|high|academy|institute|course|training)\b', re.I)


def education_level(text):
    """Highest education level (0-5) mentioned in text."""
    text = normalize_text(text).lower()
    for level, _label, pattern in _EDU_LEVELS:
        if re.search(pattern, text, re.I):
            return level
    return 0


def education_label(level):
    return next((label for lvl, label, _ in _EDU_LEVELS if lvl == level), 'Not stated')


def _required_education(job_text):
    """Level the job asks for. In "Diploma or degree" the lowest alternative is what is required."""
    best = 0
    for line in re.split(r'[\n.;]+', job_text.lower()):
        if not re.search(r'require|must|minimum|hold|holder|qualif|with an?|possess|equivalent|or higher|in\b|degree|diploma', line):
            continue
        levels = [level for level, _label, pattern in _EDU_LEVELS if re.search(pattern, line, re.I)]
        if not levels:
            continue
        needed = min(levels) if re.search(r'\bor\b|/|equivalent', line) else max(levels)
        best = max(best, needed)
    return best


_YEARS_PATTERNS = [
    re.compile(r'(\d{1,2})\s*\+?\s*(?:-|–|to)?\s*(?:\d{1,2}\s*)?(?:years?|yrs?)\b[^.\n]{0,40}?experience', re.I),
    re.compile(r'experience[^.\n]{0,40}?(\d{1,2})\s*\+?\s*(?:years?|yrs?)\b', re.I),
    re.compile(r'(\d{1,2})\s*\+\s*(?:years?|yrs?)\b', re.I),
]
_MONTHS = {m: i for i, m in enumerate(
    ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'], 1)}
_END = r'(?:present|current|date|now|ongoing|till date|to date)'
_RANGE = re.compile(
    r'(?:(?:(?P<sn>\d{1,2})[/.])|(?:(?P<sm>[A-Za-z]{3,9})\.?\s*))?(?P<sy>(?:19|20)\d\d)\s*(?:-|\u2013|\u2014|to|until)\s*'
    r'(?:(?:(?P<en>\d{1,2})[/.])|(?:(?P<em>[A-Za-z]{3,9})\.?\s*))?(?P<ey>(?:19|20)\d\d|' + _END + r')\b', re.I)


def stated_years(text):
    """Largest 'N years of experience' style claim in text (0 when none)."""
    best = 0
    for pattern in _YEARS_PATTERNS:
        for m in pattern.finditer(text or ''):
            value = int(m.group(1))
            if 0 < value <= 45:
                best = max(best, value)
    return best


def _month(name, default, number=None):
    if number and str(number).isdigit() and 1 <= int(number) <= 12:
        return int(number)
    return _MONTHS.get((name or '')[:3].lower(), default)


def dated_years(text, this_year=None):
    """Years covered by employment date ranges, counted by month so overlaps are not double-counted."""
    import datetime
    today = datetime.date.today()
    this_year = this_year or today.year
    covered = set()
    for line in (text or '').splitlines():
        if _EDU_WORDS.search(line):
            continue
        for m in _RANGE.finditer(line):
            start_y = int(m.group('sy'))
            end_raw = m.group('ey').lower()
            if end_raw.isdigit():
                end_y, end_m = int(end_raw), _month(m.group('em'), 1, m.group('en'))
            else:
                end_y, end_m = today.year, today.month
            start_m = _month(m.group('sm'), 1, m.group('sn'))
            start, end = start_y * 12 + start_m, end_y * 12 + end_m
            if 1970 <= start_y and end_y <= this_year + 1 and 0 <= end - start <= 45 * 12:
                covered.update(range(start, end))
    return min(round(len(covered) / 12), 45)


def candidate_years(text, profile_years=0):
    """Explicit claims (CV statement or profile field) win; date ranges are only a fallback."""
    explicit = max(int(profile_years or 0), stated_years(text))
    return explicit if explicit else dated_years(text)


# ── Job analysis ────────────────────────────────────────────────────────────
def _lines(value):
    out = []
    for raw in re.split(r'[\r\n]+|(?<=[.;])\s+', value or ''):
        line = raw.strip().lstrip('-•*·0123456789.) ').strip()
        if len(line) >= 4:
            out.append(line)
    return out


def analyze_job(job):
    title = job.title or ''
    req, resp, nice, desc = (getattr(job, f, '') or '' for f in
                             ('requirements', 'responsibilities', 'nice_to_have', 'description'))
    lines = [(l, 1.0, 'requirements') for l in _lines(req)]
    lines += [(l, 0.5, 'responsibilities') for l in _lines(resp)]
    lines += [(l, 0.3, 'nice_to_have') for l in _lines(nice)]
    if not lines:
        lines = [(l, 0.5, 'description') for l in _lines(desc)[:15]]

    skill_weights = {}
    for text, weight in ((title, 1.0), (req, 1.0), (resp, 0.7), (desc, 0.7), (nice, 0.4)):
        for key in find_skills(text):
            skill_weights[key] = max(skill_weights.get(key, 0), weight)

    required_years = job.experience_years_min if getattr(job, 'experience_years_min', None) is not None else None
    if not required_years:
        required_years = stated_years(' '.join([req, desc, getattr(job, 'experience_required', '') or ''])) or None

    return {
        'title_keywords': keywords(title),
        'lines': lines,
        'skill_weights': skill_weights,
        'required_years': required_years,
        'required_education': _required_education(' '.join([req, desc])),
    }


# ── Scoring ─────────────────────────────────────────────────────────────────
BASE_WEIGHTS = {'skills': 40, 'requirements': 30, 'title': 10, 'experience': 10, 'education': 10}


def score_candidate(job, candidate_text, profile_skills=None, headline='', profile_years=0,
                    profile_qualification=''):
    """Score one candidate against one job. Returns the full explanation dict."""
    analysis = analyze_job(job)
    candidate_text = strip_noise(normalize_text(candidate_text))
    profile_skills = split_skill_list(profile_skills)
    haystack = ' '.join([candidate_text, headline or '', ' '.join(profile_skills)])
    has_text = bool(haystack.strip())

    job_reqs = [line for line, _w, _s in analysis['lines']]
    if not has_text:
        return _empty_result(job_reqs, analysis)

    cand_kw = keywords(haystack)
    cand_skill_keys = set(find_skills(haystack))
    cand_skill_keys |= {k for k in (_profile_skill_key(s) for s in profile_skills) if k}

    # skills: job skills the candidate has (weighted)
    weights = analysis['skill_weights']
    matched_keys = [k for k in weights if k in cand_skill_keys]
    missing_keys = [k for k in weights if k not in cand_skill_keys]
    skills_score = None
    if weights:
        skills_score = round(100 * sum(weights[k] for k in matched_keys) / sum(weights.values()))

    # requirements: per-line vocabulary coverage
    matched_reqs, unmatched_reqs, num, den = [], [], 0.0, 0.0
    for line, weight, _src in analysis['lines']:
        kws = keywords(line)
        if not kws:
            continue
        coverage = len(kws & cand_kw) / len(kws)
        num += weight * coverage
        den += weight
        threshold = 1.0 if len(kws) <= 2 else 0.6
        (matched_reqs if coverage >= threshold else unmatched_reqs).append(line)
    req_score = round(100 * num / den) if den else None

    # title relevance
    title_kw = analysis['title_keywords']
    title_score = round(100 * len(title_kw & cand_kw) / len(title_kw)) if title_kw else None

    # experience
    cand_years = candidate_years(candidate_text, profile_years)
    req_years = analysis['required_years']
    exp_score = None
    if req_years:
        exp_score = 100 if cand_years >= req_years else round(100 * cand_years / req_years)

    # education
    cand_edu = max(education_level(' '.join([candidate_text, profile_qualification or ''])), 0)
    req_edu = analysis['required_education']
    edu_score = None
    if req_edu:
        gap = req_edu - cand_edu
        edu_score = 100 if gap <= 0 else (50 if gap == 1 else 0)

    raw = {'skills': skills_score, 'requirements': req_score, 'title': title_score,
           'experience': exp_score, 'education': edu_score}
    available = {k: v for k, v in raw.items() if v is not None}
    if not available:
        return _empty_result(job_reqs, analysis, has_text=True)

    # A job naming only 1-2 skills is thin evidence: scale the skills weight by n/3 (max 1)
    base = dict(BASE_WEIGHTS)
    soft = sum(1 for k in weights if k in SOFT_SKILLS)
    evidence = (len(weights) - soft) + 0.5 * soft  # soft skills are weaker evidence than hard skills
    base['skills'] = BASE_WEIGHTS['skills'] * min(1.0, evidence / 3)
    total_w = sum(base[k] for k in available)
    norm = {k: base[k] * 100 / total_w for k in available}
    score = round(sum(available[k] * norm[k] for k in available) / 100)
    score = max(0, min(100, score))

    details = {
        'skills': f'{len(matched_keys)} of {len(weights)} job skills found in the CV/profile',
        'requirements': f'{len(matched_reqs)} of {len(matched_reqs) + len(unmatched_reqs)} job requirements covered',
        'title': f'Job title "{job.title}" vs. candidate CV/headline',
        'experience': f'{cand_years} yr(s) found vs. {req_years} required' if req_years else '',
        'education': f'{education_label(cand_edu)} vs. {education_label(req_edu)} required' if req_edu else '',
    }
    labels = {'skills': 'Skills', 'requirements': 'Requirements', 'title': 'Role relevance',
              'experience': 'Experience', 'education': 'Education'}
    components = [{'key': k, 'label': labels[k], 'score': available[k], 'weight_pct': round(norm[k]),
                   'detail': details[k]} for k in available]

    summary = (f'{score}% match. ' + ' · '.join(f"{c['label']} {c['score']}% (weight {c['weight_pct']}%)"
                                                for c in components)
               + '. Only components the job listing lets us measure are counted.')

    return {
        'score': score,
        'skill_score': skills_score if skills_score is not None else (req_score or 0),
        'content_score': req_score if req_score is not None else (skills_score or 0),
        'skill_weight_pct': round(norm.get('skills', 0)),
        'content_weight_pct': round(norm.get('requirements', 0)),
        'components': components,
        'matched_skills': [SKILL_DISPLAY[k] for k in matched_keys],
        'unmatched_skills': [SKILL_DISPLAY[k] for k in missing_keys],
        'matched_requirements': matched_reqs,
        'unmatched_requirements': unmatched_reqs,
        'total_skills': len(weights),
        'total_requirements': len(matched_reqs) + len(unmatched_reqs),
        'candidate_years': cand_years,
        'required_years': req_years,
        'candidate_education': education_label(cand_edu),
        'required_education': education_label(req_edu) if req_edu else '',
        'has_candidate_text': True,
        'is_baseline': False,
        'summary': summary,
    }


def split_skill_list(skills):
    """Profile skills are often typed as 'Excel, Sage; Payroll' on one line: split them apart."""
    out, seen = [], set()
    for entry in skills or []:
        for part in re.split(r'[,;|\n]+', str(entry)):
            part = part.strip(' .-*')
            if part and part.lower() not in seen:
                seen.add(part.lower())
                out.append(part)
    return out


def _profile_skill_key(skill):
    found = find_skills(skill)
    return found[0] if len(found) == 1 and len(skill.split()) <= 4 else None


def _empty_result(job_reqs, analysis, has_text=False):
    return {
        'score': 0, 'skill_score': 0, 'content_score': 0, 'skill_weight_pct': 0, 'content_weight_pct': 0,
        'components': [], 'matched_skills': [], 'unmatched_skills': [SKILL_DISPLAY[k] for k in analysis['skill_weights']],
        'matched_requirements': [], 'unmatched_requirements': job_reqs,
        'total_skills': len(analysis['skill_weights']), 'total_requirements': len(job_reqs),
        'candidate_years': 0, 'required_years': analysis['required_years'],
        'candidate_education': 'Not stated', 'required_education': '',
        'has_candidate_text': has_text, 'is_baseline': True,
        'summary': ('No readable CV or profile text was found for this candidate, so no match can be calculated. '
                    'Review the CV manually.'),
    }
