"""Local-only, candidate-controlled Playwright worker for permitted ATS pages.
No submission, no CAPTCHA/MFA handling, no credential storage.
"""
import threading, time, uuid, tempfile, os, re, socket, ipaddress
from pathlib import Path
from urllib.parse import urlparse
from agent import platform, safe_profile
from portal_adapters import classify

_lock=threading.Lock()
_sessions={}
MAX_AGE=3600

def _set(sid, **fields):
    with _lock:
        if sid in _sessions: _sessions[sid].update(fields)

def status(sid):
    with _lock:
        s=_sessions.get(sid)
        if not s: return None
        return {k:v for k,v in s.items() if k not in ('stop','thread','resume_path')}

def stop(sid):
    with _lock:
        s=_sessions.get(sid)
        if not s:return False
        s['stop'].set()
        return True

def _public_host(host):
    if not host or host in ('localhost',) or host.endswith(('.local','.internal')): return False
    try:
        addresses=socket.getaddrinfo(host,443,type=socket.SOCK_STREAM)
        return bool(addresses) and all(ipaddress.ip_address(a[4][0]).is_global for a in addresses)
    except (OSError, ValueError): return False

def start(url, profile, resume_bytes=None, filename=None):
    mode=classify(url)['mode']
    if mode in ('invalid','manual_handoff'): raise ValueError('This portal uses candidate-controlled handoff, not automated filling.')
    p=urlparse(url)
    if p.scheme!='https' or p.username or p.password or p.port not in (None,443) or not _public_host(p.hostname): raise ValueError('Employer must use a public HTTPS host')
    if len(url)>1500 or not isinstance(profile,dict) or not profile.get('full_name') or not profile.get('email'): raise ValueError('Candidate full name and email required')
    with _lock:
        for s in _sessions.values():
            if s['state'] in ('starting','running','waiting_for_consent','review'):
                raise ValueError('Another browser session is active. Close it first.')
        for sid in list(_sessions):
            if time.time()-_sessions[sid]['created']>MAX_AGE: _sessions.pop(sid,None)
        sid=uuid.uuid4().hex
        s={'id':sid,'url':url,'state':'starting','events':[],'fields':[],'created':time.time(),'stop':threading.Event()}
        _sessions[sid]=s
    path=None
    if resume_bytes:
        suffix=Path(filename or '').suffix.lower()
        if suffix not in ('.pdf','.docx') or len(resume_bytes)>5_000_000: raise ValueError('Resume must be PDF or DOCX, max 5 MB')
        f=tempfile.NamedTemporaryFile(prefix='accesslens_resume_',suffix=suffix,delete=False)
        f.write(resume_bytes);f.close();path=f.name
    thread=threading.Thread(target=_worker,args=(sid,url,{**safe_profile(profile),'answers':str(profile.get('answers',''))[:6000]},path),daemon=True)
    thread.start()
    return sid

def _same_employer(a,b):
    """Conservative site-family check, not a general public suffix algorithm."""
    ah=(urlparse(a).hostname or '').lower();bh=(urlparse(b).hostname or '').lower()
    if not ah or not bh:return False
    if ah==bh:return True
    known=('appliedmaterials.com','electroluxgroup.com')
    return any((ah==d or ah.endswith('.'+d)) and (bh==d or bh.endswith('.'+d)) for d in known)

def _closed(page):
    try:
        body=page.locator('body').inner_text(timeout=6000)[:12000].lower()
        return any(re.search(p,body) for p in (r'this (?:job|position|role) has been filled',r'position (?:is )?no longer available',r'job (?:is )?no longer available',r'this job has expired'))
    except Exception:return False

def _discover(page,sid):
    """Navigate at most two clearly identified Apply links, never Submit/Quick Apply."""
    visited={page.url}
    for step in range(2):
        if _closed(page):return 'closed'
        frames=[f for f in page.frames if _same_employer(page.url,f.url) or f==page.main_frame]
        count=0
        for f in frames:
            try:count+=f.locator('input:not([type=hidden]):not([type=submit]), textarea, select').count()
            except Exception:pass
        if count:return 'form'
        candidates=[]
        for link in page.locator('a[href]').all()[:250]:
            try:
                if not link.is_visible():continue
                label=' '.join((link.inner_text(timeout=500) or '').split()).lower()
                if not re.fullmatch(r'(apply|apply now|apply for (?:this )?(?:job|position)|apply to this job)',label):continue
                href=link.get_attribute('href') or ''
                from urllib.parse import urljoin
                dest=urljoin(page.url,href)
                if not _same_employer(page.url,dest) or dest in visited or urlparse(dest).scheme!='https':continue
                candidates.append((link,dest))
            except Exception:pass
        distinct={dest for _,dest in candidates}
        if len(distinct)!=1:
            _set(sid,events=['Application form not detected; '+('no safe Apply link found.' if not distinct else 'multiple Apply destinations require candidate review.')])
            return 'no_form'
        target=next(iter(distinct));visited.add(target)
        _set(sid,events=['Following employer Apply link to '+(urlparse(target).hostname or '')+'.'])
        page.goto(target,wait_until='domcontentloaded',timeout=45000)
        if not _same_employer(next(iter(visited)),page.url):return 'external'
        page.wait_for_timeout(1800)
        if not _wait_for_consent(page,sid,_sessions[sid]['stop']):return 'consent'
    return 'no_form'

def _cookie_blocker(page):
    """Identify a visible consent dialog, without selecting a consent option."""
    try:
        for frame in page.frames:
            dialogs=frame.locator('[role="dialog"], [aria-modal="true"], #onetrust-banner-sdk, .onetrust-pc-dark-filter, [class*="cookie-modal"], [id*="cookie-banner"]')
            for i in range(min(dialogs.count(), 15)):
                node=dialogs.nth(i)
                if node.is_visible():
                    content=(node.inner_text(timeout=1000) or '').lower()[:3000]
                    if any(x in content for x in ('cookies','cookie preferences','tracking technologies','manage settings','consent')):
                        return True
        # Some consent popups use non-semantic markup; detect only if a visible
        # cookie-related text and consent controls are both present.
        for frame in page.frames:
            text=frame.get_by_text(re.compile(r'cookies and other tracking|we use cookies|cookie preferences',re.I))
            if text.count() and any(text.nth(i).is_visible() for i in range(min(text.count(),5))):
                buttons=frame.get_by_role('button',name=re.compile(r'accept|reject|manage settings|cookie settings',re.I))
                if buttons.count() and any(buttons.nth(i).is_visible() for i in range(min(buttons.count(),8))):return True
    except Exception:
        pass
    return False

def _wait_for_consent(page,sid,stop,timeout=180):
    if not _cookie_blocker(page):return True
    _set(sid,state='waiting_for_consent',events=['Cookie/privacy dialog detected. Choose your preferences in the opened employer browser; the agent will resume automatically.'])
    deadline=time.monotonic()+timeout
    while time.monotonic()<deadline and not stop.wait(0.7):
        if page.is_closed():return False
        if not _cookie_blocker(page):
            page.wait_for_timeout(900)
            _set(sid,state='running',events=['Cookie dialog dismissed. Resuming application navigation.'])
            return True
    _set(sid,state='review',fields=[{'field':'cookie consent','result':'needs input — dialog was not dismissed before timeout'}],events=['Consent dialog still blocks navigation. Close session and retry when ready.'])
    return False

def _worker(sid,url,profile,resume):
    if os.getenv("OPENAI_API_KEY"):
        from reasoning_agent import run_reasoning_agent
        return run_reasoning_agent(sid,url,profile,resume,_sessions,_set,_same_employer,_public_host)
    return _worker_rules(sid,url,profile,resume)

def _worker_rules(sid,url,profile,resume):
    browser=None
    try:
        from playwright.sync_api import sync_playwright
        with sync_playwright() as pw:
            browser=pw.chromium.launch(headless=False)
            context=browser.new_context(accept_downloads=False)
            page=context.new_page()
            original_url=url
            page.goto(url,wait_until='domcontentloaded',timeout=45000)
            if not _same_employer(original_url,page.url):raise ValueError('Redirected outside verified employer domain; manual review required.')
            page.wait_for_timeout(1500)
            if not _wait_for_consent(page,sid,_sessions[sid]['stop']):
                while not _sessions[sid]['stop'].wait(0.5):
                    if page.is_closed():break
                context.close();return
            _set(sid,state='running',events=['Opened employer posting; locating application form.'])
            discovery=_discover(page,sid)
            if discovery!='form':
                reasons={'closed':'Job appears filled or no longer available; no application attempted.',
                         'external':'Application redirected outside verified employer domain; manual review required.',
                         'no_form':'No compatible form found after checking Apply links; login, dynamic widget, or candidate action may be needed.',
                         'consent':'Cookie dialog requires candidate interaction before navigation can continue.'}
                _set(sid,state='review',fields=[{'field':'application navigation','result':'needs review — '+reasons.get(discovery,'Unable to locate form')}],events=[reasons.get(discovery,'Navigation requires review')])
                while not _sessions[sid]['stop'].wait(0.5):
                    if page.is_closed():break
                context.close();return
            # Read form semantics, fill only high-confidence matches, then read values back.
            # No LLM guesses, no legal/salary answers without approved source data.
            mapping={
                'full_name':[r'full.?name',r'^name$',r'candidate.?name'],
                'email':[r'e.?mail'], 'phone':[r'phone',r'mobile',r'contact.?number'],
                'linkedin':[r'linkedin'], 'portfolio':[r'portfolio',r'github',r'website'],
                'location':[r'^location$',r'current.?location'],
                'address_line1':[r'address.?line.?1',r'street.?address'],
                'address_line2':[r'address.?line.?2',r'apartment',r'suite'],
                'city':[r'^city$',r'^town$'],
                'state':[r'^state$',r'^province$'],
                'postal_code':[r'postal.?code',r'zip.?code',r'pin.?code'],
                'current_company':[r'current.?company',r'current.?employer'],
                'notice_period':[r'notice.?period'],
                'years_experience':[r'years?.?of.?experience',r'total.?experience'],
                'education':[r'education',r'highest.?qualification'],
                'work_authorization':[r'work.?authorization',r'right.?to.?work'],
            }
            approved={}
            for line in profile.get('answers','').splitlines():
                if '=' in line:
                    question,answer=line.split('=',1)
                    if question.strip() and answer.strip(): approved[question.strip().lower()]=answer.strip()[:500]
            results=[];used=set()
            frames=[f for f in page.frames if f==page.main_frame or _same_employer(page.url,f.url)]
            control_list=[]
            for frame in frames:
                try:
                    controls=frame.locator('input:not([type="hidden"]):not([type="file"]), textarea, select')
                    control_list.extend(controls.nth(i) for i in range(min(controls.count(),120)))
                except Exception:pass
            for i,loc in enumerate(control_list[:160]):
                try:
                    if not loc.is_visible() or not loc.is_enabled(): continue
                    meta=loc.evaluate("(el)=>({tag:el.tagName.toLowerCase(),type:(el.type||'').toLowerCase(),name:el.name||'',id:el.id||'',placeholder:el.placeholder||'',aria:el.getAttribute('aria-label')||'',required:el.required,label:(el.labels&&[...el.labels].map(x=>x.innerText).join(' '))||''})")
                    if meta['type'] in ('checkbox','radio','submit','button','password','date','number'): continue
                    desc=' '.join(str(meta[k]) for k in ('name','id','placeholder','aria','label')).strip()
                    if not desc: continue
                    key=None;value=None
                    for field,patterns in mapping.items():
                        if field in used or not profile.get(field): continue
                        if any(re.search(pat,desc,re.I) for pat in patterns): key=field;value=profile[field];break
                    if not key:
                        for question,answer in approved.items():
                            if len(question)>8 and (question in desc.lower() or desc.lower() in question and len(desc)>12):
                                key='approved answer: '+question[:70];value=answer;break
                    if key and meta['tag'] in ('input','textarea') and meta['type'] not in ('checkbox','radio'):
                        before=loc.input_value(timeout=1500)
                        if not before.strip():
                            loc.fill(value,timeout=3000)
                            verified=loc.input_value(timeout=1500)==value
                            results.append({'field':desc[:110],'result':'verified' if verified else 'needs review','source':key})
                            if key in mapping: used.add(key)
                    elif key and meta['tag']=='select':
                        results.append({'field':desc[:110],'result':'needs review — select option','source':key})
                    elif meta['required']:
                        results.append({'field':desc[:110],'result':'needs input — no approved matching answer'})
                except Exception:
                    results.append({'field':'control '+str(i+1),'result':'needs review — could not inspect'})
            # Explicit audit of remaining required fields (not proof that an employer form is complete).
            try:
                required=[]
                for frame in frames:
                    missing=frame.locator('input[required], textarea[required], select[required]')
                    required.extend(missing.nth(i) for i in range(min(missing.count(),80)))
                for loc in required[:100]:
                    if not loc.is_visible():continue
                    meta=loc.evaluate('(el)=>({name:el.name||el.id||el.getAttribute("aria-label")||"required field",type:el.type||"",value:el.value||"",checked:el.checked||false})')
                    if not meta['value'] and not (meta['type'] in ('checkbox','radio') and meta['checked']):
                        label=meta['name'][:100]
                        if not any(x['field']==label for x in results): results.append({'field':label,'result':'needs input — required field empty'})
            except Exception: pass
            if resume:
                uploads=[]
                for frame in frames:
                    loc=frame.locator('input[type="file"]')
                    uploads.extend(loc.nth(i) for i in range(min(loc.count(),10)))
                try:
                    if len(uploads)==1:
                        uploads[0].set_input_files(resume,timeout=5000)
                        results.append({'field':'resume','result':'attached — verify on employer form'})
                    else: results.append({'field':'resume','result':'needs review — missing or multiple upload controls'})
                except Exception: results.append({'field':'resume','result':'needs review — upload failed'})
            if not results: results.append({'field':'application form','result':'needs review — form detected but no recognized editable fields; check login, widgets, and frames'})
            _set(sid,state='review',fields=results,events=['Self-check finished. Verified fields are read back; unresolved or multi-step fields require review. No submission performed.'])
            while not _sessions[sid]['stop'].wait(0.5):
                if page.is_closed(): break
            _set(sid,state='closed',events=['Browser session closed. No submission was performed by AccessLens.'])
            context.close()
    except ImportError:
        _set(sid,state='error',events=['Playwright is not installed. Run: python3 -m pip install playwright && python3 -m playwright install chromium'])
    except Exception as exc:
        _set(sid,state='error',events=[str(exc)[:220]])
    finally:
        if browser:
            try: browser.close()
            except Exception: pass
        if resume:
            try: os.unlink(resume)
            except OSError: pass
