"""Experimental bounded observe-decide-act browser agent. Never submits applications.
The OpenAI API receives page text/control metadata and approved profile information.
"""
import os,json,time,re,urllib.request,urllib.parse,ipaddress,socket
from pathlib import Path

MODEL=os.getenv('ACCESSLENS_AGENT_MODEL','gpt-4.1-mini')
MAX_STEPS=24
SYSTEM='''You are a cautious job application browser agent. You receive current visible browser controls and a verified candidate profile. Choose ONE next action as JSON. Goal: navigate to the actual application, attach approved resume, fill supported fields, then stop for candidate review. Never invent facts. Never click submit, send application, create account, sign in, accept/reject cookies, bypass CAPTCHA, consent to legal declarations, or change security settings. Do not fill demographic/disability/legal/work authorization/salary fields unless the user has separately approved that exact answer. Never interact with search controls on job posting pages as if they were application controls. If cookie dialog, login, CAPTCHA, or uncertain question is present choose wait_user or finish. If action could submit, choose finish. Use only control indices provided. JSON: {"action":"click|fill|upload|wait_user|finish", "index":integer optional,"value":string optional,"reason":string}. For click, only navigate using clearly labeled Apply/Apply Now/Continue/Next controls; never click Submit, Save and Submit, Complete Application, or anything equivalent. For fill, value must be exactly an approved profile field value. For upload, choose an upload control. Choose finish when all available fields are filled or if stuck.'''

def decide(snapshot,profile,has_resume):
    key=os.environ['OPENAI_API_KEY']
    data={'model':MODEL,'temperature':0,'response_format':{'type':'json_object'},'messages':[{'role':'system','content':SYSTEM},{'role':'user','content':json.dumps({'page':snapshot,'approved_profile':profile,'resume_available':has_resume},ensure_ascii=False)}]}
    req=urllib.request.Request('https://api.openai.com/v1/chat/completions',data=json.dumps(data).encode(),headers={'Authorization':'Bearer '+key,'Content-Type':'application/json'},method='POST')
    with urllib.request.urlopen(req,timeout=40) as r: result=json.load(r)
    return json.loads(result['choices'][0]['message']['content'])

def observe(page):
    """Visible semantic controls; include associated labels and hidden file inputs."""
    js=r'''() => {const selector='a[href],button,input,textarea,select,[role="button"],[role="textbox"]';let out=[];
    for(const el of document.querySelectorAll(selector)){
      const type=(el.type||'').toLowerCase(),r=el.getBoundingClientRect(),st=getComputedStyle(el);
      if(type==='hidden')continue;
      if(type!=='file'&&(r.width<2||r.height<2||st.visibility==='hidden'||st.display==='none'))continue;
      const labels=el.labels?[...el.labels].map(x=>x.innerText).join(' '):'';
      const parent=el.closest('label')?.innerText||el.parentElement?.innerText||'';
      const nearby=el.previousElementSibling?.innerText||'';
      const label=labels||el.getAttribute('aria-label')||el.getAttribute('placeholder')||nearby||parent||el.innerText||el.name||el.id||'';
      out.push({index:out.length,tag:el.tagName.toLowerCase(),type,label:String(label).trim().slice(0,120),name:el.name||'',id:el.id||'',required:!!el.required||el.getAttribute('aria-required')==='true',value:type==='password'?'[protected]':String(el.value||'').slice(0,120),href:el.tagName==='A'?el.href:'',disabled:!!el.disabled});if(out.length>=130)break;
    }return {title:document.title,url:location.href,text:(document.body.innerText||'').slice(0,6500),controls:out};}'''
    return page.evaluate(js)

def locator_for(page,index):
    js=r'''idx=>{const selector='a[href],button,input,textarea,select,[role="button"],[role="textbox"]';let out=[];
    for(const el of document.querySelectorAll(selector)){const type=(el.type||'').toLowerCase(),r=el.getBoundingClientRect(),st=getComputedStyle(el);
      if(type==='hidden'||(type!=='file'&&(r.width<2||r.height<2||st.visibility==='hidden'||st.display==='none')))continue;
      out.push(el);if(out.length>=130)break;
    }document.querySelectorAll('[data-accesslens-target]').forEach(x=>x.removeAttribute('data-accesslens-target'));
    if(!out[idx])return false;out[idx].setAttribute('data-accesslens-target','yes');return true;}'''
    return page.locator('[data-accesslens-target="yes"]').last if page.evaluate(js,index) else None

# Deterministic high-confidence fill complements model decisions; never invents answers.
def fill_known_fields(page,profile,resume,results,report):
    from form_completion import complete_form
    results[:] = complete_form(page,profile,resume,report)


def privacy_modal_visible(page):
    """Recognize blocking privacy agreements, not ordinary page policy links."""
    try:
        for frame in page.frames:
            dialogs=frame.locator('[role="dialog"], [aria-modal="true"], .modal.show, [class*="modal-dialog"]')
            for i in range(min(dialogs.count(), 12)):
                d=dialogs.nth(i)
                if not d.is_visible():continue
                content=d.inner_text(timeout=1200).lower()
                if ('privacy' in content or 'consent' in content or 'agreement' in content) and any(word in content for word in ('confirm','agree','cancel','accept')):
                    return True
    except Exception:pass
    return False


def complete_with_consent_resume(page,profile,resume,results,report,stop,timeout=240):
    """One bounded consent handoff. Never treat a repeated modal as a fresh approval."""
    if page.is_closed():
        report('review','Employer browser closed; no submission.');return False
    # A blocking dialog must be resolved by the candidate BEFORE trying form actions.
    if privacy_modal_visible(page):
        report('waiting_for_consent','Privacy agreement needs your decision in the employer browser. The agent will not accept it.')
        deadline=time.monotonic()+timeout
        while time.monotonic()<deadline and not stop.wait(.7):
            if page.is_closed():
                report('review','Employer browser closed during consent wait.');return False
            if not privacy_modal_visible(page):
                # Require stable dismissal, not a momentary disappearance during rerender.
                if stop.wait(1.2):return False
                if page.is_closed():return False
                if not privacy_modal_visible(page):break
        else:
            report('review','Privacy agreement remains open or timed out; review manually.');return False
        report('running','Privacy dialog dismissed; performing one final form audit.')
    try:
        fill_known_fields(page,profile,resume,results,report)
    except Exception as exc:
        if page.is_closed() or 'Target page, context or browser has been closed' in str(exc):
            report('review','Employer browser closed during form audit; no submission.');return False
        raise
    if page.is_closed():
        report('review','Employer browser closed after form audit; no submission.');return False
    if privacy_modal_visible(page):
        report('review','Privacy agreement appeared again after form audit. Stopped to avoid a consent loop; review in employer browser.')
        return False
    return True


def safe_target(current,target,same_employer,public_host):
    u=urllib.parse.urlparse(target)
    return u.scheme=='https' and not u.username and not u.password and u.port in (None,443) and same_employer(current,target) and public_host(u.hostname)

def run_reasoning_agent(sid,url,profile,resume,sessions,set_state,same_employer,public_host):
    browser=None;events=[];results=[]
    stop=sessions[sid]['stop']
    def report(state,event):
        events.append(event[:240]);set_state(sid,state=state,events=events[-7:],fields=results[-70:])
    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()
            page.goto(url,wait_until='domcontentloaded',timeout=45000)
            if not safe_target(url,page.url,same_employer,public_host):raise ValueError('Unsafe redirect: stopped for review')
            report('running','Reasoning agent started; observing employer page.')
            seen={};uploads=0;completed_forms=set()
            for step in range(MAX_STEPS):
                if stop.is_set() or page.is_closed():break
                page.wait_for_timeout(950)
                snap=observe(page)
                # Ignore irrelevant search controls; let model reason from text.
                if any(x in snap['text'].lower() for x in ('this job has been filled','this position has been filled','job is no longer available')):
                    report('review','Vacancy appears closed. No application attempted.');break
                if re.search(r'cookie.{0,60}(accept|preferences)|we use cookies|tracking technologies',snap['text'][:1800],re.I) and any(re.search('accept|manage settings|reject',c['label'],re.I) for c in snap['controls']):
                    report('waiting_for_consent','Cookie preference dialog: choose your preference in the browser; waiting to resume.')
                    deadline=time.monotonic()+180
                    while time.monotonic()<deadline and not stop.wait(.8):
                        if page.is_closed():break
                        now=observe(page)
                        if not (re.search(r'we use cookies|tracking technologies',now['text'][:1200],re.I) and any(re.search('accept|manage settings|reject',c['label'],re.I) for c in now['controls'])):break
                    if stop.is_set() or page.is_closed():break
                    report('running','Consent interaction finished; continuing.')
                    continue
                if re.search(r'/careers/apply\b|/application(?:/|\?|$)|/apply\?',page.url,re.I) and page.url not in completed_forms:
                    completed_forms.add(page.url)
                    if not complete_with_consent_resume(page,profile,resume,results,report,stop):break
                    report('review','Application form audited after consent handling. Remaining questions require review; no submission.')
                    break
                fingerprint=(snap['url'],tuple((x['label'],x['value']) for x in snap['controls'][:35]))
                seen[fingerprint]=seen.get(fingerprint,0)+1
                if seen[fingerprint]>3:
                    if not complete_with_consent_resume(page,profile,resume,results,report,stop):break
                    report('review','No page progress after repeated observations; candidate review required.');break
                try:action=decide(snap,profile,bool(resume))
                except Exception as e:
                    report('review','Reasoning API unavailable: '+str(e)[:130]);break
                verb=action.get('action');why=str(action.get('reason',''))[:120]
                if verb in ('finish','wait_user'):
                    if verb=='finish' and ('apply' in page.url or 'application' in snap['text'].lower()):
                        if not complete_with_consent_resume(page,profile,resume,results,report,stop):break
                    report('review' if verb=='finish' else 'waiting_for_user',why or 'Candidate review needed.');break
                idx=action.get('index')
                if not isinstance(idx,int) or idx<0 or idx>=len(snap['controls']):
                    report('review','Invalid model control index; stopped.');break
                control=snap['controls'][idx];loc=locator_for(page,idx)
                if loc is None:report('review','Control disappeared; stopped.');break
                if verb=='click':
                    label=control['label'].strip()
                    if not re.fullmatch(r'(apply|apply now|apply for (?:this )?(?:job|position)|apply to this job|continue|next|start application)',label,re.I):
                        report('review','Unsafe or ambiguous click rejected: '+label);break
                    if control['href'] and not safe_target(page.url,control['href'],same_employer,public_host):
                        report('review','Apply link leads outside verified employer domain; candidate review needed.');break
                    previous=page.url
                    before_pages=list(context.pages)
                    loc.click(timeout=7000)
                    page.wait_for_timeout(1300)
                    new_pages=[p for p in context.pages if p not in before_pages]
                    if new_pages:
                        page=new_pages[-1]
                        page.wait_for_load_state('domcontentloaded',timeout=20000)
                    if not safe_target(url,page.url,same_employer,public_host):
                        report('review','Redirect outside verified employer domain; stopped.');break
                    report('running','Navigated using '+label+'. '+why)
                elif verb=='fill':
                    if control['tag'] not in ('input','textarea') or control['type'] in ('password','checkbox','radio','file','submit','button','hidden'):
                        report('review','Unsupported fill control; stopped.');break
                    value=str(action.get('value',''))
                    if not value or value not in [str(v) for v in profile.values() if isinstance(v,str)]:
                        report('review','Unapproved answer rejected; candidate input required.');break
                    # Identity fields are exclusively handled by the strict
                    # candidate-profile mapper; the model must not guess their target.
                    identity_label=(control.get('label') or '').lower()
                    if any(k in identity_label for k in ('first name','last name','given name','family name','surname','email','e-mail')):
                        fill_known_fields(page,profile,resume,results,report)
                        report('review','Identity field handled by verified profile mapping; review read-back.');break
                    if control['value'].strip():
                        report('review','Field already has a value; avoiding overwrite.');break
                    loc.fill(value,timeout=5000)
                    ok=loc.input_value()==value
                    results.append({'field':control['label'] or control['type'],'result':'verified' if ok else 'needs review'})
                    report('running','Filled and read back '+(control['label'] or control['type'])[:80]+'.')
                elif verb=='upload':
                    if not resume or uploads or control['type']!='file':
                        fill_known_fields(page,profile,resume,results,report);report('review','Resume upload required verification; check review fields.');break
                    loc.set_input_files(resume,timeout=9000);uploads+=1
                    results.append({'field':'resume','result':'attached — verify employer receipt'})
                    report('running','Uploaded approved resume; checking subsequent page.')
                else:
                    report('review','Unknown action rejected.');break
            else:
                fill_known_fields(page,profile,resume,results,report)
                report('review','Maximum reasoning steps reached; candidate review required.')
            if not stop.is_set():
                if sessions[sid]['state'] not in ('review','waiting_for_user'):
                    fill_known_fields(page,profile,resume,results,report)
                    report('review','Stopped for candidate review. No submission performed.')
                # Re-audit after UI settles; React may revert values asynchronously.
                if not page.is_closed() and not privacy_modal_visible(page):
                    try:
                        page.wait_for_timeout(1400)
                        fill_known_fields(page,profile,resume,results,report)
                        report('review','Final identity recheck complete; inspect mismatches before submission.')
                    except Exception as exc:
                        report('review','Final identity recheck failed: '+str(exc)[:100])
                # 5S: keep the review status tied to the *live* employer DOM.
                # Privacy dialogs can rerender and reset inputs after the original
                # form audit. Never upload again or re-run broad form completion.
                from live_identity import inspect as inspect_identity, merge as merge_identity
                was_modal=False
                last_audit=None
                repair_after_consent=False
                while not stop.wait(2.5):
                    if page.is_closed():break
                    try:
                        modal=privacy_modal_visible(page)
                        if modal:
                            if not was_modal:
                                report('waiting_for_consent','Privacy agreement is open. Make your own decision in the employer browser; identity verification is suspended.')
                            was_modal=True
                            continue
                        if was_modal:
                            # A single bounded repair after candidate dismissal.
                            page.wait_for_timeout(1100)
                            if privacy_modal_visible(page):continue
                            repair_after_consent=True
                            was_modal=False
                        audit=inspect_identity(page,profile,repair=repair_after_consent)
                        repair_after_consent=False
                        results[:]=merge_identity(results,audit)
                        signature=tuple((r['field'],r['result']) for r in audit)
                        if signature!=last_audit:
                            mismatch=[r['field'] for r in audit if r['result']!='verified']
                            report('review',('Live identity mismatch: '+', '.join(mismatch)+'. Check employer form; do not submit.' if mismatch else 'Live identity fields match approved candidate profile. Other application questions still need review.'))
                            last_audit=signature
                    except Exception as exc:
                        report('review','Live identity verification unavailable: '+str(exc)[:100])
            context.close()
    except ImportError:report('error','Playwright missing. Install playwright and Chromium.')
    except Exception as e:report('error','Browser agent error: '+str(e)[:180])
    finally:
        if browser:
            try:browser.close()
            except Exception:pass
        if resume:
            try:os.unlink(resume)
            except OSError:pass
        if stop.is_set():report('closed','Session closed. No submission performed.')

from contextlib import nullcontext as _no_context
