#!/usr/bin/env python3
"""Opt-in marketplace adapters. Validate in a test seller account before production.
Never treats pending jobs as success. Credentials are read from environment only.
"""
import os,json,time,urllib.request,urllib.parse,urllib.error,datetime,argparse,fcntl
from core import Engine,DomainError,now
from server import DB
UK='A1F83G8C2ARO7P'
class RemoteError(Exception):pass
def request(method,url,headers=None,body=None,form=False):
 if body is not None:
  body=(urllib.parse.urlencode(body) if form else json.dumps(body)).encode()
 h=dict(headers or {})
 if body is not None:h['Content-Type']='application/x-www-form-urlencoded' if form else 'application/json'
 try:
  with urllib.request.urlopen(urllib.request.Request(url,data=body,headers=h,method=method),timeout=40) as r:
   raw=r.read();return json.loads(raw) if raw else {}
 except urllib.error.HTTPError as ex:
  # Do not record remote response bodies: they can contain buyer data.
  raise RemoteError(f'Marketplace HTTP {ex.code}; check app permissions, listing support and API limits') from None
 except (urllib.error.URLError,TimeoutError):raise RemoteError('Marketplace network request failed') from None

def settings(store):
 p=store['id'].upper().replace('-','_')
 keys=['CLIENT_ID','CLIENT_SECRET','REFRESH_TOKEN']+(['SELLER_ID'] if store['platform']=='amazon' else [])
 cfg={k:os.getenv(p+'_'+k) or os.getenv(store['platform'].upper()+'_'+k) for k in keys}
 if not all(cfg.values()):raise RemoteError('Missing server credentials for '+store['id'])
 return cfg
class Adapter:
 def __init__(self,s):
  self.s=s;c=settings(s);self.seller=c.get('SELLER_ID')
  if s['platform']=='amazon':
   r=request('POST','https://api.amazon.com/auth/o2/token',body={'grant_type':'refresh_token','refresh_token':c['REFRESH_TOKEN'],'client_id':c['CLIENT_ID'],'client_secret':c['CLIENT_SECRET']},form=True)
   self.headers={'x-amz-access-token':r['access_token'],'User-Agent':'StockBridge/0.1 (Language=Python)'};self.base='https://sellingpartnerapi-eu.amazon.com'
  else:
   import base64
   auth=base64.b64encode((c['CLIENT_ID']+':'+c['CLIENT_SECRET']).encode()).decode()
   r=request('POST','https://api.ebay.com/identity/v1/oauth2/token',headers={'Authorization':'Basic '+auth},body={'grant_type':'refresh_token','refresh_token':c['REFRESH_TOKEN']},form=True)
   self.headers={'Authorization':'Bearer '+r['access_token'],'X-EBAY-C-MARKETPLACE-ID':'EBAY_GB','Content-Language':'en-GB'};self.base='https://api.ebay.com'
 def get(self,path,params=None):return request('GET',self.base+path+('?' + urllib.parse.urlencode(params) if params else ''),self.headers)
 def stock(self,m,qty):
  if self.s['platform']=='amazon':
   path='/listings/2021-08-01/items/'+urllib.parse.quote(self.seller,safe='')+'/'+urllib.parse.quote(m['listing_sku'],safe='')
   r=request('PATCH',self.base+path+'?marketplaceIds='+UK,self.headers,{'productType':'PRODUCT','patches':[{'op':'replace','path':'/attributes/fulfillment_availability','value':[{'fulfillment_channel_code':'DEFAULT','quantity':qty}]}]})
   if r.get('status')!='ACCEPTED' or any(i.get('severity')=='ERROR' for i in r.get('issues',[])):raise RemoteError('Amazon did not accept the quantity update')
   return 'submitted' # ACCEPTED is not proof that the live listing has updated.
  if not m['offer_id']:raise RemoteError('eBay Inventory API offer ID required; legacy listings need migration or another adapter')
  r=request('POST',self.base+'/sell/inventory/v1/bulk_update_price_quantity',self.headers,{'requests':[{'sku':m['listing_sku'],'shipToLocationAvailability':{'quantity':qty},'offers':[{'offerId':m['offer_id'],'availableQuantity':qty}]}]})
  responses=r.get('responses',[])
  if not responses or any(not 200<=x.get('statusCode',500)<300 for x in responses):raise RemoteError('eBay rejected inventory update')
  return 'submitted'
 def orders(self,since):
  if self.s['platform']=='amazon':
   params={'MarketplaceIds':UK,'LastUpdatedAfter':since,'FulfillmentChannels':'MFN','MaxResultsPerPage':100}
   while True:
    payload=self.get('/orders/v0/orders',params).get('payload',{})
    for o in payload.get('Orders',[]):
     status=o['OrderStatus']
     if status in ['Pending','PendingAvailability']:continue
     items=[];ip={}
     while status!='Canceled':
      part=self.get('/orders/v0/orders/'+urllib.parse.quote(o['AmazonOrderId'],safe='')+'/orderItems',ip).get('payload',{})
      items += [{'sku':i['SellerSKU'],'qty':i['QuantityOrdered']} for i in part.get('OrderItems',[])]
      if not part.get('NextToken'):break
      ip={'NextToken':part['NextToken']};time.sleep(2)
     yield o['AmazonOrderId'],status=='Canceled',items
    if not payload.get('NextToken'):break
    params={'NextToken':payload['NextToken']};time.sleep(2)
  else:
   offset=0
   while True:
    r=self.get('/sell/fulfillment/v1/order',{'filter':f'lastmodifieddate:[{since}..]','limit':100,'offset':offset})
    for o in r.get('orders',[]):
     cancel=o.get('cancelStatus',{}).get('cancelState')=='CANCELED'
     if o.get('orderPaymentStatus')!='PAID' and not cancel:continue
     yield o['orderId'],cancel,[{'sku':i.get('sku',''),'qty':i['quantity']} for i in o.get('lineItems',[])]
    if not r.get('next'):break
    offset+=100

def run(args):
 e=Engine(DB)
 try:
  for s in e.rows('stores'):
   key=s['id'].upper().replace('-','_')+'_LIVE'
   if os.getenv(key)!='1':continue
   try:
    a=Adapter(s)
    if args.import_orders:
     row=e.db.execute('SELECT cursor FROM sync_state WHERE store=?',(s['id'],)).fetchone();since=row[0] if row else args.since
     if not since:raise RemoteError('First import requires --since matching your stock cutover time')
     until=now()
     for ref,cancel,items in a.orders(since):
      existing=e.db.execute('SELECT id,status FROM orders WHERE store=? AND external_id=?',(s['id'],ref)).fetchone()
      if cancel:
       if existing and existing['status']=='open':e.apply('cancel',{'id':existing['id']})
       elif existing and existing['status']=='shipped':raise RemoteError('Remote cancellation of a locally shipped order requires manual reconciliation')
       continue
      if existing:continue
      mapped=[]
      for i in items:
       m=e.db.execute('SELECT product FROM mappings WHERE store=? AND listing_sku=?',(s['id'],i['sku'])).fetchone()
       if not m:raise RemoteError('Unmapped order SKU; import cursor not advanced')
       mapped.append({'product':m[0],'qty':i['qty']})
      e.apply('order',{'external_id':ref,'store':s['id'],'items':mapped})
     # 10-minute overlap covers delayed updates. Unique(store,external_id) deduplicates imports.
     cursor=(datetime.datetime.fromisoformat(until)-datetime.timedelta(minutes=10)).isoformat()
     e.db.execute('INSERT INTO sync_state VALUES(?,?) ON CONFLICT(store) DO UPDATE SET cursor=excluded.cursor',(s['id'],cursor))
    if args.push_stock:
     if os.getenv('ALLOW_MARKETPLACE_WRITES')!='1':raise RemoteError('Outbound writes disabled; set ALLOW_MARKETPLACE_WRITES=1 after validation')
     for job in e.db.execute("SELECT * FROM jobs WHERE store=? AND status IN ('pending','failed')",(s['id'],)).fetchall():
      try:
       p=e.db.execute('SELECT * FROM products WHERE id=?',(job['product'],)).fetchone()
       ms=e.db.execute('SELECT * FROM mappings WHERE store=? AND product=?',(s['id'],job['product'])).fetchall()
       if not ms:
        e.db.execute('DELETE FROM jobs WHERE id=?',(job['id'],));continue
       qty=max(0,p['stock']-p['safety'])
       for m in ms:a.stock(m,qty);time.sleep(2)
       e.db.execute("UPDATE jobs SET status='submitted',error='' WHERE id=? AND updated=?",(job['id'],job['updated']))
      except RemoteError as ex:e.db.execute("UPDATE jobs SET status='failed',error=? WHERE id=? AND updated=?",(str(ex),job['id'],job['updated']))
    print(s['id']+': cycle completed')
   except (RemoteError,DomainError,KeyError,ValueError) as ex:
    msg=str(ex) if isinstance(ex,(RemoteError,DomainError)) else 'Unexpected API response; inspect connector compatibility'
    e.audit('Sync needs attention',s['id']+': '+msg);print(s['id']+': '+msg)
 finally:e.close()
if __name__=='__main__':
 p=argparse.ArgumentParser();p.add_argument('--import-orders',action='store_true');p.add_argument('--push-stock',action='store_true');p.add_argument('--since',help='ISO8601 UTC inventory cutover, e.g. 2026-11-14T18:00:00Z');p.add_argument('--loop',type=int,default=0,help='Repeat interval seconds, minimum 60')
 args=p.parse_args()
 if not (args.import_orders or args.push_stock):p.error('Choose --import-orders and/or --push-stock')
 if args.since:
  try:datetime.datetime.fromisoformat(args.since.replace('Z','+00:00'))
  except ValueError:p.error('--since must be an ISO8601 datetime')
 lock=open(DB+'.sync.lock','w')
 try:fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
 except BlockingIOError:raise SystemExit('Another sync worker is running')
 while True:
  run(args)
  if not args.loop:break
  time.sleep(max(60,args.loop))
