from sys import stdout
import threading
from typing import Optional

from flask import g, request
from pusher_push_notifications import PushNotifications
from sqlalchemy import select, update
from sqlalchemy.orm import Query, aliased
from sqlalchemy.sql.expression import alias, func, text

from files.classes import Comment, Notification, Subscription, User
from files.classes.visstate import StateMod
from files.helpers.alerts import NOTIFY_USERS
from files.helpers.assetcache import assetcache_path
from files.helpers.config.environment import (PUSHER_ID, PUSHER_KEY, SITE_FULL,
                                              SITE_ID)

if PUSHER_ID != 'blahblahblah':
	beams_client = PushNotifications(instance_id=PUSHER_ID, secret_key=PUSHER_KEY)

def pusher_thread(interests, c, username):
	if len(c.body) > 500: notifbody = c.body[:500] + '...'
	else: notifbody = c.body

	beams_client.publish_to_interests(
		interests=[interests],
		publish_body={
			'web': {
				'notification': {
					'title': f'New reply by @{username}',
					'body': notifbody,
					'deep_link': f'{SITE_FULL}/comment/{c.id}?context=8&read=true#context',
					'icon': SITE_FULL + assetcache_path(f'images/{SITE_ID}/icon.webp'),
				}
			},
			'fcm': {
				'notification': {
					'title': f'New reply by @{username}',
					'body': notifbody,
				},
				'data': {
					'url': f'/comment/{c.id}?context=8&read=true#context',
				}
			}
		},
	)
	stdout.flush()


def update_stateful_counters(comment, delta):
	"""
	When a comment changes publish status, we need to update all affected stateful
	comment counters (e.g. author comment count, post comment count)
	"""
	update_post_comment_count(comment, delta)
	update_author_comment_count(comment, delta)
	update_ancestor_descendant_counts(comment, delta)

def update_post_comment_count(comment, delta):
	author = comment.author
	comment.post.comment_count += delta
	g.db.add(comment.post)

def update_author_comment_count(comment, delta):
	author = comment.author
	comment.author.comment_count = g.db.query(Comment).filter(
		Comment.author_id == comment.author_id,
		Comment.parent_submission != None,
		Comment.state_mod == StateMod.VISIBLE,
		Comment.state_user_deleted_utc == None,
	).count()
	g.db.add(comment.author)

def update_ancestor_descendant_counts(comment, delta):
	if comment.parent_comment_id is None:
		return

	# Use a recursive CTE to find all ancestors and update in one query,
	# instead of walking the parent chain one lazy load at a time.
	base = select(Comment.id, Comment.parent_comment_id).where(
		Comment.id == comment.parent_comment_id
	).cte(name='ancestors', recursive=True)

	base = base.union_all(
		select(Comment.id, Comment.parent_comment_id).where(
			Comment.id == base.c.parent_comment_id
		)
	)

	g.db.execute(
		update(Comment)
		.where(Comment.id.in_(select(base.c.id)))
		.values(descendant_count=Comment.descendant_count + delta)
		.execution_options(synchronize_session=False)
	)

def bulk_recompute_descendant_counts(predicate = None, db=None):
	"""
	Recomputes the descendant_count of a large number of comments.

	The descendant_count of a comment is equal to the number of direct visible child comments
	plus the sum of the descendant_count of those visible child comments.

	:param predicate: If set, only update comments matching this predicate
	:param db: If set, use this instead of g.db

	So for example

		>>> bulk_update_descendant_counts()

	will update all comments, while

		>>> bulk_update_descendant_counts(lambda q: q.where(Comment.parent_submission == 32)

	will only update the descendant counts of comments where parent_submission=32

	Internally, how this works is
		1. Find the maximum level of comments matching the predicate
		2. Starting from that level and going down, for each level update the descendant_counts

	Since the comments at the max level will always have 0 children, this means that we will perform
	`level` updates to update all comments.

	The update query looks like

		UPDATE comments
		SET descendant_count=descendant_counts.descendant_count
		FROM (
			SELECT
				parent_comments.id AS id,
				coalesce(sum(child_comments.descendant_count + 1), 0) AS descendant_count
			FROM comments AS parent_comments
				LEFT OUTER JOIN comments AS child_comments ON parent_comments.id = child_comments.parent_comment_id
			GROUP BY parent_comments.id
		) AS descendant_counts
		WHERE comments.id = descendant_counts.id
			AND comments.level = :level_1
			<predicate goes here>
	"""
	db = db if db is not None else g.db
	max_level_query = select(func.max(Comment.level))
	if predicate:
		max_level_query = predicate(max_level_query)

	max_level = db.execute(max_level_query).scalar()

	if max_level is None:
		max_level = 0

	for level in range(max_level, 0, -1):
		parent_comments = alias(Comment, name="parent_comments")
		child_comments = alias(Comment, name="child_comments")
		descendant_counts = aliased(
			Comment,
			(
				select(parent_comments)
				.outerjoin(
					child_comments,
					parent_comments.corresponding_column(Comment.id) == child_comments.corresponding_column(Comment.parent_comment_id)
				)
				.group_by(parent_comments.corresponding_column(Comment.id))
				.with_only_columns(
					parent_comments.corresponding_column(Comment.id),
					func.coalesce(
						func.sum(child_comments.corresponding_column(Comment.descendant_count) + text(str(1))),
						text(str(0))
					).label('descendant_count')
				)
				.subquery(name='descendant_counts')
			),
			adapt_on_names=True
		)
		update_statement = (
			update(Comment)
			.values(descendant_count=descendant_counts.descendant_count)
			.execution_options(synchronize_session=False)
			.where(Comment.id == descendant_counts.id)
			.where(Comment.level == level)
		)
		if predicate:
			update_statement = predicate(update_statement)
		db.execute(update_statement)
	db.commit()

def comment_on_publish(comment:Comment):
	"""
	Run when comment becomes visible: immediately for non-filtered comments,
	or on approval for previously filtered comments.
	Should be used to update stateful counters, notifications, etc. that
	reflect the comments users will actually see.
	"""
	author = comment.author

	# Shadowbanned users are invisible. This may lead to inconsistencies if
	# a user comments while shadowed and is later unshadowed. (TODO?)
	if author.shadowbanned:
		return

	# Comment instances used for purposes other than actual comments (notifs,
	# DMs) shouldn't be considered published.
	if not comment.parent_submission:
		return

	# Generate notifs for: mentions, post subscribers, parent post/comment
	to_notify = NOTIFY_USERS(comment.body, comment.author)

	post_subscribers = g.db.query(Subscription.user_id).filter(
			Subscription.submission_id == comment.parent_submission,
			Subscription.user_id != comment.author_id,
		).all()
	to_notify.update([x[0] for x in post_subscribers])

	parent = comment.parent
	if parent and parent.author_id != comment.author_id and not parent.author.is_blocking(author):
		to_notify.add(parent.author_id)

	for uid in to_notify:
		notif = g.db.query(Notification) \
					.filter_by(comment_id=comment.id, user_id=uid).one_or_none()
		if not notif:
			notif = Notification(comment_id=comment.id, user_id=uid)
			g.db.add(notif)

	update_stateful_counters(comment, +1)

	# Generate push notifications if enabled.
	if PUSHER_ID != 'blahblahblah' and comment.author_id != parent.author_id:
		try:
			thread = threading.Thread(
				target=pusher_thread,
				args=(f'{request.host}{parent.author.id}', comment, comment.author_name),
				daemon=True
			)
			thread.start()
		except: pass

def comment_on_unpublish(comment:Comment):
	"""
	Run when a comment becomes invisible: when a moderator makes the comment non-visible
	by changing the state_mod to "removed", or when the user deletes the comment.
	Should be used to update stateful counters, notifications, etc. that
	reflect the comments users will actually see.
	"""
	update_stateful_counters(comment, -1)


def comment_filter_moderated(q: Query, v: Optional[User]) -> Query:
	if not (v and v.shadowbanned) and not (v and v.admin_level >= 3):
		q = q.join(User, User.id == Comment.author_id) \
		     .filter(User.shadowbanned == None)
	if not v or v.admin_level < 2:
		q = q.filter(
			(Comment.state_mod == StateMod.VISIBLE)
			| (Comment.author_id == ((v and v.id) or 0))
		)
	return q
