import os, re, json, hmac, hashlib, secrets, asyncio, smtplib, ssl
from datetime import datetime, timedelta, timezone
from email.message import EmailMessage
from typing import Optional

import httpx
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from pydantic import BaseModel, EmailStr
from sqlalchemy import create_engine, Column, Integer, String, Float, Boolean, DateTime, Text, ForeignKey, inspect, text
from sqlalchemy.orm import declarative_base, sessionmaker
from sqlalchemy.exc import IntegrityError
try:
    import dns.resolver
except Exception:
    dns = None

APP_NAME='Social Dynamics AI Suite'
APP_SECRET=os.getenv('APP_SECRET','dev-'+secrets.token_urlsafe(24))
BASE_URL=os.getenv('BASE_URL','http://localhost:8120').rstrip('/')
DATABASE_URL=os.getenv('DATABASE_URL','sqlite:///./data/suite.db')
SUPPORT_EMAIL=os.getenv('SUPPORT_EMAIL','support@socialdynamics.digital')
ADMIN_EMAIL=os.getenv('ADMIN_EMAIL','admin@admin.socialdynamics.digital').lower().strip()
ADMIN_PASSWORD=os.getenv('ADMIN_PASSWORD','Admin@123!')
COOKIE_SECURE=os.getenv('COOKIE_SECURE','false').lower()=='true'

STRIPE_SECRET_KEY=os.getenv('STRIPE_SECRET_KEY','')
STRIPE_WEBHOOK_SECRET=os.getenv('STRIPE_WEBHOOK_SECRET','')
STRIPE_PRICES={
 'starter':os.getenv('STRIPE_PRICE_STARTER',''),
 'growth':os.getenv('STRIPE_PRICE_GROWTH',''),
 'agency':os.getenv('STRIPE_PRICE_AGENCY',''),
 'scale':os.getenv('STRIPE_PRICE_SCALE','')}

RAZORPAY_KEY_ID=os.getenv('RAZORPAY_KEY_ID','')
RAZORPAY_KEY_SECRET=os.getenv('RAZORPAY_KEY_SECRET','')
RAZORPAY_WEBHOOK_SECRET=os.getenv('RAZORPAY_WEBHOOK_SECRET','')
RAZORPAY_PLANS={
 'starter':os.getenv('RAZORPAY_PLAN_STARTER',''),
 'growth':os.getenv('RAZORPAY_PLAN_GROWTH',''),
 'agency':os.getenv('RAZORPAY_PLAN_AGENCY',''),
 'scale':os.getenv('RAZORPAY_PLAN_SCALE','')}
RAZORPAY_TOTAL_COUNT=int(os.getenv('RAZORPAY_TOTAL_COUNT','120'))
RESEND_API_KEY=os.getenv('RESEND_API_KEY','')
SENDGRID_API_KEY=os.getenv('SENDGRID_API_KEY','')
EMAIL_FROM=os.getenv('EMAIL_FROM','Social Dynamics <notifications@socialdynamics.digital>')
SMTP_HOST=os.getenv('SMTP_HOST',''); SMTP_PORT=int(os.getenv('SMTP_PORT','587')); SMTP_USER=os.getenv('SMTP_USER',''); SMTP_PASSWORD=os.getenv('SMTP_PASSWORD',''); SMTP_TLS=os.getenv('SMTP_TLS','true').lower()=='true'
OPENAI_API_KEY=os.getenv('OPENAI_API_KEY',''); OPENAI_MODEL=os.getenv('OPENAI_MODEL','gpt-5-mini')
GOOGLE_OAUTH_CLIENT_ID=os.getenv('GOOGLE_OAUTH_CLIENT_ID',''); GOOGLE_OAUTH_CLIENT_SECRET=os.getenv('GOOGLE_OAUTH_CLIENT_SECRET','')
META_OAUTH_APP_ID=os.getenv('META_OAUTH_APP_ID',''); META_OAUTH_APP_SECRET=os.getenv('META_OAUTH_APP_SECRET','')
GOOGLE_GBP_CLIENT_ID=os.getenv('GOOGLE_GBP_CLIENT_ID',''); GOOGLE_GBP_CLIENT_SECRET=os.getenv('GOOGLE_GBP_CLIENT_SECRET','')
GOOGLE_ADS_DEVELOPER_TOKEN=os.getenv('GOOGLE_ADS_DEVELOPER_TOKEN',''); META_ACCESS_TOKEN=os.getenv('META_ACCESS_TOKEN','')
TWILIO_ACCOUNT_SID=os.getenv('TWILIO_ACCOUNT_SID',''); TWILIO_AUTH_TOKEN=os.getenv('TWILIO_AUTH_TOKEN',''); EXOTEL_SID=os.getenv('EXOTEL_SID',''); EXOTEL_API_KEY=os.getenv('EXOTEL_API_KEY','')

TRIAL_DAYS=14
PAST_DUE_GRACE_DAYS=3
PLANS={
 'starter':{'name':'Starter','price':99,'contacts':5000,'email_monthly':10000,'gbp_locations':3,'ai_actions':1500,'team_users':3},
 'growth':{'name':'Growth','price':249,'contacts':25000,'email_monthly':100000,'gbp_locations':15,'ai_actions':10000,'team_users':10},
 'agency':{'name':'Agency','price':499,'contacts':100000,'email_monthly':500000,'gbp_locations':75,'ai_actions':50000,'team_users':30},
 'scale':{'name':'Scale','price':899,'contacts':500000,'email_monthly':2000000,'gbp_locations':250,'ai_actions':200000,'team_users':100}}

connect_args={'check_same_thread':False} if DATABASE_URL.startswith('sqlite') else {}
engine=create_engine(DATABASE_URL,connect_args=connect_args,pool_pre_ping=True)
SessionLocal=sessionmaker(bind=engine,autoflush=False,autocommit=False)
Base=declarative_base()
def utcnow(): return datetime.now(timezone.utc).replace(tzinfo=None)

class User(Base):
    __tablename__='users'; id=Column(Integer,primary_key=True); name=Column(String(160),nullable=False); company=Column(String(200),default=''); email=Column(String(240),unique=True,nullable=False,index=True); password_hash=Column(String(400),nullable=False); role=Column(String(30),default='user'); active=Column(Boolean,default=True); created_at=Column(DateTime,default=utcnow); last_login_at=Column(DateTime,nullable=True)
class Subscription(Base):
    __tablename__='subscriptions'; id=Column(Integer,primary_key=True); user_id=Column(Integer,ForeignKey('users.id'),unique=True,nullable=False,index=True); plan=Column(String(40),default='trial'); status=Column(String(40),default='trialing'); trial_end=Column(DateTime,nullable=True); current_period_end=Column(DateTime,nullable=True); grace_until=Column(DateTime,nullable=True); suspended=Column(Boolean,default=False); billing_gateway=Column(String(30),default=''); stripe_customer_id=Column(String(160),default=''); stripe_subscription_id=Column(String(160),default=''); razorpay_customer_id=Column(String(160),default=''); razorpay_subscription_id=Column(String(160),default=''); cancel_at_period_end=Column(Boolean,default=False); last_payment_at=Column(DateTime,nullable=True); updated_at=Column(DateTime,default=utcnow)
class Payment(Base):
    __tablename__='payments'; id=Column(Integer,primary_key=True); user_id=Column(Integer,ForeignKey('users.id'),nullable=False,index=True); amount=Column(Float,default=0); currency=Column(String(10),default='USD'); status=Column(String(40),default='paid'); external_id=Column(String(200),default=''); plan=Column(String(40),default=''); provider=Column(String(30),default='legacy'); created_at=Column(DateTime,default=utcnow)
class Notification(Base):
    __tablename__='notifications'; id=Column(Integer,primary_key=True); user_id=Column(Integer,ForeignKey('users.id'),nullable=False,index=True); kind=Column(String(80),default='system'); title=Column(String(220),default=''); body=Column(Text,default=''); delivery=Column(String(30),default='in_app'); read=Column(Boolean,default=False); created_at=Column(DateTime,default=utcnow)
class Inquiry(Base):
    __tablename__='inquiries'; id=Column(Integer,primary_key=True); name=Column(String(160),nullable=False); email=Column(String(240),nullable=False); company=Column(String(200),default=''); message=Column(Text,default=''); status=Column(String(40),default='new'); created_at=Column(DateTime,default=utcnow)
class Usage(Base):
    __tablename__='usage'; id=Column(Integer,primary_key=True); user_id=Column(Integer,ForeignKey('users.id'),nullable=False,index=True); product=Column(String(60),nullable=False); metric=Column(String(80),nullable=False); quantity=Column(Integer,default=1); created_at=Column(DateTime,default=utcnow)
class EngageContact(Base):
    __tablename__='engage_contacts'; id=Column(Integer,primary_key=True); user_id=Column(Integer,ForeignKey('users.id'),index=True,nullable=False); name=Column(String(160),nullable=False); phone=Column(String(80),default=''); email=Column(String(240),default=''); channel=Column(String(50),default='whatsapp'); stage=Column(String(60),default='New'); created_at=Column(DateTime,default=utcnow)
class EngageConversation(Base):
    __tablename__='engage_conversations'; id=Column(Integer,primary_key=True); user_id=Column(Integer,ForeignKey('users.id'),index=True,nullable=False); contact_name=Column(String(160),nullable=False); channel=Column(String(50),default='whatsapp'); message=Column(Text,default=''); direction=Column(String(20),default='inbound'); created_at=Column(DateTime,default=utcnow)
class GBPLocation(Base):
    __tablename__='gbp_locations'; id=Column(Integer,primary_key=True); user_id=Column(Integer,ForeignKey('users.id'),index=True,nullable=False); name=Column(String(220),nullable=False); city=Column(String(160),default=''); category=Column(String(160),default=''); completeness=Column(Integer,default=75); rating=Column(Float,default=4.2); reviews=Column(Integer,default=25); response_rate=Column(Integer,default=60); posts_30d=Column(Integer,default=2); photos=Column(Integer,default=20); nap_consistent=Column(Boolean,default=True); website=Column(Boolean,default=True); created_at=Column(DateTime,default=utcnow)
class GBPAudit(Base):
    __tablename__='gbp_audits'; id=Column(Integer,primary_key=True); user_id=Column(Integer,ForeignKey('users.id'),index=True,nullable=False); location_id=Column(Integer,nullable=False); overall=Column(Integer,default=0); profile=Column(Integer,default=0); reviews_score=Column(Integer,default=0); content=Column(Integer,default=0); local_seo=Column(Integer,default=0); conversion=Column(Integer,default=0); issues_json=Column(Text,default='[]'); created_at=Column(DateTime,default=utcnow)
class MailContact(Base):
    __tablename__='mail_contacts'; id=Column(Integer,primary_key=True); user_id=Column(Integer,ForeignKey('users.id'),index=True,nullable=False); email=Column(String(240),nullable=False); first_name=Column(String(120),default=''); company=Column(String(180),default=''); consent=Column(Boolean,default=True); verification=Column(String(50),default='unverified'); created_at=Column(DateTime,default=utcnow)
class MailCampaign(Base):
    __tablename__='mail_campaigns'; id=Column(Integer,primary_key=True); user_id=Column(Integer,ForeignKey('users.id'),index=True,nullable=False); name=Column(String(220),nullable=False); subject=Column(String(260),default=''); status=Column(String(40),default='draft'); scheduled_at=Column(DateTime,nullable=True); audience=Column(Integer,default=0); sent=Column(Integer,default=0); created_at=Column(DateTime,default=utcnow)
class CRMLead(Base):
    __tablename__='crm_leads'; id=Column(Integer,primary_key=True); user_id=Column(Integer,ForeignKey('users.id'),index=True,nullable=False); name=Column(String(180),nullable=False); company=Column(String(180),default=''); email=Column(String(240),default=''); phone=Column(String(80),default=''); source=Column(String(80),default='Manual'); stage=Column(String(80),default='New'); value=Column(Float,default=0); score=Column(Integer,default=50); owner=Column(String(160),default=''); created_at=Column(DateTime,default=utcnow); updated_at=Column(DateTime,default=utcnow)
class CRMTask(Base):
    __tablename__='crm_tasks'; id=Column(Integer,primary_key=True); user_id=Column(Integer,ForeignKey('users.id'),index=True,nullable=False); lead_id=Column(Integer,nullable=True); title=Column(String(240),nullable=False); due_at=Column(DateTime,nullable=True); status=Column(String(40),default='open'); created_at=Column(DateTime,default=utcnow)
class AdminAudit(Base):
    __tablename__='admin_audit'; id=Column(Integer,primary_key=True); admin_user_id=Column(Integer,nullable=False); target_user_id=Column(Integer,nullable=True); action=Column(String(160),default=''); details=Column(Text,default=''); created_at=Column(DateTime,default=utcnow)

Base.metadata.create_all(engine)

def ensure_billing_schema_extensions():
    # Lightweight migration so an existing local database can be reused after upgrading
    # from the Stripe-only build. Production deployments should use Alembic migrations.
    wanted={
        'subscriptions':{
            'billing_gateway':"VARCHAR(30) DEFAULT ''",
            'razorpay_customer_id':"VARCHAR(160) DEFAULT ''",
            'razorpay_subscription_id':"VARCHAR(160) DEFAULT ''",
            'cancel_at_period_end':"BOOLEAN DEFAULT FALSE",
        },
        'payments':{
            'provider':"VARCHAR(30) DEFAULT 'legacy'",
        }
    }
    with engine.begin() as conn:
        for table,cols in wanted.items():
            existing={c['name'] for c in inspect(engine).get_columns(table)}
            for name,ddl in cols.items():
                if name not in existing:
                    conn.execute(text(f'ALTER TABLE {table} ADD COLUMN {name} {ddl}'))

ensure_billing_schema_extensions()
def hash_password(password):
    salt=secrets.token_bytes(16); dk=hashlib.pbkdf2_hmac('sha256',password.encode(),salt,220000); return salt.hex()+':'+dk.hex()
def verify_password(password,stored):
    try:
        s,h=stored.split(':',1); dk=hashlib.pbkdf2_hmac('sha256',password.encode(),bytes.fromhex(s),220000); return hmac.compare_digest(dk.hex(),h)
    except Exception: return False

def get_user(db,request):
    uid=request.session.get('user_id')
    if not uid: raise HTTPException(401,'Login required')
    u=db.query(User).filter(User.id==uid,User.active==True).first()
    if not u: raise HTTPException(401,'Login required')
    return u
def get_sub(db,user_id): return db.query(Subscription).filter(Subscription.user_id==user_id).first()
def subscription_state(db,user):
    if user.role=='admin': return {'allowed':True,'status':'admin','plan':'admin','suspended':False,'days_left':None}
    sub=get_sub(db,user.id)
    if not sub: return {'allowed':False,'status':'none','plan':'none','suspended':True}
    now=utcnow()
    if sub.status=='trialing' and sub.trial_end and sub.trial_end<=now:
        sub.status='trial_expired'; sub.suspended=True; sub.updated_at=now; db.commit()
    if sub.status=='active' and sub.current_period_end and sub.current_period_end<=now:
        sub.status='past_due'; sub.grace_until=now+timedelta(days=PAST_DUE_GRACE_DAYS); sub.updated_at=now; db.commit()
    if sub.status=='past_due' and sub.grace_until and sub.grace_until<=now:
        sub.status='suspended'; sub.suspended=True; sub.updated_at=now; db.commit()
    end=sub.trial_end if sub.status=='trialing' else sub.current_period_end
    days_left=max(0,(end.date()-now.date()).days) if end else None
    grace_active=(sub.status=='past_due' and sub.grace_until and sub.grace_until>now)
    return {'allowed':(sub.status in ('trialing','active') or grace_active) and not sub.suspended,'status':sub.status,'plan':sub.plan,'billing_gateway':sub.billing_gateway or '', 'suspended':sub.suspended,'trial_end':sub.trial_end.isoformat() if sub.trial_end else None,'current_period_end':sub.current_period_end.isoformat() if sub.current_period_end else None,'grace_until':sub.grace_until.isoformat() if sub.grace_until else None,'days_left':days_left,'limits':PLANS.get(sub.plan,{})}

def create_notification(db,user_id,kind,title,body,delivery='in_app'):
    old=db.query(Notification).filter(Notification.user_id==user_id,Notification.kind==kind).order_by(Notification.created_at.desc()).first()
    if old and old.created_at>utcnow()-timedelta(hours=20): return False
    db.add(Notification(user_id=user_id,kind=kind,title=title,body=body,delivery=delivery)); db.commit(); return True

def email_send(to,subject,html,text=''):
    text=text or re.sub('<[^>]+>','',html)
    if RESEND_API_KEY:
        try:
            r=httpx.post('https://api.resend.com/emails',headers={'Authorization':f'Bearer {RESEND_API_KEY}','Content-Type':'application/json'},json={'from':EMAIL_FROM,'to':[to],'subject':subject,'html':html,'text':text},timeout=15); return r.status_code<300,f'resend:{r.status_code}'
        except Exception as e: return False,str(e)
    if SENDGRID_API_KEY:
        try:
            from_addr=EMAIL_FROM.split('<')[-1].strip('> ') if '<' in EMAIL_FROM else EMAIL_FROM
            r=httpx.post('https://api.sendgrid.com/v3/mail/send',headers={'Authorization':f'Bearer {SENDGRID_API_KEY}','Content-Type':'application/json'},json={'personalizations':[{'to':[{'email':to}]}],'from':{'email':from_addr},'subject':subject,'content':[{'type':'text/html','value':html}]},timeout=15); return r.status_code<300,f'sendgrid:{r.status_code}'
        except Exception as e: return False,str(e)
    if SMTP_HOST:
        try:
            msg=EmailMessage(); msg['From']=EMAIL_FROM; msg['To']=to; msg['Subject']=subject; msg.set_content(text); msg.add_alternative(html,subtype='html')
            with smtplib.SMTP(SMTP_HOST,SMTP_PORT,timeout=15) as s:
                if SMTP_TLS: s.starttls(context=ssl.create_default_context())
                if SMTP_USER: s.login(SMTP_USER,SMTP_PASSWORD)
                s.send_message(msg)
            return True,'smtp'
        except Exception as e: return False,str(e)
    return False,'not_configured'

def usage(db,user_id,product,metric,qty=1): db.add(Usage(user_id=user_id,product=product,metric=metric,quantity=qty)); db.commit()

def seed():
    db=SessionLocal()
    try:
        if not db.query(User).filter(User.email==ADMIN_EMAIL).first():
            db.add(User(name='Social Dynamics Admin',company='Social Dynamics',email=ADMIN_EMAIL,password_hash=hash_password(ADMIN_PASSWORD),role='admin')); db.commit()
        demo=db.query(User).filter(User.email=='demo@demo.socialdynamics.digital').first()
        if not demo:
            demo=User(name='Demo User',company='Demo Company',email='demo@demo.socialdynamics.digital',password_hash=hash_password('Demo@123!')); db.add(demo); db.commit(); db.refresh(demo)
            db.add(Subscription(user_id=demo.id,plan='growth',status='active',current_period_end=utcnow()+timedelta(days=30),last_payment_at=utcnow())); db.commit()
            db.add_all([
                EngageContact(user_id=demo.id,name='Aarav Sharma',phone='+91 90000 00001',email='aarav@example.com',channel='whatsapp',stage='Qualified'),
                EngageContact(user_id=demo.id,name='Maya Patel',phone='+91 90000 00002',email='maya@example.com',channel='instagram',stage='New'),
                EngageConversation(user_id=demo.id,contact_name='Aarav Sharma',channel='whatsapp',message='Please send the proposal again.',direction='inbound'),
                GBPLocation(user_id=demo.id,name='Absolute Moving Demo',city='Oklahoma City',category='Moving Company',completeness=78,rating=4.5,reviews=86,response_rate=63,posts_30d=1,photos=28,nap_consistent=True,website=True),
                MailContact(user_id=demo.id,email='sam@example.com',first_name='Sam',company='Example Co',consent=True,verification='valid'),
                MailCampaign(user_id=demo.id,name='August Nurture',subject='Quick follow-up',status='scheduled',scheduled_at=utcnow()+timedelta(days=1),audience=420),
                CRMLead(user_id=demo.id,name='Noah Williams',company='Prime Movers',email='noah@example.com',phone='+1 302 555 0181',source='Meta Ads',stage='Qualified',value=12500,score=82,owner='Shawn'),
                CRMLead(user_id=demo.id,name='Sophia Brown',company='Bright Dental',email='sophia@example.com',phone='+1 302 555 0182',source='Google Ads',stage='Proposal',value=9800,score=76,owner='Michael')]); db.commit()
    finally: db.close()
seed()

app=FastAPI(title=APP_NAME,version='1.0.0')
app.mount('/static',StaticFiles(directory='static'),name='static')

@app.on_event('startup')
async def startup(): asyncio.create_task(subscription_worker())
async def subscription_worker():
    while True:
        db=SessionLocal()
        try:
            now=utcnow()
            for sub in db.query(Subscription).all():
                user=db.query(User).filter(User.id==sub.user_id).first()
                if not user: continue
                if sub.status=='trialing' and sub.trial_end:
                    days=(sub.trial_end.date()-now.date()).days
                    if days in (7,3,1):
                        title=f'Your free trial ends in {days} day'+('s' if days!=1 else '')
                        if create_notification(db,user.id,f'trial_{days}d',title,'Choose a paid plan to keep all four products active.'):
                            email_send(user.email,title,f'<p>Your Social Dynamics AI Suite trial ends in {days} day(s).</p><p><a href="{BASE_URL}/app#billing">Choose a plan</a></p>')
                    if sub.trial_end<=now:
                        sub.status='trial_expired'; sub.suspended=True; sub.updated_at=now; db.commit()
                        if create_notification(db,user.id,'trial_expired','Trial expired — subscription required','Product access is suspended until you subscribe.'):
                            email_send(user.email,'Your free trial has ended',f'<p>Your 14-day trial ended and product access is suspended.</p><p><a href="{BASE_URL}/app#billing">Subscribe to reactivate</a></p>')
                elif sub.status=='active' and sub.current_period_end:
                    days=(sub.current_period_end.date()-now.date()).days
                    if days in (7,3,1):
                        title=f'Subscription renewal in {days} day'+('s' if days!=1 else '')
                        if create_notification(db,user.id,f'renewal_{days}d',title,'Your subscription is approaching renewal.'):
                            email_send(user.email,title,f'<p>Your subscription renews in {days} day(s).</p>')
                    if sub.current_period_end<=now:
                        sub.status='past_due'; sub.grace_until=now+timedelta(days=PAST_DUE_GRACE_DAYS); sub.updated_at=now; db.commit()
                elif sub.status=='past_due' and sub.grace_until and sub.grace_until<=now:
                    sub.status='suspended'; sub.suspended=True; sub.updated_at=now; db.commit()
                    if create_notification(db,user.id,'payment_suspended','Account suspended','Payment was not completed before the grace period ended.'):
                        email_send(user.email,'Account suspended',f'<p>Payment remains overdue. <a href="{BASE_URL}/app#billing">Restore access</a></p>')
        except Exception: pass
        finally: db.close()
        await asyncio.sleep(60)

PAGES={'/':'index.html','/login':'login.html','/signup':'signup.html','/admin-login':'admin-login.html','/app':'app.html','/admin':'admin.html','/about':'about.html','/contact':'contact.html','/privacy':'privacy.html','/terms':'terms.html'}
for path,filename in PAGES.items():
    def handler_factory(name):
        async def handler(): return FileResponse('static/'+name)
        return handler
    app.add_api_route(path,handler_factory(filename),methods=['GET'],include_in_schema=False)
@app.get('/robots.txt',include_in_schema=False)
def robots(): return PlainTextResponse(f'User-agent: *\nAllow: /\nSitemap: {BASE_URL}/sitemap.xml\n')
@app.get('/sitemap.xml',include_in_schema=False)
def sitemap():
    urls=['','about','contact','privacy','terms','login','signup']; body=''.join(f'<url><loc>{BASE_URL}/{u}</loc></url>' for u in urls); return HTMLResponse('<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'+body+'</urlset>',media_type='application/xml')
PUBLIC_API=('/api/auth/','/api/contact','/api/plans','/api/health','/webhooks/')
@app.middleware('http')
async def api_guard(request:Request,call_next):
    path=request.url.path
    if not path.startswith('/api/') or any(path.startswith(x) for x in PUBLIC_API): return await call_next(request)
    uid=request.session.get('user_id')
    if not uid: return JSONResponse({'error':'unauthorized'},status_code=401)
    db=SessionLocal()
    try:
        user=db.query(User).filter(User.id==uid,User.active==True).first()
        if not user: return JSONResponse({'error':'unauthorized'},status_code=401)
        if path.startswith('/api/admin/'):
            if user.role!='admin': return JSONResponse({'error':'forbidden'},status_code=403)
            return await call_next(request)
        if path in ('/api/me','/api/subscription','/api/notifications') or path.startswith('/api/billing/'):
            return await call_next(request)
        st=subscription_state(db,user)
        if not st.get('allowed'): return JSONResponse({'error':'subscription_required','subscription':st},status_code=402)
    finally: db.close()
    return await call_next(request)

# SessionMiddleware must wrap the subscription guard so request.session is available there.
app.add_middleware(SessionMiddleware,secret_key=APP_SECRET,max_age=60*60*24*14,same_site='lax',https_only=COOKIE_SECURE)

class SignupIn(BaseModel): name:str; company:str=''; email:EmailStr; password:str
class LoginIn(BaseModel): email:EmailStr; password:str
class PlanIn(BaseModel): plan:str; gateway:str='stripe'
class ContactIn(BaseModel): name:str; email:EmailStr; company:str=''; message:str

@app.get('/api/health')
def health(): return {'ok':True,'app':APP_NAME}
@app.get('/api/plans')
def plans(): return {'trial':{'days':TRIAL_DAYS,'card_required':False},'plans':PLANS}

@app.post('/api/auth/signup')
def signup(data:SignupIn,request:Request):
    if len(data.password)<8: raise HTTPException(400,'Password must be at least 8 characters')
    db=SessionLocal()
    try:
        email=str(data.email).lower().strip()
        if db.query(User).filter(User.email==email).first(): raise HTTPException(400,'An account with this email already exists')
        u=User(name=data.name.strip(),company=data.company.strip(),email=email,password_hash=hash_password(data.password)); db.add(u); db.commit(); db.refresh(u)
        db.add(Subscription(user_id=u.id,plan='trial',status='trialing',trial_end=utcnow()+timedelta(days=TRIAL_DAYS))); db.commit(); request.session['user_id']=u.id
        create_notification(db,u.id,'welcome','Welcome to your 14-day trial','All four products are active. No card is required during the trial.')
        email_send(u.email,'Welcome to Social Dynamics AI Suite',f'<h2>Welcome, {u.name}.</h2><p>Your 14-day free trial is active — no card required.</p><p><a href="{BASE_URL}/app">Open your dashboard</a></p>')
        return {'ok':True,'redirect':'/app'}
    except IntegrityError:
        db.rollback(); raise HTTPException(400,'Account already exists')
    finally: db.close()

@app.post('/api/auth/login')
def login(data:LoginIn,request:Request):
    db=SessionLocal()
    try:
        u=db.query(User).filter(User.email==str(data.email).lower().strip()).first()
        if not u or not verify_password(data.password,u.password_hash): raise HTTPException(401,'Invalid email or password')
        if not u.active: raise HTTPException(403,'Account disabled')
        u.last_login_at=utcnow(); db.commit(); request.session['user_id']=u.id
        return {'ok':True,'role':u.role,'redirect':'/admin' if u.role=='admin' else '/app'}
    finally: db.close()
@app.post('/api/auth/logout')
def logout(request:Request): request.session.clear(); return {'ok':True}

@app.get('/api/me')
def me(request:Request):
    db=SessionLocal()
    try:
        u=get_user(db,request); return {'id':u.id,'name':u.name,'company':u.company,'email':u.email,'role':u.role,'created_at':u.created_at.isoformat(),'subscription':subscription_state(db,u)}
    finally: db.close()

@app.get('/api/subscription')
def sub_api(request:Request):
    db=SessionLocal()
    try:
        u=get_user(db,request); s=get_sub(db,u.id); pays=db.query(Payment).filter(Payment.user_id==u.id).order_by(Payment.created_at.desc()).limit(30).all()
        return {'state':subscription_state(db,u),'plans':PLANS,'stripe_configured':bool(STRIPE_SECRET_KEY),'razorpay_configured':bool(RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET),'gateways':{'stripe':{'configured':bool(STRIPE_SECRET_KEY),'plans':{k:bool(v) for k,v in STRIPE_PRICES.items()}},'razorpay':{'configured':bool(RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET),'plans':{k:bool(v) for k,v in RAZORPAY_PLANS.items()}}},'subscription':{'plan':s.plan,'status':s.status,'trial_end':s.trial_end.isoformat() if s.trial_end else None,'current_period_end':s.current_period_end.isoformat() if s.current_period_end else None,'billing_gateway':s.billing_gateway or '', 'stripe_customer_id':bool(s.stripe_customer_id),'razorpay_subscription_id':bool(s.razorpay_subscription_id),'cancel_at_period_end':bool(s.cancel_at_period_end)} if s else None,'payments':[{'amount':p.amount,'currency':p.currency,'status':p.status,'plan':p.plan,'provider':p.provider or 'legacy','external_id':p.external_id,'created_at':p.created_at.isoformat()} for p in pays]}
    finally: db.close()

@app.get('/api/notifications')
def note_api(request:Request):
    db=SessionLocal()
    try:
        u=get_user(db,request); rows=db.query(Notification).filter(Notification.user_id==u.id).order_by(Notification.created_at.desc()).limit(30).all(); return [{'id':x.id,'kind':x.kind,'title':x.title,'body':x.body,'delivery':x.delivery,'read':x.read,'created_at':x.created_at.isoformat()} for x in rows]
    finally: db.close()

def stripe_request(path,data):
    if not STRIPE_SECRET_KEY: raise HTTPException(503,'Stripe is not configured')
    r=httpx.post('https://api.stripe.com/v1/'+path,headers={'Authorization':f'Bearer {STRIPE_SECRET_KEY}'},data=data,timeout=20)
    try: payload=r.json()
    except Exception: payload={'error':{'message':r.text}}
    if r.status_code>=300: raise HTTPException(502,payload.get('error',{}).get('message','Stripe request failed'))
    return payload

def razorpay_request(method,path,payload=None):
    if not RAZORPAY_KEY_ID or not RAZORPAY_KEY_SECRET: raise HTTPException(503,'Razorpay is not configured')
    r=httpx.request(method,'https://api.razorpay.com/v1/'+path,auth=(RAZORPAY_KEY_ID,RAZORPAY_KEY_SECRET),json=payload,timeout=20)
    try: data=r.json()
    except Exception: data={'error':{'description':r.text}}
    if r.status_code>=300:
        err=data.get('error',{}) if isinstance(data,dict) else {}
        raise HTTPException(502,err.get('description') or err.get('reason') or 'Razorpay request failed')
    return data

@app.post('/api/billing/checkout')
def checkout(data:PlanIn,request:Request):
    if data.plan not in PLANS: raise HTTPException(400,'Invalid plan')
    gateway=(data.gateway or 'stripe').lower().strip()
    if gateway not in ('stripe','razorpay'): raise HTTPException(400,'Choose Stripe or Razorpay')
    db=SessionLocal()
    try:
        u=get_user(db,request); s=get_sub(db,u.id)
        if s and s.status=='active' and (s.stripe_subscription_id or s.razorpay_subscription_id):
            raise HTTPException(409,'An active recurring subscription is already linked to this account. Cancel or complete the current subscription before starting another gateway/plan to avoid duplicate billing.')
        if s and s.status=='active' and s.plan==data.plan and (s.billing_gateway or 'stripe')==gateway:
            raise HTTPException(400,f'{PLANS[data.plan]["name"]} is already active through {gateway.title()}')
        if gateway=='stripe':
            price=STRIPE_PRICES.get(data.plan)
            if not STRIPE_SECRET_KEY or not price:
                return {'demo_mode':True,'gateway':'stripe','message':'Stripe keys/Price IDs are not configured. Demo activation is available only on local HTTP.'}
            form=[('mode','subscription'),('success_url',BASE_URL+'/app#billing?success=1'),('cancel_url',BASE_URL+'/app#billing?canceled=1'),('customer_email',u.email),('line_items[0][price]',price),('line_items[0][quantity]','1'),('metadata[user_id]',str(u.id)),('metadata[plan]',data.plan),('metadata[gateway]','stripe'),('subscription_data[metadata][user_id]',str(u.id)),('subscription_data[metadata][plan]',data.plan)]
            session=stripe_request('checkout/sessions',form)
            return {'gateway':'stripe','url':session['url']}
        plan_id=RAZORPAY_PLANS.get(data.plan)
        if not RAZORPAY_KEY_ID or not RAZORPAY_KEY_SECRET or not plan_id:
            return {'demo_mode':True,'gateway':'razorpay','message':'Razorpay keys/Plan IDs are not configured. Demo activation is available only on local HTTP.'}
        rz=razorpay_request('POST','subscriptions',{
            'plan_id':plan_id,
            'total_count':RAZORPAY_TOTAL_COUNT,
            'quantity':1,
            'customer_notify':1,
            'notes':{'user_id':str(u.id),'plan':data.plan,'suite':APP_NAME}
        })
        s.razorpay_subscription_id=rz.get('id','') or ''
        s.razorpay_customer_id=rz.get('customer_id','') or ''
        s.billing_gateway='razorpay'; s.cancel_at_period_end=False; s.updated_at=utcnow(); db.commit()
        return {'gateway':'razorpay','key_id':RAZORPAY_KEY_ID,'subscription_id':rz.get('id'),'name':APP_NAME,'description':PLANS[data.plan]['name']+' subscription','prefill':{'name':u.name,'email':u.email},'theme':{'color':'#5B5CF0'}}
    finally: db.close()

@app.post('/api/billing/demo-activate')
def demo_activate(data:PlanIn,request:Request):
    if data.plan not in PLANS: raise HTTPException(400,'Invalid plan')
    db=SessionLocal()
    try:
        u=get_user(db,request)
        if BASE_URL.startswith('https://'): raise HTTPException(403,'Demo activation is disabled on HTTPS deployments')
        s=get_sub(db,u.id); s.plan=data.plan; s.status='active'; s.suspended=False; s.trial_end=None; s.grace_until=None; s.current_period_end=utcnow()+timedelta(days=30); s.last_payment_at=utcnow(); s.billing_gateway=(data.gateway or 'demo'); s.updated_at=utcnow(); db.add(Payment(user_id=u.id,amount=PLANS[data.plan]['price'],currency='USD',status='paid',external_id='demo',plan=data.plan,provider='demo')); db.commit(); return {'ok':True}
    finally: db.close()

@app.post('/api/billing/portal')
def billing_portal(request:Request):
    db=SessionLocal()
    try:
        u=get_user(db,request); s=get_sub(db,u.id)
        if not s or not s.stripe_customer_id: raise HTTPException(400,'No Stripe customer is linked to this account')
        p=stripe_request('billing_portal/sessions',[('customer',s.stripe_customer_id),('return_url',BASE_URL+'/app#billing')]); return {'url':p['url']}
    finally: db.close()

def verify_stripe_signature(payload,header):
    if not STRIPE_WEBHOOK_SECRET: return False
    parts={}
    for chunk in header.split(','):
        if '=' in chunk:
            k,v=chunk.split('=',1); parts.setdefault(k,[]).append(v)
    try: ts=parts['t'][0]; sigs=parts['v1']
    except Exception: return False
    expected=hmac.new(STRIPE_WEBHOOK_SECRET.encode(),ts.encode()+b'.'+payload,hashlib.sha256).hexdigest()
    if not any(hmac.compare_digest(expected,s) for s in sigs): return False
    try: return abs(int(datetime.now(timezone.utc).timestamp())-int(ts))<=300
    except Exception: return False

@app.post('/webhooks/stripe')
async def stripe_webhook(request:Request):
    payload=await request.body(); sig=request.headers.get('stripe-signature','')
    if not verify_stripe_signature(payload,sig): raise HTTPException(400,'Invalid Stripe signature')
    event=json.loads(payload); typ=event.get('type',''); obj=event.get('data',{}).get('object',{})
    db=SessionLocal()
    try:
        if typ=='checkout.session.completed':
            meta=obj.get('metadata',{}) or {}; uid=int(meta.get('user_id','0') or 0); plan=meta.get('plan','')
            if uid and plan in PLANS:
                u=db.query(User).filter(User.id==uid).first(); s=get_sub(db,uid); s.plan=plan; s.status='active'; s.suspended=False; s.trial_end=None; s.grace_until=None; s.current_period_end=utcnow()+timedelta(days=30); s.billing_gateway='stripe'; s.cancel_at_period_end=False; s.stripe_customer_id=obj.get('customer','') or ''; s.stripe_subscription_id=obj.get('subscription','') or ''; s.last_payment_at=utcnow(); s.updated_at=utcnow(); db.commit()
                if u: email_send(u.email,'Subscription activated',f'<p>Your {PLANS[plan]["name"]} subscription is active.</p>')
        elif typ=='invoice.paid':
            customer=obj.get('customer',''); s=db.query(Subscription).filter(Subscription.stripe_customer_id==customer).first()
            if s:
                u=db.query(User).filter(User.id==s.user_id).first(); amount=(obj.get('amount_paid',0) or 0)/100; currency=(obj.get('currency','usd') or 'usd').upper(); s.status='active'; s.suspended=False; s.grace_until=None; s.billing_gateway='stripe'; s.last_payment_at=utcnow(); s.current_period_end=utcnow()+timedelta(days=30); ext=obj.get('id','')
                is_new=not ext or not db.query(Payment).filter(Payment.external_id==ext,Payment.provider=='stripe').first()
                if is_new: db.add(Payment(user_id=s.user_id,amount=amount,currency=currency,status='paid',external_id=ext,plan=s.plan,provider='stripe'))
                db.commit()
                if u and is_new: email_send(u.email,'Payment receipt — Social Dynamics AI Suite',f'<p>Payment received via Stripe: {currency} {amount:,.2f}.</p>')
        elif typ=='invoice.payment_failed':
            customer=obj.get('customer',''); s=db.query(Subscription).filter(Subscription.stripe_customer_id==customer).first()
            if s:
                u=db.query(User).filter(User.id==s.user_id).first(); s.status='past_due'; s.billing_gateway='stripe'; s.grace_until=utcnow()+timedelta(days=PAST_DUE_GRACE_DAYS); s.updated_at=utcnow(); db.commit(); create_notification(db,s.user_id,'payment_failed','Payment failed','Update payment before the grace period ends.')
                if u: email_send(u.email,'Payment failed — action required',f'<p>Please update payment within {PAST_DUE_GRACE_DAYS} days to avoid suspension.</p><p><a href="{BASE_URL}/app#billing">Update billing</a></p>')
        elif typ in ('customer.subscription.deleted','customer.subscription.paused'):
            s=db.query(Subscription).filter(Subscription.stripe_subscription_id==obj.get('id','')).first()
            if s: s.status='suspended'; s.suspended=True; db.commit()
        elif typ=='customer.subscription.updated':
            s=db.query(Subscription).filter(Subscription.stripe_subscription_id==obj.get('id','')).first()
            if s:
                status=obj.get('status',''); end=obj.get('current_period_end')
                if status in ('active','trialing'): s.status='active'; s.suspended=False
                elif status in ('past_due','unpaid'): s.status='past_due'; s.grace_until=utcnow()+timedelta(days=PAST_DUE_GRACE_DAYS)
                if end: s.current_period_end=datetime.fromtimestamp(end,timezone.utc).replace(tzinfo=None)
                db.commit()
        return {'received':True}
    finally: db.close()

class RazorpayVerifyIn(BaseModel):
    razorpay_payment_id:str
    razorpay_subscription_id:str
    razorpay_signature:str

@app.post('/api/billing/razorpay/verify')
def razorpay_verify(data:RazorpayVerifyIn,request:Request):
    if not RAZORPAY_KEY_SECRET: raise HTTPException(503,'Razorpay is not configured')
    db=SessionLocal()
    try:
        u=get_user(db,request); s=get_sub(db,u.id)
        if not s or not s.razorpay_subscription_id or s.razorpay_subscription_id!=data.razorpay_subscription_id:
            raise HTTPException(403,'Razorpay subscription does not belong to this account')
        body=f'{data.razorpay_payment_id}|{data.razorpay_subscription_id}'.encode()
        expected=hmac.new(RAZORPAY_KEY_SECRET.encode(),body,hashlib.sha256).hexdigest()
        if not hmac.compare_digest(expected,data.razorpay_signature): raise HTTPException(400,'Invalid Razorpay payment signature')
        rzsub=razorpay_request('GET',f'subscriptions/{data.razorpay_subscription_id}')
        notes=rzsub.get('notes') or {}; plan=notes.get('plan','') if isinstance(notes,dict) else ''
        if plan not in PLANS: raise HTTPException(400,'Razorpay subscription plan mapping is missing')
        if str(notes.get('user_id',''))!=str(u.id): raise HTTPException(403,'Razorpay subscription account mismatch')
        pay=razorpay_request('GET',f'payments/{data.razorpay_payment_id}')
        if pay.get('status') not in ('captured','authorized'): raise HTTPException(400,'Razorpay payment is not confirmed')
        end=rzsub.get('current_end') or rzsub.get('charge_at')
        s.plan=plan; s.status='active'; s.suspended=False; s.trial_end=None; s.grace_until=None; s.billing_gateway='razorpay'; s.cancel_at_period_end=False
        s.razorpay_customer_id=rzsub.get('customer_id','') or s.razorpay_customer_id; s.last_payment_at=utcnow(); s.updated_at=utcnow()
        s.current_period_end=datetime.fromtimestamp(end,timezone.utc).replace(tzinfo=None) if end else utcnow()+timedelta(days=30)
        exists=db.query(Payment).filter(Payment.external_id==data.razorpay_payment_id,Payment.provider=='razorpay').first()
        if not exists:
            db.add(Payment(user_id=u.id,amount=(pay.get('amount',0) or 0)/100,currency=(pay.get('currency','INR') or 'INR').upper(),status='paid',external_id=data.razorpay_payment_id,plan=plan,provider='razorpay'))
        db.commit(); create_notification(db,u.id,'razorpay_activated','Razorpay subscription active',f'{PLANS[plan]["name"]} is now active.')
        email_send(u.email,'Subscription activated — Social Dynamics AI Suite',f'<p>Your {PLANS[plan]["name"]} subscription is active via Razorpay.</p>')
        return {'ok':True,'status':'active','plan':plan}
    finally: db.close()

@app.post('/api/billing/razorpay/cancel')
def razorpay_cancel(request:Request):
    db=SessionLocal()
    try:
        u=get_user(db,request); s=get_sub(db,u.id)
        if not s or not s.razorpay_subscription_id: raise HTTPException(400,'No Razorpay subscription is linked to this account')
        rz=razorpay_request('POST',f'subscriptions/{s.razorpay_subscription_id}/cancel',{'cancel_at_cycle_end':True})
        s.cancel_at_period_end=True; s.updated_at=utcnow(); db.commit()
        create_notification(db,u.id,'razorpay_cancel_scheduled','Razorpay cancellation scheduled','Your subscription will cancel at the end of the current billing cycle.')
        return {'ok':True,'status':rz.get('status'),'cancel_at_period_end':True}
    finally: db.close()

def verify_razorpay_webhook(payload,signature):
    if not RAZORPAY_WEBHOOK_SECRET or not signature: return False
    expected=hmac.new(RAZORPAY_WEBHOOK_SECRET.encode(),payload,hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected,signature)

@app.post('/webhooks/razorpay')
async def razorpay_webhook(request:Request):
    payload=await request.body(); sig=request.headers.get('x-razorpay-signature','')
    if not verify_razorpay_webhook(payload,sig): raise HTTPException(400,'Invalid Razorpay webhook signature')
    event=json.loads(payload); typ=event.get('event',''); subent=((event.get('payload') or {}).get('subscription') or {}).get('entity') or {}
    payent=((event.get('payload') or {}).get('payment') or {}).get('entity') or {}
    sub_id=subent.get('id',''); notes=subent.get('notes') or {}
    db=SessionLocal()
    try:
        s=db.query(Subscription).filter(Subscription.razorpay_subscription_id==sub_id).first() if sub_id else None
        if not s and isinstance(notes,dict) and str(notes.get('user_id','')).isdigit(): s=get_sub(db,int(notes['user_id']))
        if not s: return {'received':True,'matched':False}
        u=db.query(User).filter(User.id==s.user_id).first(); plan=notes.get('plan',s.plan) if isinstance(notes,dict) else s.plan
        if plan in PLANS: s.plan=plan
        s.billing_gateway='razorpay'; s.razorpay_subscription_id=sub_id or s.razorpay_subscription_id; s.razorpay_customer_id=subent.get('customer_id','') or s.razorpay_customer_id; s.updated_at=utcnow()
        end=subent.get('current_end')
        if end: s.current_period_end=datetime.fromtimestamp(end,timezone.utc).replace(tzinfo=None)
        if typ in ('subscription.authenticated','subscription.activated','subscription.resumed'):
            s.status='active'; s.suspended=False; s.trial_end=None; s.grace_until=None; s.cancel_at_period_end=False
        elif typ=='subscription.charged':
            s.status='active'; s.suspended=False; s.grace_until=None; s.last_payment_at=utcnow()
            pid=payent.get('id','')
            if pid and not db.query(Payment).filter(Payment.external_id==pid,Payment.provider=='razorpay').first():
                amount=(payent.get('amount',0) or 0)/100; currency=(payent.get('currency','INR') or 'INR').upper()
                db.add(Payment(user_id=s.user_id,amount=amount,currency=currency,status='paid',external_id=pid,plan=s.plan,provider='razorpay'))
                if u: email_send(u.email,'Payment receipt — Social Dynamics AI Suite',f'<p>Payment received via Razorpay: {currency} {amount:,.2f}.</p>')
        elif typ in ('subscription.pending','subscription.halted'):
            s.status='past_due'; s.suspended=False
            if not s.grace_until or s.grace_until<utcnow(): s.grace_until=utcnow()+timedelta(days=PAST_DUE_GRACE_DAYS)
            if u:
                made=create_notification(db,u.id,'razorpay_payment_failed','Razorpay payment requires attention','Resolve the payment before the grace period ends.')
                if made: email_send(u.email,'Payment failed — action required',f'<p>Your Razorpay subscription payment requires attention. Please resolve it within {PAST_DUE_GRACE_DAYS} days to avoid suspension.</p><p><a href="{BASE_URL}/app#billing">Open billing</a></p>')
        elif typ in ('subscription.cancelled','subscription.canceled','subscription.completed','subscription.expired','subscription.paused'):
            s.status='suspended'; s.suspended=True; s.cancel_at_period_end=False
        elif typ=='subscription.updated':
            status=subent.get('status','')
            if status in ('active','authenticated'): s.status='active'; s.suspended=False
            elif status in ('pending','halted'): s.status='past_due'; s.suspended=False; s.grace_until=s.grace_until or (utcnow()+timedelta(days=PAST_DUE_GRACE_DAYS))
            elif status in ('cancelled','completed','expired','paused'): s.status='suspended'; s.suspended=True
        db.commit(); return {'received':True,'matched':True}
    finally: db.close()

@app.post('/api/contact')
def contact(data:ContactIn):
    db=SessionLocal()
    try:
        db.add(Inquiry(name=data.name,email=str(data.email),company=data.company,message=data.message)); db.commit(); email_send(ADMIN_EMAIL,'New Social Dynamics website inquiry',f'<p><b>{data.name}</b> ({data.email}) from {data.company}</p><p>{data.message}</p>'); return {'ok':True,'message':'Thanks — your message has been received.'}
    finally: db.close()
@app.get('/api/dashboard')
def dashboard(request:Request):
    db=SessionLocal()
    try:
        u=get_user(db,request); st=subscription_state(db,u)
        return {'subscription':st,'products':[
            {'id':'engage','name':'Engage','description':'Omnichannel messaging, CRM, team inbox, WhatsApp campaigns and automation.','usage':db.query(Usage).filter(Usage.user_id==u.id,Usage.product=='engage').count()},
            {'id':'gbp','name':'GBP Command AI','description':'Advanced GBP audits, optimisation, reviews, posts, rankings and competitor intelligence.','usage':db.query(Usage).filter(Usage.user_id==u.id,Usage.product=='gbp').count()},
            {'id':'mailflow','name':'MailFlow AI','description':'Email verification, contacts, broadcasts, sequences and responsible sending automation.','usage':db.query(Usage).filter(Usage.user_id==u.id,Usage.product=='mailflow').count()},
            {'id':'crm','name':'NexusCRM AI','description':'AI CRM, sales pipeline, follow-ups, ads attribution, dialer readiness and forecasting.','usage':db.query(Usage).filter(Usage.user_id==u.id,Usage.product=='crm').count()}]}
    finally: db.close()

class EngageContactIn(BaseModel): name:str; phone:str=''; email:str=''; channel:str='whatsapp'; stage:str='New'
class ConversationIn(BaseModel): contact_name:str; channel:str='whatsapp'; message:str; direction:str='outbound'
@app.get('/api/engage/contacts')
def engage_contacts(request:Request):
    db=SessionLocal()
    try:
        u=get_user(db,request); rows=db.query(EngageContact).filter(EngageContact.user_id==u.id).order_by(EngageContact.created_at.desc()).all(); return [{'id':x.id,'name':x.name,'phone':x.phone,'email':x.email,'channel':x.channel,'stage':x.stage,'created_at':x.created_at.isoformat()} for x in rows]
    finally: db.close()
@app.post('/api/engage/contacts')
def engage_add(data:EngageContactIn,request:Request):
    db=SessionLocal()
    try:
        u=get_user(db,request); x=EngageContact(user_id=u.id,**data.model_dump()); db.add(x); db.commit(); usage(db,u.id,'engage','contact_created'); return {'ok':True,'id':x.id}
    finally: db.close()
@app.get('/api/engage/conversations')
def engage_conversations(request:Request):
    db=SessionLocal()
    try:
        u=get_user(db,request); rows=db.query(EngageConversation).filter(EngageConversation.user_id==u.id).order_by(EngageConversation.created_at.desc()).limit(60).all(); return [{'id':x.id,'contact_name':x.contact_name,'channel':x.channel,'message':x.message,'direction':x.direction,'created_at':x.created_at.isoformat()} for x in rows]
    finally: db.close()
@app.post('/api/engage/conversations')
def engage_log_message(data:ConversationIn,request:Request):
    db=SessionLocal()
    try:
        u=get_user(db,request); x=EngageConversation(user_id=u.id,**data.model_dump()); db.add(x); db.commit(); usage(db,u.id,'engage','message_logged'); return {'ok':True,'note':'Message logged. Live channel delivery requires approved provider APIs.'}
    finally: db.close()

class GBPLocationIn(BaseModel):
    name:str; city:str=''; category:str=''; completeness:int=75; rating:float=4.2; reviews:int=25; response_rate:int=60; posts_30d:int=2; photos:int=20; nap_consistent:bool=True; website:bool=True
@app.get('/api/gbp/locations')
def gbp_locations(request:Request):
    db=SessionLocal()
    try:
        u=get_user(db,request); rows=db.query(GBPLocation).filter(GBPLocation.user_id==u.id).order_by(GBPLocation.created_at.desc()).all(); return [{'id':x.id,'name':x.name,'city':x.city,'category':x.category,'completeness':x.completeness,'rating':x.rating,'reviews':x.reviews,'response_rate':x.response_rate,'posts_30d':x.posts_30d,'photos':x.photos,'nap_consistent':x.nap_consistent,'website':x.website} for x in rows]
    finally: db.close()
@app.post('/api/gbp/locations')
def gbp_add(data:GBPLocationIn,request:Request):
    db=SessionLocal()
    try:
        u=get_user(db,request); x=GBPLocation(user_id=u.id,**data.model_dump()); db.add(x); db.commit(); usage(db,u.id,'gbp','location_added'); return {'ok':True,'id':x.id}
    finally: db.close()
def run_gbp_audit(loc):
    issues=[]; profile=max(0,min(100,int(loc.completeness))); reviews_score=int(min(100,(loc.rating/5)*55+min(loc.reviews,150)/150*25+loc.response_rate/100*20)); content=int(min(100,min(loc.posts_30d,8)/8*45+min(loc.photos,60)/60*55)); local_seo=100 if loc.nap_consistent else 45; conversion=int((55 if loc.website else 25)+min(45,loc.completeness*.45))
    if profile<90: issues.append('Complete every relevant GBP field, service/category and business attribute.')
    if loc.response_rate<90: issues.append('Increase review response rate and prioritise recent unanswered reviews.')
    if loc.posts_30d<4: issues.append('Publish fresh Google Posts consistently throughout the month.')
    if loc.photos<30: issues.append('Add more recent authentic photos covering team, services, location and work examples.')
    if not loc.nap_consistent: issues.append('Fix business name/address/phone consistency across major local citations.')
    if not loc.website: issues.append('Add a conversion-focused website link and track actions with UTM parameters.')
    overall=round(profile*.25+reviews_score*.25+content*.20+local_seo*.15+conversion*.15); return overall,profile,reviews_score,content,local_seo,conversion,issues
@app.post('/api/gbp/audit/{location_id}')
def gbp_audit(location_id:int,request:Request):
    db=SessionLocal()
    try:
        u=get_user(db,request); loc=db.query(GBPLocation).filter(GBPLocation.id==location_id,GBPLocation.user_id==u.id).first()
        if not loc: raise HTTPException(404,'Location not found')
        v=run_gbp_audit(loc); db.add(GBPAudit(user_id=u.id,location_id=loc.id,overall=v[0],profile=v[1],reviews_score=v[2],content=v[3],local_seo=v[4],conversion=v[5],issues_json=json.dumps(v[6]))); db.commit(); usage(db,u.id,'gbp','audit_run'); return {'overall':v[0],'profile':v[1],'reviews':v[2],'content':v[3],'local_seo':v[4],'conversion':v[5],'issues':v[6]}
    finally: db.close()

EMAIL_RE=re.compile(r"^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$")
class VerifyIn(BaseModel): email:str
class MailContactIn(BaseModel): email:str; first_name:str=''; company:str=''; consent:bool=True
class MailCampaignIn(BaseModel): name:str; subject:str=''; scheduled_at:Optional[str]=None
@app.post('/api/mailflow/verify')
def mail_verify(data:VerifyIn,request:Request):
    db=SessionLocal()
    try:
        u=get_user(db,request); email=data.email.strip().lower(); syntax=bool(EMAIL_RE.match(email)); domain=email.split('@')[-1] if '@' in email else ''; mx=False; mx_hosts=[]
        if syntax and dns:
            try:
                ans=dns.resolver.resolve(domain,'MX',lifetime=4); mx_hosts=[str(r.exchange).rstrip('.') for r in ans]; mx=bool(mx_hosts)
            except Exception: pass
        result='valid' if syntax and mx else ('risky' if syntax else 'invalid'); usage(db,u.id,'mailflow','email_verified'); return {'email':email,'syntax':syntax,'mx':mx,'mx_hosts':mx_hosts[:5],'result':result,'note':'MX validation does not prove that a specific mailbox exists.'}
    finally: db.close()
@app.get('/api/mailflow/contacts')
def mail_contacts(request:Request):
    db=SessionLocal()
    try:
        u=get_user(db,request); rows=db.query(MailContact).filter(MailContact.user_id==u.id).order_by(MailContact.created_at.desc()).all(); return [{'id':x.id,'email':x.email,'first_name':x.first_name,'company':x.company,'consent':x.consent,'verification':x.verification} for x in rows]
    finally: db.close()
@app.post('/api/mailflow/contacts')
def mail_add(data:MailContactIn,request:Request):
    db=SessionLocal()
    try:
        u=get_user(db,request); x=MailContact(user_id=u.id,**data.model_dump()); db.add(x); db.commit(); usage(db,u.id,'mailflow','contact_added'); return {'ok':True,'id':x.id}
    finally: db.close()
@app.get('/api/mailflow/campaigns')
def mail_campaigns(request:Request):
    db=SessionLocal()
    try:
        u=get_user(db,request); rows=db.query(MailCampaign).filter(MailCampaign.user_id==u.id).order_by(MailCampaign.created_at.desc()).all(); return [{'id':x.id,'name':x.name,'subject':x.subject,'status':x.status,'scheduled_at':x.scheduled_at.isoformat() if x.scheduled_at else None,'audience':x.audience,'sent':x.sent} for x in rows]
    finally: db.close()
@app.post('/api/mailflow/campaigns')
def mail_campaign_add(data:MailCampaignIn,request:Request):
    db=SessionLocal()
    try:
        u=get_user(db,request); audience=db.query(MailContact).filter(MailContact.user_id==u.id,MailContact.consent==True).count(); when=datetime.fromisoformat(data.scheduled_at) if data.scheduled_at else None; x=MailCampaign(user_id=u.id,name=data.name,subject=data.subject,status='scheduled' if when else 'draft',scheduled_at=when,audience=audience); db.add(x); db.commit(); usage(db,u.id,'mailflow','campaign_created'); return {'ok':True,'id':x.id,'audience':audience,'note':'Campaign scheduling stored. Connect an authorised ESP/SMTP worker for live delivery.'}
    finally: db.close()
class LeadIn(BaseModel): name:str; company:str=''; email:str=''; phone:str=''; source:str='Manual'; stage:str='New'; value:float=0; score:int=50; owner:str=''
class StageIn(BaseModel): stage:str
class TaskIn(BaseModel): title:str; lead_id:Optional[int]=None; due_at:Optional[str]=None
class AIIn(BaseModel): task:str='next_action'; lead_id:Optional[int]=None; prompt:str=''
@app.get('/api/crm/leads')
def crm_leads(request:Request):
    db=SessionLocal()
    try:
        u=get_user(db,request); rows=db.query(CRMLead).filter(CRMLead.user_id==u.id).order_by(CRMLead.updated_at.desc()).all(); return [{'id':x.id,'name':x.name,'company':x.company,'email':x.email,'phone':x.phone,'source':x.source,'stage':x.stage,'value':x.value,'score':x.score,'owner':x.owner,'created_at':x.created_at.isoformat()} for x in rows]
    finally: db.close()
@app.post('/api/crm/leads')
def crm_add(data:LeadIn,request:Request):
    db=SessionLocal()
    try:
        u=get_user(db,request); x=CRMLead(user_id=u.id,**data.model_dump()); db.add(x); db.commit(); usage(db,u.id,'crm','lead_created'); return {'ok':True,'id':x.id}
    finally: db.close()
@app.patch('/api/crm/leads/{lead_id}/stage')
def crm_stage(lead_id:int,data:StageIn,request:Request):
    db=SessionLocal()
    try:
        u=get_user(db,request); x=db.query(CRMLead).filter(CRMLead.id==lead_id,CRMLead.user_id==u.id).first()
        if not x: raise HTTPException(404,'Lead not found')
        x.stage=data.stage; x.updated_at=utcnow(); db.commit(); usage(db,u.id,'crm','stage_changed'); return {'ok':True}
    finally: db.close()
@app.get('/api/crm/tasks')
def crm_tasks(request:Request):
    db=SessionLocal()
    try:
        u=get_user(db,request); rows=db.query(CRMTask).filter(CRMTask.user_id==u.id).order_by(CRMTask.status.asc(),CRMTask.due_at.asc()).all(); return [{'id':x.id,'title':x.title,'lead_id':x.lead_id,'due_at':x.due_at.isoformat() if x.due_at else None,'status':x.status} for x in rows]
    finally: db.close()
@app.post('/api/crm/tasks')
def crm_task_add(data:TaskIn,request:Request):
    db=SessionLocal()
    try:
        u=get_user(db,request); when=datetime.fromisoformat(data.due_at) if data.due_at else None; x=CRMTask(user_id=u.id,title=data.title,lead_id=data.lead_id,due_at=when); db.add(x); db.commit(); usage(db,u.id,'crm','task_created'); return {'ok':True}
    finally: db.close()
@app.post('/api/crm/ai')
async def crm_ai(data:AIIn,request:Request):
    db=SessionLocal()
    try:
        u=get_user(db,request); lead=None
        if data.lead_id: lead=db.query(CRMLead).filter(CRMLead.id==data.lead_id,CRMLead.user_id==u.id).first()
        ctx=f'Lead: {lead.name}, company {lead.company}, stage {lead.stage}, score {lead.score}, value {lead.value}, source {lead.source}.' if lead else 'Pipeline-level request.'
        prompt=f'You are a concise B2B CRM sales copilot. Task: {data.task}. {ctx} User instruction: {data.prompt}. Return actionable guidance without inventing customer facts.'
        result=None
        if OPENAI_API_KEY:
            try:
                async with httpx.AsyncClient(timeout=35) as client:
                    r=await client.post('https://api.openai.com/v1/responses',headers={'Authorization':f'Bearer {OPENAI_API_KEY}','Content-Type':'application/json'},json={'model':OPENAI_MODEL,'input':prompt})
                if r.status_code<300:
                    j=r.json(); result=j.get('output_text')
                    if not result:
                        for item in j.get('output',[]):
                            for c in item.get('content',[]):
                                if c.get('type')=='output_text': result=c.get('text'); break
            except Exception: result=None
        if not result:
            if data.task=='email': result=f'Subject: Quick follow-up\n\nHi {lead.name if lead else "there"},\n\nI wanted to follow up and make the next step simple. Would a short call tomorrow work so we can confirm your priorities and timeline?\n\nBest regards,'
            elif data.task=='forecast':
                rows=db.query(CRMLead).filter(CRMLead.user_id==u.id).all(); weighted=sum((x.value or 0)*(max(0,min(100,x.score))/100) for x in rows if x.stage not in ('Won','Lost')); result=f'Directional AI-weighted pipeline: ${weighted:,.0f}. Improve accuracy by connecting historical stage conversion and activity data.'
            else: result=f'Next best action: contact {lead.name if lead else "the highest-scoring open lead"} within 2 hours, confirm decision criteria and schedule a dated next step. Prioritise opportunities without recent activity.'
        usage(db,u.id,'crm','ai_action'); return {'result':result,'engine':'OpenAI' if OPENAI_API_KEY else 'Built-in fallback'}
    finally: db.close()

@app.get('/api/integrations/status')
def integration_status(request:Request):
    db=SessionLocal()
    try:
        get_user(db,request)
        return {'stripe':bool(STRIPE_SECRET_KEY),'razorpay':bool(RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET),'resend':bool(RESEND_API_KEY),'sendgrid':bool(SENDGRID_API_KEY),'smtp':bool(SMTP_HOST),'openai':bool(OPENAI_API_KEY),'google_oauth':bool(GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET),'meta_oauth':bool(META_OAUTH_APP_ID and META_OAUTH_APP_SECRET),'google_gbp':bool(GOOGLE_GBP_CLIENT_ID and GOOGLE_GBP_CLIENT_SECRET),'google_ads':bool(GOOGLE_ADS_DEVELOPER_TOKEN),'meta_ads':bool(META_ACCESS_TOKEN),'twilio':bool(TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN),'exotel':bool(EXOTEL_SID and EXOTEL_API_KEY)}
    finally: db.close()

@app.get('/api/admin/overview')
def admin_overview(request:Request):
    db=SessionLocal()
    try:
        get_user(db,request); users=db.query(User).filter(User.role!='admin').all(); subs=db.query(Subscription).all(); pays=db.query(Payment).all(); inquiries=db.query(Inquiry).all(); usages=db.query(Usage).all(); mrr=sum(PLANS.get(s.plan,{}).get('price',0) for s in subs if s.status=='active'); by_product={p:sum(x.quantity for x in usages if x.product==p) for p in ('engage','gbp','mailflow','crm')}
        return {'kpis':{'users':len(users),'active_subscriptions':len([s for s in subs if s.status=='active']),'trialing':len([s for s in subs if s.status=='trialing']),'suspended':len([s for s in subs if s.suspended or s.status in ('suspended','trial_expired')]),'mrr':mrr,'payments_count':len([p for p in pays if p.status=='paid']),'stripe_active':len([s for s in subs if s.status=='active' and (s.billing_gateway or 'stripe')=='stripe']),'razorpay_active':len([s for s in subs if s.status=='active' and s.billing_gateway=='razorpay']),'inquiries':len(inquiries)},'product_usage':by_product}
    finally: db.close()
@app.get('/api/admin/users')
def admin_users(request:Request):
    db=SessionLocal()
    try:
        get_user(db,request); rows=db.query(User).filter(User.role!='admin').order_by(User.created_at.desc()).all(); out=[]
        for u in rows:
            s=get_sub(db,u.id); out.append({'id':u.id,'name':u.name,'company':u.company,'email':u.email,'active':u.active,'created_at':u.created_at.isoformat(),'last_login_at':u.last_login_at.isoformat() if u.last_login_at else None,'subscription':{'plan':s.plan,'status':s.status,'suspended':s.suspended,'billing_gateway':s.billing_gateway or '', 'trial_end':s.trial_end.isoformat() if s.trial_end else None,'current_period_end':s.current_period_end.isoformat() if s.current_period_end else None} if s else None})
        return out
    finally: db.close()
@app.get('/api/admin/payments')
def admin_payments(request:Request):
    db=SessionLocal()
    try:
        get_user(db,request); rows=db.query(Payment).order_by(Payment.created_at.desc()).limit(100).all(); return [{'id':x.id,'user_id':x.user_id,'amount':x.amount,'currency':x.currency,'status':x.status,'plan':x.plan,'provider':x.provider or 'legacy','external_id':x.external_id,'created_at':x.created_at.isoformat()} for x in rows]
    finally: db.close()
@app.get('/api/admin/inquiries')
def admin_inquiries(request:Request):
    db=SessionLocal()
    try:
        get_user(db,request); rows=db.query(Inquiry).order_by(Inquiry.created_at.desc()).limit(100).all(); return [{'id':x.id,'name':x.name,'email':x.email,'company':x.company,'message':x.message,'status':x.status,'created_at':x.created_at.isoformat()} for x in rows]
    finally: db.close()
@app.get('/api/admin/product-data')
def admin_product_data(request:Request):
    db=SessionLocal()
    try:
        get_user(db,request); users=db.query(User).filter(User.role!='admin').order_by(User.created_at.desc()).all(); out=[]
        for u in users:
            out.append({'user_id':u.id,'name':u.name,'company':u.company,'email':u.email,
                'engage_contacts':db.query(EngageContact).filter(EngageContact.user_id==u.id).count(),
                'engage_conversations':db.query(EngageConversation).filter(EngageConversation.user_id==u.id).count(),
                'gbp_locations':db.query(GBPLocation).filter(GBPLocation.user_id==u.id).count(),
                'gbp_audits':db.query(GBPAudit).filter(GBPAudit.user_id==u.id).count(),
                'mail_contacts':db.query(MailContact).filter(MailContact.user_id==u.id).count(),
                'mail_campaigns':db.query(MailCampaign).filter(MailCampaign.user_id==u.id).count(),
                'crm_leads':db.query(CRMLead).filter(CRMLead.user_id==u.id).count(),
                'crm_tasks':db.query(CRMTask).filter(CRMTask.user_id==u.id).count(),
                'usage_events':db.query(Usage).filter(Usage.user_id==u.id).count()})
        return out
    finally: db.close()

class AdminActionIn(BaseModel): action:str; plan:Optional[str]=None
@app.post('/api/admin/users/{user_id}/subscription')
def admin_subscription(user_id:int,data:AdminActionIn,request:Request):
    db=SessionLocal()
    try:
        admin=get_user(db,request); target=db.query(User).filter(User.id==user_id,User.role!='admin').first()
        if not target: raise HTTPException(404,'User not found')
        s=get_sub(db,user_id)
        if data.action=='suspend': s.status='suspended'; s.suspended=True
        elif data.action=='restore': s.status='active'; s.suspended=False; s.current_period_end=utcnow()+timedelta(days=30); s.grace_until=None
        elif data.action=='plan' and data.plan in PLANS: s.plan=data.plan
        else: raise HTTPException(400,'Invalid action')
        s.updated_at=utcnow(); db.add(AdminAudit(admin_user_id=admin.id,target_user_id=user_id,action=data.action,details=data.plan or '')); db.commit(); return {'ok':True}
    finally: db.close()
