#!/usr/bin/env python3
"""StockBridge private single-owner server. Run behind an HTTPS reverse proxy."""
import os,json,secrets,time,hashlib,hmac,threading
from pathlib import Path
from http.server import ThreadingHTTPServer,SimpleHTTPRequestHandler
from http.cookies import SimpleCookie
from core import Engine,DomainError
ROOT=Path(__file__).parent
DB=os.environ.get('STOCKBRIDGE_DB',str(ROOT/'data'/'stockbridge.db'))
SESSIONS={};ATTEMPTS={};LOCK=threading.Lock()
HASH=os.environ.get('ADMIN_PASSWORD_HASH','')
SECURE=os.environ.get('COOKIE_SECURE','0')=='1'
def password_hash(password,salt=None):
 salt=salt or secrets.token_hex(16)
 return salt+':'+hashlib.pbkdf2_hmac('sha256',password.encode(),bytes.fromhex(salt),600000).hex()
def check_password(p):
 try:
  salt,expected=HASH.split(':',1)
  return hmac.compare_digest(password_hash(p,salt).split(':',1)[1],expected)
 except ValueError:return False
class Handler(SimpleHTTPRequestHandler):
 def __init__(self,*a,**kw):super().__init__(*a,directory=str(ROOT/'public'),**kw)
 def log_message(self,*a):pass
 def end_headers(self):
  self.send_header('X-Content-Type-Options','nosniff');self.send_header('Referrer-Policy','same-origin');self.send_header('X-Frame-Options','DENY')
  self.send_header('Content-Security-Policy',"default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
  super().end_headers()
 def reply(self,status,obj,cookie=None):
  data=json.dumps(obj).encode();self.send_response(status);self.send_header('Content-Type','application/json');self.send_header('Cache-Control','no-store');self.send_header('Content-Length',str(len(data)))
  if cookie:self.send_header('Set-Cookie',cookie)
  self.end_headers();self.wfile.write(data)
 def session(self):
  try:
   c=SimpleCookie(self.headers.get('Cookie',''));token=c['sb_session'].value
   with LOCK:
    s=SESSIONS.get(token)
    if s and s['expires']>time.time():return s
  except (KeyError,ValueError):pass
  return None
 def origin_ok(self):
  origin=self.headers.get('Origin')
  return not origin or origin in ['http://'+self.headers.get('Host',''),'https://'+self.headers.get('Host','')]
 def do_GET(self):
  if self.path=='/api/health':return self.reply(200,{'app':'stockbridge'})
  if self.path.startswith('/api/'):
   s=self.session()
   if not s:return self.reply(401,{'error':'Sign in required'})
   if self.path=='/api/session':return self.reply(200,{'csrf':s['csrf']})
   if self.path=='/api/state':
    e=Engine(DB)
    try:return self.reply(200,e.state())
    finally:e.close()
   return self.reply(404,{'error':'Not found'})
  return super().do_GET()
 def do_POST(self):
  if not self.origin_ok():return self.reply(403,{'error':'Origin rejected'})
  try:
   size=int(self.headers.get('Content-Length','0'))
   if size>1000000:return self.reply(413,{'error':'Request too large'})
   data=json.loads(self.rfile.read(size) or b'{}')
  except (ValueError,TypeError):return self.reply(400,{'error':'Invalid JSON'})
  if self.path=='/api/login':
   ip=self.client_address[0]
   with LOCK:
    attempts=[t for t in ATTEMPTS.get(ip,[]) if t>time.time()-900];ATTEMPTS[ip]=attempts
    if len(attempts)>=10:return self.reply(429,{'error':'Too many attempts. Try again in 15 minutes.'})
    ATTEMPTS[ip].append(time.time())
   if not isinstance(data.get('password'),str) or not check_password(data['password']):return self.reply(401,{'error':'Incorrect password'})
   token=secrets.token_urlsafe(32);csrf=secrets.token_urlsafe(32)
   with LOCK:
    ATTEMPTS.pop(ip,None)
    for old in [k for k,v in SESSIONS.items() if v['expires']<=time.time()]:SESSIONS.pop(old,None)
    SESSIONS[token]={'csrf':csrf,'expires':time.time()+28800}
   return self.reply(200,{'csrf':csrf},'sb_session='+token+'; HttpOnly; SameSite=Strict; Path=/; Max-Age=28800'+('; Secure' if SECURE else ''))
  s=self.session()
  if not s:return self.reply(401,{'error':'Sign in required'})
  if not hmac.compare_digest(self.headers.get('X-CSRF-Token',''),s['csrf']):return self.reply(403,{'error':'Invalid CSRF token'})
  if self.path=='/api/logout':
   c=SimpleCookie(self.headers.get('Cookie',''))
   with LOCK:SESSIONS.pop(c['sb_session'].value,None)
   return self.reply(200,{'ok':True},'sb_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0')
  if self.path=='/api/action':
   e=Engine(DB)
   try:return self.reply(200,e.apply(data['action'],data['data']))
   except (DomainError,ValueError,KeyError,TypeError) as ex:return self.reply(400,{'error':str(ex)})
   except Exception as ex:
    import sqlite3
    if isinstance(ex,sqlite3.IntegrityError):return self.reply(400,{'error':'Duplicate SKU or invalid product/store mapping'})
    return self.reply(500,{'error':'Database operation failed'})
   finally:e.close()
  return self.reply(404,{'error':'Not found'})
if __name__=='__main__':
 import sys,getpass
 if '--hash-password' in sys.argv:
  p=getpass.getpass('Choose an administrator password (12+ characters): ')
  if len(p)<12:raise SystemExit('Use at least 12 characters')
  print(password_hash(p));raise SystemExit
 if not HASH:raise SystemExit('Set ADMIN_PASSWORD_HASH. Generate it with python3 server.py --hash-password')
 Path(DB).parent.mkdir(parents=True,exist_ok=True)
 e=Engine(DB);e.seed();e.close()
 os.chmod(DB,0o600)
 host=os.environ.get('BIND_HOST','127.0.0.1');port=int(os.environ.get('PORT','8080'))
 print(f'StockBridge listening on http://{host}:{port}')
 ThreadingHTTPServer((host,port),Handler).serve_forever()
