"""AccessLens matching v2: role/domain gates followed by explainable relevance ranking.

Scores are prioritization heuristics, never probabilities of employment.
"""
import re

ALIASES = {
 'api':['api testing','api automation','rest api','rest assured','restassured','postman','api'],
 'ci/cd':['ci/cd','ci cd','continuous integration','continuous delivery','jenkins','github actions'],
 'selenium':['selenium','webdriver'], 'playwright':['playwright'],
 'java':['java'], 'python':['python'], 'cypress':['cypress'],
 'automation':['test automation','automated testing','automation'],
 'performance':['performance testing','load testing','jmeter','k6'],
}
SOFTWARE_STRONG = re.compile(r'\b(?:software quality|quality engineering|test engineering|software testing|sdet|test automation|automated testing|qa automation|selenium|playwright|cypress|api testing|software development|engineering productivity|test infrastructure|test frameworks?|software delivery|ci/cd|devops)\b',re.I)
SOFTWARE_WEAK = re.compile(r'\b(?:software|engineering|technology|platform|application|agile|scrum|release|developer|product development)\b',re.I)
OTHER_DOMAIN = re.compile(r'\b(?:revenue cycle management|medical billing|claims processing|pharmacovigilance|gmp|glp|manufacturing quality|factory quality|call cent(?:er|re) quality|bpo quality|food safety|supplier quality|clinical quality|healthcare operations|collections quality)\b',re.I)
QE_TITLE = re.compile(r'\b(?:qa|qe|quality assurance|quality engineering|test(?:ing)?|sdet|software quality|automation)\b',re.I)
LEADERSHIP = [
 r'\b(?:quality|test|qa|qe|automation) strateg(?:y|ies)\b',
 r'\b(?:people management|people leadership|manage managers|managing managers|direct reports|performance reviews)\b',
 r'\b(?:build|lead|manage|scale|mentor|hire|grow)\b.{0,55}\b(?:qa|qe|quality|test|engineering)\b.{0,35}\b(?:team|organi[sz]ation|function|engineers?)\b',
 r'\b(?:quality transformation|test transformation|engineering transformation|quality roadmap)\b',
 r'\b(?:budget ownership|headcount planning|hiring strategy|organi[sz]ational leadership)\b',
 r'\b(?:cross.functional leadership|stakeholder management|quality governance|executive stakeholders)\b',
 r'\b(?:release quality|quality metrics|defect prevention|shift.left|continuous quality)\b',
]

def contains(text, term):
 return bool(re.search(r'(?<![a-z0-9])'+re.escape(term.lower())+r'(?![a-z0-9])',text.lower()))

def level(title):
 t=(title or '').lower()
 if re.search(r'\b(intern|internship|trainee|apprentice)\b',t):return 0
 if re.search(r'\b(?:senior|sr\.?)\s+(?:director|vice president|vp)\b',t):return 7
 if re.search(r'\b(?:director|head(?:\s+of|\s*[-–—])?|vp|vice president)\b',t):return 6
 if re.search(r'\b(?:senior|sr\.?)\s+(?:(?:qa|qe|quality|test|testing|engineering|automation|release)\s+)*manager\b',t):return 5.5
 if re.search(r'\b(?:manager|engineering manager)\b',t):return 5
 if re.search(r'\b(?:staff|principal|architect|lead)\b',t):return 4
 if re.search(r'\b(?:senior|sr\.?|sdet\s*[- ]?(?:3|iii)\b)\b',t):return 3
 if re.search(r'\b(?:sdet\s*[- ]?(?:2|ii)\b|level\s*2\b)\b',t):return 2
 if re.search(r'\b(?:junior|associate|entry.level|sdet\s*[- ]?(?:1|i)\b)\b',t):return 1
 return None

def target_level(role):
 return level(role) if level(role) is not None else 2

def required_years(description):
 text=(description or '')[:14000]
 patterns=[r'\b(?:minimum|at least|requires?|experience(?: of|:)?|with)\s*(\d{1,2})\s*\+?\s*(?:years|yrs)\b',r'\b(\d{1,2})\s*\+\s*(?:years|yrs)\s*(?:of\s*)?(?:experience|in testing|in automation)\b']
 nums=[int(n) for p in patterns for n in re.findall(p,text,re.I) if int(n)<=30]
 return max(nums) if nums else None

def domain_assessment(title,description):
 text=((title or '')+' '+(description or '')[:14000]); strong=bool(SOFTWARE_STRONG.search(text));weak=bool(SOFTWARE_WEAK.search(text));other=bool(OTHER_DOMAIN.search(text))
 if other and not strong:return 'Non-software QA domain',False
 if other and strong:return 'Mixed software/non-software domain; review',None
 if strong:return 'Software QE evidence present',True
 if weak and QE_TITLE.search(title or ''):return 'Software QE domain plausible; verify',None
 return 'Software QE domain unverified',None

def score(job,skills,role,location,years=None):
 title=str(job.get('title') or '');desc=str(job.get('description') or '')[:14000];corpus=(title+' '+desc).lower()
 skills=list(dict.fromkeys(str(s).strip().lower() for s in skills if str(s).strip()))
 matched=[];unmentioned=[];evidence={}
 for skill in skills:
  hit=next((a for a in ALIASES.get(skill,[skill]) if contains(corpus,a)),None)
  if hit:matched.append(skill);evidence[skill]=hit
  else:unmentioned.append(skill)
 target=target_level(role);actual=level(title);leadership=target>=5
 domain_note,domain_ok=domain_assessment(title,desc)
 qe_title=bool(QE_TITLE.search(title))
 req=required_years(desc)
 loc=(job.get('location') or '').lower();loc_match=not location or location.lower() in loc or bool(job.get('remote')) or 'remote' in loc
 reasons=[];review=[];hard=[]
 if not qe_title and leadership:hard.append('Title is not a QE/QA/testing leadership role')
 if domain_ok is False:hard.append(domain_note)
 if domain_ok is None:review.append(domain_note)
 if actual is None:review.append('Seniority not explicit in title')
 elif leadership:
  # Distinguish senior manager, director and senior director rather than accepting any leadership title.
  if target>=7 and actual<6:hard.append('Role is below Director seniority')
  elif target>=6 and actual<6:hard.append('Role is below Director seniority')
  elif target==5.5 and actual<5:hard.append('Role is below Manager seniority')
  elif target==5 and actual<5:hard.append('Role is below Manager seniority')
  elif abs(actual-target)>1.5:review.append('Seniority differs from target')
 elif actual<target-1.5:hard.append('Role is substantially below target seniority')
 elif actual>target+2:review.append('Role is above target seniority')
 if years is not None and req is not None and years<req:hard.append(f'Posting requires {req}+ years; profile has {years}')
 if not desc.strip():review.append('Job description unavailable; responsibilities cannot be verified')
 signal_count=sum(bool(re.search(p,desc,re.I)) for p in LEADERSHIP)
 if leadership and signal_count==0:review.append('Leadership responsibilities not evidenced in description')
 if actual is not None and abs(actual-target)<=0.5:role_points=35
 elif actual is not None and abs(actual-target)<=1.5:role_points=26
 elif actual is None:role_points=12
 else:role_points=8
 responsibility_points=min(30,signal_count*6) if leadership else min(30,10+round(20*len(matched)/max(1,len(skills))))
 skill_points=(round(15*len(matched)/len(skills)) if skills else 8)
 # Missing descriptions cannot earn a full experience score: unknown != satisfied.
 experience_points=10 if req is not None and years is not None and years>=req else 5 if req is None else 3
 location_points=10 if loc_match else 0
 raw=min(100,role_points+responsibility_points+skill_points+experience_points+location_points)
 status='excluded' if hard else 'review' if review else 'verified'
 # 'review' jobs may appear in a separate review section, never in Verified Best Matches.
 eligible=status=='verified'
 points=min(raw,39) if hard else min(raw,69) if review else raw
 confidence='High' if desc and req is not None and (signal_count>0 if leadership else bool(matched)) else 'Medium' if desc else 'Low'
 if domain_ok is None:confidence='Low'
 reasons.extend(hard or review or ['Role and domain evidence align'])
 reasons.append(domain_note)
 if leadership:reasons.append(f'Leadership evidence: {signal_count} responsibility signals in posting')
 if req is not None:reasons.append(f'Posting mentions {req}+ years')
 if not skills:reasons.append('Candidate skills not supplied; neutral skill score')
 return {**job,'score':points,'match_confidence':confidence,'evidence_count':sum([bool(desc),actual is not None,req is not None,bool(matched)]),'score_breakdown':{'skills':skill_points,'seniority':role_points,'experience':experience_points,'location':location_points,'responsibilities':responsibility_points,'cap_applied':points<raw},'matched':matched,'missing':unmentioned,'unmentioned':unmentioned,'skill_evidence':evidence,'location_match':loc_match,'role_match':actual==target,'seniority_level':actual,'target_level':target,'seniority_note':reasons[0],'required_years':req,'eligible':eligible,'review_required':status=='review','match_status':status,'reasons':reasons,'domain_status':domain_note,'leadership_signals':signal_count}

# v3: separate discovery from classification. These fields are intentionally
# additive to preserve the existing API and frontend contract.
DOMAIN_NEGATIVE_TITLE = re.compile(r'\b(?:rcm|revenue cycle|medical billing|clinical|pharma|manufacturing|supplier|factory|food|call center|bpo|validation professional)\b', re.I)
DOMAIN_POSITIVE_TITLE = re.compile(r'\b(?:software|sdet|test engineering|quality engineering|qa automation|test automation|software quality|engineering quality|software testing)\b',re.I)

def classify_domain(title, description):
 title=title or ''; description=description or ''
 strong=bool(SOFTWARE_STRONG.search(description) or DOMAIN_POSITIVE_TITLE.search(title))
 negative=bool(DOMAIN_NEGATIVE_TITLE.search(title) or OTHER_DOMAIN.search(description[:12000]))
 if negative and not strong:return 'non_software'
 if negative and strong:return 'uncertain'
 if strong:return 'software_qe'
 return 'uncertain'

_legacy_score=score

def score(job,skills,role,location,years=None):
 result=_legacy_score(job,skills,role,location,years)
 title=str(job.get('title') or '');desc=str(job.get('description') or '')
 domain=classify_domain(title,desc)
 result['domain_class']=domain
 result['discovery_relevance']='related' if QE_TITLE.search(title) else 'unrelated'
 if domain=='non_software':
  result['eligible']=False;result['match_status']='excluded';result['score']=min(result['score'],39)
  result['reasons']=['Job appears to be outside software quality engineering']+result['reasons']
 elif domain=='uncertain' and result['match_status']!='excluded':
  result['eligible']=False;result['review_required']=True;result['match_status']='review';result['score']=min(result['score'],69)
  result['reasons']=['Software QE domain needs verification']+result['reasons']
 # Missing responsibilities are unknown evidence, not evidence of a mismatch.
 if not desc.strip() and result['match_status']!='excluded':
  result['eligible']=False;result['review_required']=True;result['match_status']='review';result['score']=min(result['score'],69)
 result['ranking_version']='v3-evidence'
 return result
