#!/usr/bin/env python3

import functools, json, os, time, werkzeug
from datetime import datetime
from flask import Flask, redirect, render_template, request, session
from flask_mail import Mail
from flask_mail import Message as EmailMessage
from hashlib import scrypt as hash_func
from markupsafe import escape
from peewee import *
from secrets import token_urlsafe
from time import strftime
from werkzeug.exceptions import BadRequestKeyError
from werkzeug.utils import secure_filename


# TODO: Rate limits

app = Flask(__name__)
app.config.from_file('config.json', load=json.load)
app.secret_key = app.config['SECRET_KEY']

emailer = Mail(app)

# Logic
#######################################

def allowed_file(filename):
	return '.' in filename and filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS']

def check_unread_messages():
	if not session.get('user_id'):
		return
	
	threads = ThreadView.select().where(ThreadView.owner_id == session['user_id'])
	for thread in threads:
		if thread.unread:
			session['unread_messages'] = True
			return
	
	session['unread_messages'] = False

def hash_str(password):
	salt_bytes = app.config['SALT'].encode()
	return hash_func(password.encode(), salt=salt_bytes, n=16384, r=10, p=1)

def login(username, password):
	try:
		user = User.get(
			(User.username == username) |
			(User.email == username)
		)
	except DoesNotExist:
		return render_template('login.html', warning='Login incorrect'), 401
	
	password_hash = hash_str(request.form['password'])
	if password_hash != user.password_hash:
		return render_template('login.html', warning='Login incorrect'), 401
	
	# We're in!
	session.clear()
	
	session['user_id'] = user.id
	session['username'] = user.username
	
	check_unread_messages()
	
	return redirect(request.args.get('next') or '/')

def login_required(func):
	@functools.wraps(func)
	def decorated_login_required(*args, **kwargs):
		if 'user_id' not in session:
			return redirect('/login?next='+request.path)
		elif User.get_by_id(session['user_id']).active != True:
			return render_template('error.html', error="Your account is inactive. Check whether you've received a confirmation email."), 403
		else:
			return func(*args, **kwargs)
	return decorated_login_required


# Models
#######################################

db = SqliteDatabase(app.config['DB_PATH'], pragmas={'foreign_keys': 1})

class BaseModel(Model):
	class Meta:
		database = db

class User(BaseModel):
	username = CharField(unique=True)
	email = CharField()
	password_hash = BlobField()
	about_me = TextField(null=True) # Currently unused
	active = BooleanField(default=True)
	activation_key = CharField(null=True)
	created_at = DateTimeField(default=datetime.now)

class Listing(BaseModel):
	title = CharField()
	description = TextField()
	location = CharField()
	owner = ForeignKeyField(User, backref='listings', on_delete='CASCADE')
	available = BooleanField(default=True)
	borrower = ForeignKeyField(User, backref='loans', null=True)
	primary_image_id = IntegerField(null=True)
	flagged = BooleanField(default=False)
	created_at = DateTimeField(default=datetime.now)

	@property
	def primary_image_path(self):
		try:
			if self.primary_image_id:
				image = ListingImage.get_by_id(self.primary_image_id)
			else:
				image = self.images.get()
			return image.path
		except DoesNotExist:
			return None

class ListingImage(BaseModel):
	listing = ForeignKeyField(Listing, backref='images', on_delete='CASCADE')
	path = CharField()
	created = DateTimeField(default=datetime.now)
	flagged = BooleanField(default=False)

class Message(BaseModel):
	text = TextField()
	author = ForeignKeyField(User, backref='sent_messages', on_delete='CASCADE')
	recipient = ForeignKeyField(User, backref='received_messages', null=True)
	listing = ForeignKeyField(Listing, backref='messages', null=True)
	created_at = DateTimeField(default=datetime.now)

class ThreadView(BaseModel): # Each ThreadView is one user's view on a series of messages
	owner = ForeignKeyField(User, backref='threads', on_delete='CASCADE')
	interlocutor = ForeignKeyField(User, null=True)
	unread = BooleanField()
	last_message = ForeignKeyField(Message, null=True)

# Called if __name__ == '__main__'
def setup_db():
	if os.path.isfile(app.config['DB_PATH']):
		raise RuntimeError('Database already exists!')
	
	db.connect()
	db.create_tables([
		User,
		Listing,
		ListingImage,
		Message,
		ThreadView,
	])

def populate_db():
	with db.atomic():
	
		admin = User.create(
			username='admin',
			email='admin@abc.com',
			password_hash = hash_str('admin') # WARNING TODO DEBUG
		)
		
		User.create(
			username='user',
			email='user@abc.com',
			password_hash = hash_str('user') # WARNING TODO DEBUG
		)
		
		User.create(
			username='test',
			email='test.com',
			password_hash = hash_str('test') # WARNING TODO DEBUG
		)
		
		listing = Listing.create(
			title='First listing!',
			description='Something you can borrow',
			location='Aber',
			owner=admin,
		)

		image = ListingImage.create(
			listing = listing,
			path = '2025-03-12T16:58:40 TheClockIsTicking.png',
		)


# Routes
#######################################

# TODO: Max image size; thumbnails

@app.get('/')
def index():
	check_unread_messages()
	
	message = request.args.get('message')
	
	listings = Listing.select().where(Listing.available == True).order_by(Listing.created_at.desc()).limit(25)
	return render_template('index.html', listings=listings, message=message)

@app.get('/listing/<int:listing_id>')
def listing_get(listing_id):
	check_unread_messages()
	
	try:
		listing = Listing.get_by_id(listing_id)
		warning = None
		if not listing.available:
			warning = 'This item is currently unavailable!'
		
		return render_template('listing.html', listing=listing, warning=warning)
	except DoesNotExist:
		return render_template('error.html', error='Listing not found'), 404

@app.get('/listing/<listing_id>/edit')
@login_required
def edit_listing_get(listing_id):
	check_unread_messages()
	
	try:
		listing = Listing.get_by_id(listing_id)
	except DoesNotExist:
		return render_template('error.html', error='Listing not found'), 404

	owner = listing.owner_id
	if owner != session['user_id']:
		return render_template(
			'error.html',
			error="You don't have permission for that"
			), 403

	return render_template('listing-edit.html', listing=listing)

@app.post('/listing/<listing_id>/edit')
@login_required
def edit_listing_post(listing_id):
	check_unread_messages()
	
	try:
		listing = Listing.get_by_id(listing_id)
	except DoesNotExist:
		return render_template('error.html', error='Listing not found'), 404

	owner = listing.owner_id
	if owner != session['user_id']:
		return render_template(
			'error.html',
			error="You don't have permission for that"
		), 403
	
	listing.title = request.form['title']
	listing.description = request.form['description']
	listing.location = request.form['location']
	if request.form.get('available'):
		listing.available = True
	else:
		listing.available = False
	
	try:
		listing.primary_image_id = request.form['primary-image']
	except BadRequestKeyError:
		pass
	
	# Delete selected images (This seems ugly...)
	for key in request.form.keys():
		if key.startswith('delete-image-'):
			image_id = int(key.split('-')[-1])
			image = ListingImage.get_by_id(image_id)
			
			os.remove(os.path.join('static', 'images', image.path))
			if listing.primary_image_id == image_id:
				listing.primary_image_id = None
			image.delete_instance()
	
	# Upload images
	files = request.files.getlist("image")
	for file in files:
		if file.filename:
			filename = strftime("%Y-%m-%dT%H:%M:%S") + ' ' + secure_filename(file.filename)
			
			if not allowed_file(filename):
				return render_template(
					'error.html',
					error='Image filetype not allowed. Allowed types are: ' + ', '.join(app.config['ALLOWED_EXTENSIONS'])
				), 415
			file.save(os.path.join('./static/images/', filename))
			
			ListingImage(
				listing=listing,
				path=filename
			).save()
	
	listing.save()
	
	return render_template('listing-edit.html', listing=listing, message='Changes saved!')

@app.get('/login')
def login_get():
	check_unread_messages()
	return render_template('login.html')

@app.post('/login')
def login_post():
	time.sleep(1) # Slow brute-forcers
	username = request.form['username']
	password = request.form['password']
	return login(username, password)

@app.get('/logout')
def logout():
	session.clear()
	return redirect('/')

@app.get('/me')
@login_required
def user_get():
	if not session.get('user_id'):
		raise RuntimeError('Not logged in!')
	
	check_unread_messages()
	
	try:
		user = User.get_by_id(session['user_id'])
	except DoesNotExist:
		return render_template('error.html', error='User does not exist'), 400
	
	page_no = request.args.get('page', default=1, type=int) # TODO: Next page button
	
	unread_count = (ThreadView
		.select()
		.where((ThreadView.owner == user) & (ThreadView.unread == True))
		.count()
	)
	
	listings = (Listing
		.select()
		.where(Listing.owner == user)
		.order_by(Listing.created_at.desc())
		.paginate(page_no, 25)
	)
	
	return render_template('user.html', user=user, unread_count=unread_count, listings=listings)

@app.post('/me')
@login_required
def user_post():
	if not session.get('user_id'):
		raise RuntimeError('Not logged in!')
	
	check_unread_messages()
	
	try:
		user = User.get_by_id(session['user_id'])
	except DoesNotExist:
		return render_template('error.html', error='User does not exist'), 400
	
	unread_count = (ThreadView
		.select()
		.where((ThreadView.owner == user) & (ThreadView.unread == True))
		.count()
	)
	
	listings = (Listing
		.select()
		.where(Listing.owner == user)
		.order_by(Listing.created_at.desc())
		.paginate(page_no, 25)
	)
	
	if request.form.get('username') != user.username:
		if User.get_or_none(User.username == request.form['username']): # username already in use
			return render_template('user.html', user=user, listings=listings, warning='Username already in use!'), 400
		user.username = request.form['username']
	
	if request.form.get('email') != user.email:
		if User.get_or_none(User.email == request.form['email']): # email already in use
			return render_template('user.html', user=user, listings=listings, warning='Email address already in use!'), 400
		user.email = request.form['email']
	
	if request.form.get('password'):
		if request.form.get('password') != request.form.get('password-confirm'):
			return render_template('user.html', user=user, listings=listings, warning="Passwords don't match!")
	user.password_hash = hash_str(request.form['password'])
	
	user.save()
	
	return render_template('user.html', user=user, unread_count=unread_count, message='Info updated!')

@app.get('/me/delete')
@login_required
def user_delete_get():
	check_unread_messages()
	
	return render_template('user-delete.html')

@app.post('/me/delete')
@login_required
def user_delete_post():
	if not session.get('user_id'):
		raise RuntimeError('Not logged in!')
	
	try:
		user = User.get_by_id(session['user_id'])
	except DoesNotExist:
		return render_template('error.html', error='User does not exist'), 400
	if not session.get('user_id'):
		raise RuntimeError('Not logged in!')
	
	check_unread_messages()
	
	user = User.get_by_id(session['user_id'])
	
	confirmation = request.form['confirm-delete']
	if confirmation != user.username:
		return render_template('user-delete.html', warning='Type your username to confirm you really want to delete your account. Did you spell it correctly?'), 400
	
	listings = Listing.select().where(Listing.owner == user)
	images = ListingImage.select().where(ListingImage.listing.in_(listings))
	for image in images:
		os.remove(os.path.join('static', 'images', image.path))
		
	user.delete_instance(recursive=True)
	
	session.clear()
	
	return redirect('/?message=Account deleted!')

@app.get('/messages')
@login_required
def get_my_messages():
	if not session.get('user_id'):
		raise RuntimeError('Not logged in!')
	
	check_unread_messages()

	page_no = request.args.get('page', default=1, type=int) # TODO: Next page button
	pagination_no = 50

	threads = (ThreadView
		.select(ThreadView, Message)
		.join(Message)
		.where(ThreadView.owner_id == session['user_id'])
		.order_by(ThreadView.last_message.created_at.desc())
		.paginate(page_no, pagination_no)
	)

	return render_template('messages.html', threads=threads, page_no=page_no, pagination_no=pagination_no)

@app.get('/messages/<interlocutor_id>')
@login_required
def get_message_thread(interlocutor_id):
	if not session.get('user_id'):
		raise RuntimeError('Not logged in!')
	
	try:
		interlocutor = User.get_by_id(interlocutor_id)
	except DoesNotExist:
		return render_template('error.html', error='Not found, this user may no longer exist.'), 404

	(ThreadView
		.update(unread=False)
		.where((ThreadView.owner_id == session['user_id']) & (ThreadView.interlocutor == interlocutor))
	).execute()
	
	check_unread_messages()
	
	page_no = request.args.get('page', default=1, type=int)
	pagination_no = 50

	messages = (Message
		.select()
		.where(
			((Message.author_id == session['user_id']) & (Message.recipient_id == interlocutor_id)) |
			((Message.author_id == interlocutor_id) & (Message.recipient_id == session['user_id']))
		).order_by(Message.created_at)
		.paginate(page_no, pagination_no)
	)
	
	return render_template('message-thread.html', messages=messages, interlocutor=interlocutor, page_no=page_no, pagination_no=pagination_no)

@app.post('/messages/<interlocutor_id>')
@login_required
def post_message(interlocutor_id):
	if not session.get('user_id'):
		raise RuntimeError('Not logged in!')
	
	check_unread_messages()
	
	sender = User.get_by_id(session['user_id'])
	listing = None
	listing_id = request.args.get('listing_id', type=int)
	if listing_id:
		try:
			listing = Listing.get_by_id(listing_id)
		except DoesNotExist:
			return render_template('error.html', error='No such listing'), 404
	
	try:
		recipient = User.get_by_id(interlocutor_id)
	except DoesNotExist:
		return render_template('error.html', error='Not found, this user may no longer exist'), 404
	
	if len(request.form['text']) <= 0:
		return render_template('error.html', error='Message is empty!'), 422

	with db.atomic():
		
		message = Message.create(
			author=sender,
			recipient=recipient,
			text=request.form['text'],
			listing=listing
		)

		try:
			sender_thread = ThreadView.get((ThreadView.owner == sender) & (ThreadView.interlocutor == recipient))
			sender_thread.last_message = message
			sender_thread.unread = False
			sender_thread.save()
		except DoesNotExist:
			sender_thread = ThreadView.create(
				owner = sender,
				interlocutor = recipient,
				unread=False,
				last_message=message
			)
		
		try:
			recipient_thread = ThreadView.get((ThreadView.owner == recipient) & (ThreadView.interlocutor == sender))
			recipient_thread.last_message = message
			recipient_thread.unread = True
			recipient_thread.save()
		except DoesNotExist:
			recipient_thread = ThreadView.create(
				owner = recipient,
				interlocutor = sender,
				unread=True,
				last_message=message
			)
	
	# Send email
	message_url = f"{app.config['SITE_URL']}/messages/{sender.id}#{message.id}"
	subject = f"{app.config['SITE_NAME']} Message from {sender.username}"
	escaped_message = escape(request.form['text'])
	
	text = f"{sender.username} sent you a message:\n\n{escaped_message}\n\nSee it here: {message_url}"
	html = f"""<h2>{sender.username} sent you a message:</h2>
	<br />
	<blockquote style='white-space: pre-wrap;'>{escaped_message}</blockquote>
	<br />
	See it here: <a href='{message_url}'>{message_url}</a>
	"""
	
	email = EmailMessage(
		subject=subject,
		recipients=[recipient.email],
		body=text,
		html=html
	)
	emailer.send(email)
	
	if listing_id:
		return redirect(f'/messages/{recipient.id}?listing_id={listing_id}')
	
	return redirect(f'/messages/{recipient.id}')

@app.get('/new-listing')
@login_required
def new_listing_get():
	if not session.get('user_id'):
		raise RuntimeError('Not logged in!')
	
	return render_template('listing-new.html')

@app.post('/new-listing')
@login_required
def new_listing_post():
	if not session.get('user_id'):
		raise RuntimeError('Not logged in!')
	
	if (
		len(request.form['title']) < 1 or
		len(request.form['description']) < 1 or
		len(request.form['location']) <1
	):
		return render_template('listing-new.html', warning="Please fill (at least) Title, Description & Location.")
	
	listing = Listing.create(
		title = request.form['title'],
		description = request.form['description'],
		location = request.form['location'],
		owner=User.get(session['user_id'])
	)
	
	# Upload images
	files = request.files.getlist("image")
	for file in files:
		if file.filename:
			filename = strftime("%Y-%m-%dT%H:%M:%S") + ' ' + secure_filename(file.filename)
			
			if not allowed_file(filename):
				return render_template(
					'error.html',
					error='Image filetype not allowed. Allowed types are: ' + ', '.join(app.config['ALLOWED_EXTENSIONS'])
				)
			file.save(os.path.join('./static/images/', filename))
			
			ListingImage.create(
				listing=listing,
				path=filename
			)
	
	return redirect(f'/listing/{listing.id}/edit?message=Listing created!')

@app.get('/signup')
def signup_get():
	check_unread_messages()
	
	return render_template('signup.html')

@app.post('/signup')
def signup_post():
	if (
		len(request.form['username']) < 1 or
		len(request.form['email']) < 1 or
		len(request.form['password']) < 1 or
		len(request.form['password-confirm']) < 1
	):
		return render_template('signup.html', warning="Please fill all fields!")
	
	if request.form['password'] != request.form['password-confirm']:
		return render_template('signup.html', warning="Passwords don't match!")
	
	if User.get_or_none(User.username == request.form['username']): # username already in use
			return render_template('signup.html', warning='Username already in use!')
	
	if User.get_or_none(User.email == request.form['email']): # email already in use
		return render_template('signup.html', warning='Email already in use!')
	
	activation_key = token_urlsafe(40)
	
	user = User.create(
		username=request.form['username'],
		email=request.form['email'],
		password_hash=hash_str(request.form['password']),
		active=False,
		activation_key=activation_key
	)
	
	# Send confirmation email
	validation_link = f"{app.config['SITE_URL']}/signup/{user.id}?k={activation_key}"
	subject = f"{app.config['SITE_NAME']} - Confirm your email address"
	text = f"Follow this link to validate your email address and activate your account:\n{validation_link}"
	html = f"""
	<h2>Welcome to {app.config['SITE_NAME']}!</h2>
	<p> Click this link to activate your account:
	<br />
	<a href='{validation_link}'>{validation_link}</a>
	</p>
	"""
	
	email = EmailMessage(
		subject=subject,
		recipients=[user.email],
		body=text,
		html=html,
	)
	emailer.send(email)
	
	return render_template('signup-success.html')

@app.get('/signup/<user_id>')
def signup_valdiate_post(user_id):
	try:
		user = User.get_by_id(user_id)
	except DoesNotExist:
		return render_template('error.html', error='User does not exist'), 400
	
	key = request.args.get('k')
	if key != user.activation_key:
		return render_template('error.html', error='Incorrect key'), 403
	
	user.active=True
	user.activation_key = None
	user.save()
	
	return render_template('user-verified.html')

if __name__ == '__main__':
	setup_db()
	populate_db()
