import os,re,csv,io,uuid,secrets
from decimal import Decimal
from pathlib import Path
from datetime import datetime
from contextlib import asynccontextmanager
from dotenv import load_dotenv
from fastapi import FastAPI,Request,Form,UploadFile,File,HTTPException,Depends
from fastapi.responses import HTMLResponse,RedirectResponse,StreamingResponse,FileResponse
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from sqlalchemy import create_engine,func
from sqlalchemy.orm import sessionmaker,joinedload
from passlib.context import CryptContext
from models import *
from seed import seed
load_dotenv();DB=os.getenv('DATABASE_URL','');secret=os.getenv('SECRET_KEY','');admin_email=os.getenv('ADMIN_EMAIL','');admin_password=os.getenv('ADMIN_PASSWORD','')
if not DB or len(secret)<32:raise RuntimeError('Configure DATABASE_URL and SECRET_KEY in .env')
engine=create_engine(DB,pool_pre_ping=True,pool_recycle=1800);Session=sessionmaker(bind=engine);pwd=CryptContext(schemes=['bcrypt'],deprecated='auto');templates=Jinja2Templates(directory=str(Path(__file__).parent/'templates'));upload_dir=Path(os.getenv('UPLOAD_DIR','./uploads')).resolve();upload_dir.mkdir(parents=True,exist_ok=True)
@asynccontextmanager
async def lifespan(app):
 Base.metadata.create_all(engine)
 with Session() as db:
  seed(db)
  if not db.query(User).first():
   if not admin_email or len(admin_password)<12 or admin_password.startswith('CHANGE'):raise RuntimeError('Set strong ADMIN_EMAIL and ADMIN_PASSWORD before first startup')
   db.add(User(email=admin_email.lower(),password_hash=pwd.hash(admin_password),role='admin'));db.commit()
 yield
app=FastAPI(title='J2MS',lifespan=lifespan)
app.add_middleware(SessionMiddleware,secret_key=secret,https_only=os.getenv('SESSION_HTTPS_ONLY','false').lower()=='true',same_site='lax',max_age=28800)
def db_dep():
 with Session() as db:yield db
def user(request,db):
 uid=request.session.get('uid');return db.get(User,uid) if uid else None
def require(request,db,write=False):
 u=user(request,db)
 if not u or not u.active:raise HTTPException(401,'Login required')
 if write and u.role not in ('admin','manager','editor'):raise HTTPException(403,'Read only')
 return u
def guard(request):
 token=request.session.get('csrf')
 if not token or request.headers.get('x-csrf-token')!=token:raise HTTPException(403,'Invalid CSRF token')
def audit(db,u,action,entity,eid,details=''):db.add(Audit(user_id=u.id,action=action,entity=entity,entity_id=eid,details=details))
def render(request,name,db,**kwargs):
 u=user(request,db)
 if not request.session.get('csrf'):request.session['csrf']=secrets.token_urlsafe(32)
 return templates.TemplateResponse(request,name,{'u':u,'csrf':request.session['csrf'],'clusters':db.query(Cluster).order_by(Cluster.code).all(),'networks':db.query(Network).order_by(Network.code).all(),**kwargs})
@app.get('/login')
def login_form(request:Request,db=Depends(db_dep)):return render(request,'login.html',db)
@app.post('/login')
def login(request:Request,email:str=Form(...),password:str=Form(...),db=Depends(db_dep)):
 u=db.query(User).filter_by(email=email.strip().lower(),active=True).first()
 if not u or not pwd.verify(password,u.password_hash):return render(request,'login.html',db,error='Email atau kata laluan salah')
 request.session.clear();request.session['uid']=u.id;request.session['csrf']=secrets.token_urlsafe(32);return RedirectResponse('/',303)
@app.post('/logout')
def logout(request:Request):guard(request);request.session.clear();return RedirectResponse('/login',303)
@app.get('/')
def dashboard(request:Request,db=Depends(db_dep)):
 require(request,db);sites=db.query(Site).options(joinedload(Site.cluster)).order_by(Site.site_key).all();counts={c.code:sum(s.cluster_id==c.id for s in sites) for c in db.query(Cluster)}
 return render(request,'dashboard.html',db,sites=sites,counts=counts,company_count=db.query(Company).count(),task_count=db.query(Task).count())
@app.post('/sites')
def create_site(request:Request,site_key:str=Form(...),name:str=Form(...),cluster_id:int=Form(...),state:str=Form(''),district:str=Form(''),tower_height:str=Form(''),power_type:str=Form(''),db=Depends(db_dep)):
 guard(request);u=require(request,db,True);key=site_key.strip().upper()
 if not re.fullmatch(r'JEN2_\d{4,}',key):raise HTTPException(422,'Site ID must be JEN2_0000 format')
 if not db.get(Cluster,cluster_id):raise HTTPException(422,'Invalid cluster')
 if db.query(Site).filter_by(site_key=key).first():raise HTTPException(409,'Duplicate Site ID')
 s=Site(site_key=key,name=name.strip(),cluster_id=cluster_id,state=state,district=district,tower_height=int(tower_height) if tower_height else None,power_type=power_type);db.add(s);db.flush();audit(db,u,'CREATE','site',s.id,key);db.commit();return RedirectResponse(f'/sites/{s.id}',303)
@app.get('/sites/{sid}')
def site_detail(sid:int,request:Request,db=Depends(db_dep)):
 require(request,db);s=db.get(Site,sid)
 if not s:raise HTTPException(404)
 assignments=db.query(Assignment).options(joinedload(Assignment.task).joinedload(Task.network),joinedload(Assignment.company)).filter_by(site_id=sid).all();boq=db.query(BoqItem).filter_by(site_id=sid).all();docs=db.query(Document).filter_by(site_id=sid).all()
 return render(request,'site.html',db,s=s,assignments=assignments,boq=boq,docs=docs,companies=db.query(Company).order_by(Company.name).all(),tasks=db.query(Task).join(Network).order_by(Network.code,Task.id).all(),total=sum((x.quantity or 0)*(x.unit_rate or 0) for x in boq))
@app.post('/sites/{sid}/update')
def update_site(sid:int,request:Request,name:str=Form(...),status:str=Form(...),tower_height:str=Form(''),power_type:str=Form(''),backhaul:str=Form(''),notes:str=Form(''),db=Depends(db_dep)):
 guard(request);u=require(request,db,True);s=db.get(Site,sid)
 if not s:raise HTTPException(404)
 if status not in ('PLANNING','SURVEY','DESIGN','APPROVAL','CONSTRUCTION','TESTING','PAC','FAC','ON_HOLD'):raise HTTPException(422)
 s.name=name;s.status=status;s.tower_height=int(tower_height) if tower_height else None;s.power_type=power_type;s.backhaul=backhaul;s.notes=notes;audit(db,u,'UPDATE','site',sid,status);db.commit();return RedirectResponse(f'/sites/{sid}',303)
@app.get('/companies')
def companies(request:Request,db=Depends(db_dep)):
 require(request,db);return render(request,'companies.html',db,companies=db.query(Company).order_by(Company.name).all())
@app.post('/companies')
def company_create(request:Request,name:str=Form(...),registration_no:str=Form(''),contact:str=Form(''),email:str=Form(''),phone:str=Form(''),category:str=Form(''),db=Depends(db_dep)):
 guard(request);u=require(request,db,True);c=Company(name=name,registration_no=registration_no,contact=contact,email=email,phone=phone,category=category);db.add(c);db.flush();audit(db,u,'CREATE','company',c.id,name);db.commit();return RedirectResponse('/companies',303)
@app.get('/networks')
def networks(request:Request,db=Depends(db_dep)):
 require(request,db);return render(request,'networks.html',db,tasks=db.query(Task).order_by(Task.network_id,Task.id).all())
@app.post('/sites/{sid}/assign')
def assign(sid:int,request:Request,task_id:int=Form(...),company_id:str=Form(''),status:str=Form('NOT_STARTED'),progress:int=Form(0),contract_value:Decimal=Form(0),remarks:str=Form(''),db=Depends(db_dep)):
 guard(request);u=require(request,db,True)
 if not db.get(Site,sid) or not db.get(Task,task_id):raise HTTPException(404)
 if status not in ('NOT_STARTED','IN_PROGRESS','BLOCKED','DONE','APPROVED') or not 0<=progress<=100:raise HTTPException(422)
 a=db.query(Assignment).filter_by(site_id=sid,task_id=task_id).first()
 if not a:a=Assignment(site_id=sid,task_id=task_id);db.add(a)
 a.company_id=int(company_id) if company_id else None;a.status=status;a.progress=progress;a.contract_value=contract_value;a.remarks=remarks;db.flush();audit(db,u,'UPSERT','assignment',a.id,f'{sid}:{task_id}');db.commit();return RedirectResponse(f'/sites/{sid}#work',303)
@app.post('/sites/{sid}/boq')
def add_boq(sid:int,request:Request,section:str=Form(...),item_code:str=Form(''),description:str=Form(...),unit:str=Form(...),quantity:Decimal=Form(...),unit_rate:Decimal=Form(...),scope:str=Form('MANDATORY'),db=Depends(db_dep)):
 guard(request);u=require(request,db,True)
 if not db.get(Site,sid):raise HTTPException(404)
 if section not in list('ABCDEFGHIJKLMNOPQR') or scope not in ('MANDATORY','ADDITIONAL') or quantity<0 or unit_rate<0:raise HTTPException(422)
 b=BoqItem(site_id=sid,section=section,item_code=item_code,description=description,unit=unit,quantity=quantity,unit_rate=unit_rate,scope=scope);db.add(b);db.flush();audit(db,u,'CREATE','boq',b.id,description[:100]);db.commit();return RedirectResponse(f'/sites/{sid}#boq',303)
@app.post('/sites/{sid}/documents')
async def document_upload(sid:int,request:Request,category:str=Form(...),version:str=Form('1'),file:UploadFile=File(...),db=Depends(db_dep)):
 guard(request);u=require(request,db,True)
 if not db.get(Site,sid):raise HTTPException(404)
 ext=Path(file.filename or '').suffix.lower()
 if ext not in ('.pdf','.docx','.xlsx','.png','.jpg','.jpeg','.dwg','.dxf','.csv'):raise HTTPException(422,'Unsupported file type')
 if category not in ('SSR','DP','DRAWING','BOQ','APPROVAL','PROGRESS','PAC','FAC','OTHER'):raise HTTPException(422)
 data=await file.read(20*1024*1024+1)
 if len(data)>20*1024*1024:raise HTTPException(413,'Max 20 MB')
 stored=uuid.uuid4().hex+ext;(upload_dir/stored).write_bytes(data)
 d=Document(site_id=sid,category=category,filename=Path(file.filename).name[:255],storage_name=stored,version=version[:30],uploaded_by=u.id);db.add(d);db.flush();audit(db,u,'UPLOAD','document',d.id,d.filename);db.commit();return RedirectResponse(f'/sites/{sid}#documents',303)
@app.get('/documents/{did}')
def download(did:int,request:Request,db=Depends(db_dep)):
 require(request,db);d=db.get(Document,did)
 if not d:raise HTTPException(404)
 p=upload_dir/d.storage_name
 if not p.exists():raise HTTPException(404)
 return FileResponse(p,filename=d.filename,media_type='application/octet-stream')
@app.get('/export/sites.csv')
def export_sites(request:Request,db=Depends(db_dep)):
 require(request,db);o=io.StringIO();w=csv.writer(o);w.writerow(['site_key','name','cluster','state','district','tower_height','power_type','status'])
 for s in db.query(Site).order_by(Site.site_key):w.writerow([s.site_key,s.name,s.cluster.code,s.state,s.district,s.tower_height,s.power_type,s.status])
 return StreamingResponse(iter([o.getvalue()]),media_type='text/csv',headers={'Content-Disposition':'attachment; filename=J2MS_sites.csv'})
@app.post('/sites/import')
async def import_sites(request:Request,file:UploadFile=File(...),db=Depends(db_dep)):
 guard(request);u=require(request,db,True);raw=await file.read(2_000_001)
 if len(raw)>2_000_000:raise HTTPException(413)
 try:reader=list(csv.DictReader(io.StringIO(raw.decode('utf-8-sig'))))
 except Exception:raise HTTPException(422,'CSV must be UTF-8')
 if not {'site_key','name','cluster'}.issubset(reader[0].keys() if reader else []):raise HTTPException(422,'CSV requires site_key,name,cluster')
 errors=[];staged=[];seen=set();clusters={x.code:x.id for x in db.query(Cluster)}
 for line,row in enumerate(reader,2):
  key=(row.get('site_key') or '').strip().upper();code=(row.get('cluster') or '').strip().upper();name=(row.get('name') or '').strip()
  if not re.fullmatch(r'JEN2_\d{4,}',key) or code not in clusters or not name or key in seen or db.query(Site).filter_by(site_key=key).first():errors.append(f'Line {line}: invalid or duplicate site/cluster');continue
  seen.add(key);staged.append(Site(site_key=key,name=name,cluster_id=clusters[code],state=row.get('state'),district=row.get('district')))
 if errors:raise HTTPException(422,'; '.join(errors[:15]))
 db.add_all(staged);db.flush();audit(db,u,'IMPORT','site',0,f'{len(staged)} sites');db.commit();return RedirectResponse('/',303)
@app.get('/audit')
def audit_page(request:Request,db=Depends(db_dep)):
 u=require(request,db)
 if u.role!='admin':raise HTTPException(403)
 return render(request,'audit.html',db,logs=db.query(Audit).order_by(Audit.id.desc()).limit(200).all())
@app.get('/health')
def health():return {'status':'ok','service':'J2MS'}
from fastapi.staticfiles import StaticFiles
app.mount('/static',StaticFiles(directory=str(Path(__file__).parent/'static')),name='static')
