// matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge.
// Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.
import { Long } from "bson"

import {
	AuthApiClient,
	OAuthApiClient,
	ServiceApiClient,
	TalkClient,
	KnownAuthStatusCode,
	util,
} from "node-kakao"
/** @typedef {import("node-kakao").OAuthCredential} OAuthCredential */
/** @typedef {import("node-kakao").ChannelType} ChannelType */
/** @typedef {import("node-kakao").ReplyAttachment} ReplyAttachment */
/** @typedef {import("node-kakao").MentionStruct} MentionStruct */
/** @typedef {import("node-kakao/dist/talk").TalkChannelList} TalkChannelList */

import chat from "node-kakao/chat"
const { KnownChatType } = chat

import { emitLines, promisify } from "./util.js"

/**
 * @typedef {Object} ChannelProps
 * @property {Long} id
 * @property {ChannelType} type
 */


ServiceApiClient.prototype.requestFriendList = async function() {
	const res = await this._client.requestData(
		"POST",
		`${this.getFriendsApiPath("update.json")}`,
		{
			phone_number_type: 1,
		}
	);

	return {
		status: res.status,
		success: res.status === 0,
		result: res,
	};
}


class UserClient {
	static #initializing = false

	#talkClient = new TalkClient()
	get talkClient() { return this.#talkClient }

	/** @type {ServiceApiClient} */
	#serviceClient
	get serviceClient() { return this.#serviceClient }

	/**
	 * DO NOT CONSTRUCT DIRECTLY. Callers should use {@link UserClient#create} instead.
	 * @param {string} mxid
	 * @param {PeerClient} peerClient TODO Make RPC user-specific instead of needing this
	 */
	constructor(mxid, peerClient) {
		if (!UserClient.#initializing) {
			throw new Error("Private constructor")
		}
		UserClient.#initializing = false

		this.mxid = mxid
		this.peerClient = peerClient

		this.#talkClient.on("chat", (data, channel) => {
			this.log(`Received chat message ${data.chat.logId} in channel ${channel.channelId}`)
			return this.write("chat", {
				//is_sequential: true, // TODO Make sequential per user & channel (if it isn't already)
				chatlog: data.chat,
				channelId: channel.channelId,
				channelType: channel.info.type,
			})
		})

		/* TODO Many more listeners
		this.#talkClient.on("chat_read", (chat, channel, reader) => {
			this.log(`chat_read in channel ${channel.channelId}`)
			//chat.logId
		})
		*/

		this.#talkClient.on("disconnected", (reason) => {
			this.log(`Disconnected (reason=${reason})`)
			this.disconnect()
			return this.write("disconnected", {
				reason: reason,
			})
		})

		this.#talkClient.on("switch_server", () => {
			this.log(`Server switch requested`)
			return this.write("switch_server", {
				is_sequential: true,
			})
		})
	}

	/**
	 * @param {string} mxid The ID of the associated Matrix user
	 * @param {OAuthCredential} credential The token to log in with, obtained from prior login
	 * @param {PeerClient} peerClient What handles RPC
	 */
	static async create(mxid, credential, peerClient) {
		this.#initializing = true
		const userClient = new UserClient(mxid, peerClient)

		userClient.#serviceClient = await ServiceApiClient.create(credential)
		return userClient
	}


	log(...text) {
		console.log(`[API/${this.mxid}]`, ...text)
	}

	error(...text) {
		console.error(`[API/${this.mxid}]`, ...text)
	}

	/**
	 * @param {ChannelProps} channelProps
	 */
	async getChannel(channelProps) {
		let channel = this.#talkClient.channelList.get(channelProps.id)
		if (channel) {
			return channel
		} else {
			const channelList = getChannelListForType(
				this.#talkClient.channelList,
				channelProps.type
			)
			const res = await channelList.addChannel({
				channelId: channelProps.id,
			})
			if (!res.success) {
				throw new Error(`Unable to add ${channelProps.type} channel ${channelProps.id}`)
			}
			return res.result
		}
	}

	/**
	 * @param {OAuthCredential} credential The token to log in with, obtained from prior login
	 */
	async connect(credential) {
		// TODO Don't re-login if possible. But must still return a LoginResult!
		this.disconnect()
		return await this.#talkClient.login(credential)
	}

	disconnect() {
		if (this.#talkClient.logon) {
			this.#talkClient.close()
		}
	}

	/**
	 * Send a user-specific command with (optional) data to the socket.
	 *
	 * @param {string} command - The data to write.
	 * @param {?object} data - The data to write.
	 */
	write(command, data) {
		return this.peerClient.write({
			id: --this.peerClient.notificationID,
			command: `${command}:${this.mxid}`,
			...data
		})
	}
}

export default class PeerClient {
	/**
	 * @param {import("./clientmanager.js").default} manager
	 * @param {import("net").Socket} socket
	 * @param {number} connID
	 */
	constructor(manager, socket, connID) {
		this.manager = manager
		this.socket = socket
		this.connID = connID

		this.stopped = false
		this.notificationID = 0
		this.maxCommandID = 0
		this.peerID = null

		/** @type {Map<string, UserClient>} */
		this.userClients = new Map()
	}


	log(...text) {
		if (this.peerID) {
			console.log(`[API/${this.peerID}/${this.connID}]`, ...text)
		} else {
			console.log(`[API/${this.connID}]`, ...text)
		}
	}

	error(...text) {
		if (this.peerID) {
			console.error(`[API/${this.peerID}/${this.connID}]`, ...text)
		} else {
			console.error(`[API/${this.connID}]`, ...text)
		}
	}

	start() {
		this.log("Received connection", this.connID)
		emitLines(this.socket)
		this.socket.on("line", line => this.handleLine(line)
			.catch(err => this.log("Error handling line:", err)))
		this.socket.on("end", this.handleEnd)

		setTimeout(() => {
			if (!this.peerID && !this.stopped) {
				this.log("Didn't receive register request within 3 seconds, terminating")
				this.stop("Register request timeout")
			}
		}, 3000)
	}

	async stop(error = null) {
		if (this.stopped) {
			return
		}
		this.stopped = true
		this.#closeUsers()
		try {
			await this.write({ id: --this.notificationID, command: "quit", error })
			await promisify(cb => this.socket.end(cb))
		} catch (err) {
			this.error("Failed to end connection:", err)
			this.socket.destroy(err)
		}
	}

	handleEnd = () => {
		this.stopped = true
		this.#closeUsers()
		if (this.peerID && this.manager.clients.get(this.peerID) === this) {
			this.manager.clients.delete(this.peerID)
		}
		this.log(`Connection closed (peer: ${this.peerID})`)
	}

	#closeUsers() {
		this.log("Closing all API clients for", this.peerID)
		for (const userClient of this.userClients.values()) {
			userClient.disconnect()
		}
		this.userClients.clear()
	}

	/**
	 * Write JSON data to the socket.
	 *
	 * @param {object} data - The data to write.
	 * @returns {Promise<void>}
	 */
	write(data) {
		return promisify(cb => this.socket.write(JSON.stringify(data, this.#writeReplacer) + "\n", cb))
	}

	/**
	 * @param {Object} req
	 * @param {string} req.passcode
	 * @param {string} req.uuid
	 * @param {Object} req.form
	 */
	registerDevice = async (req) => {
		// TODO Look for a deregister API call
		const authClient = await this.#createAuthClient(req.uuid)
		return await authClient.registerDevice(req.form, req.passcode, true)
	}

	/**
	 * Obtain login tokens. If this fails due to not having a device, also request a device passcode.
	 * @param {Object} req
	 * @param {string} req.uuid
	 * @param {Object} req.form
	 * @returns The response of the login attempt, including obtained
	 * credentials for subsequent token-based login. If a required device passcode
	 * request failed, its status is stored here.
	 */
	handleLogin = async (req) => {
		// TODO Look for a logout API call
		const authClient = await this.#createAuthClient(req.uuid)
		const loginRes = await authClient.login(req.form, true)
		if (loginRes.status === KnownAuthStatusCode.DEVICE_NOT_REGISTERED) {
			const passcodeRes = await authClient.requestPasscode(req.form)
			if (!passcodeRes.success) {
				loginRes.status = passcodeRes.status
			}
		}
		return loginRes
	}

	/**
	 * TODO Consider caching per-user
	 * @param {string} uuid
	 */
	async #createAuthClient(uuid) {
		return await AuthApiClient.create("KakaoTalk Bridge", uuid)
	}

	// TODO Wrapper for per-user commands

	/**
	 * Checked lookup of a UserClient for a given mxid.
	 * @param {string} mxid
	 * @returns {UserClient}
	 */
	#getUser(mxid) {
		const userClient = this.userClients.get(mxid)
		if (userClient === undefined) {
			throw new Error(`Could not find user ${mxid}`)
		}
		return userClient
	}

	/**
	 * Unchecked lookup of a UserClient for a given mxid.
	 * @param {string} mxid
	 * @returns {UserClient | undefined}
	 */
	#tryGetUser(mxid) {
		return this.userClients.get(mxid)
	}

	/**
	 * @param {string} mxid
	 * @param {ChannelProps} channelProps
	 */
	async #getUserChannel(mxid, channelProps) {
		return await this.#getUser(mxid).getChannel(channelProps)
	}

	/**
	 * @param {Object} req
	 * @param {OAuthCredential} req.oauth_credential
	 */
	handleRenew = async (req) => {
		// TODO Cache per user? Reset API client objects?
		const oAuthClient = await OAuthApiClient.create()
		return await oAuthClient.renew(req.oauth_credential)
	}

	/**
	 * @param {Object} req
	 * @param {string} req.mxid
	 * @param {OAuthCredential} req.oauth_credential
	 */
	userStart = async (req) => {
		const userClient = this.#tryGetUser(req.mxid) || await UserClient.create(req.mxid, req.oauth_credential, this)
		// TODO Should call requestMore/LessSettings instead
		const res = await userClient.serviceClient.requestMyProfile()
		if (res.success) {
			this.userClients.set(req.mxid, userClient)
		}
		return res
	}

	/**
	 * @param {Object} req
	 * @param {string} req.mxid
	 */
	userStop = async (req) => {
		this.handleDisconnect(req)
		this.userClients.delete(req.mxid)
	}

	/**
	 * @param {Object} req
	 * @param {string} req.mxid
	 * @param {OAuthCredential} req.oauth_credential
	 */
	handleConnect = async (req) => {
		return await this.#getUser(req.mxid).connect(req.oauth_credential)
	}

	/**
	 * @param {Object} req
	 * @param {string} req.mxid
	 */
	handleDisconnect = (req) => {
		this.#tryGetUser(req.mxid)?.disconnect()
	}

	/**
	 * @param {Object} req
	 * @param {?string} req.mxid
	 */
	getOwnProfile = async (req) => {
		return await this.#getUser(req.mxid).serviceClient.requestMyProfile()
	}

	/**
	 * @param {Object} req
	 * @param {?string} req.mxid
	 * @param {Long} req.user_id
	 */
	getProfile = async (req) => {
		return await this.#getUser(req.mxid).serviceClient.requestProfile(req.user_id)
	}

	/**
	 * @param {Object} req
	 * @param {string} req.mxid
	 * @param {ChannelProps} req.channel_props
	 */
	getPortalChannelInfo = async (req) => {
		const talkChannel = await this.#getUserChannel(req.mxid, req.channel_props)

		const res = await talkChannel.updateAll()
		if (!res.success) return res

		return this.#makeCommandResult({
			name: talkChannel.getDisplayName(),
			participants: Array.from(talkChannel.getAllUserInfo()),
			// TODO Image
		})
	}

	/**
	 * @param {Object} req
	 * @param {string} req.mxid
	 * @param {ChannelProps} req.channel_props
	 */
	getParticipants = async (req) => {
		const talkChannel = await this.#getUserChannel(req.mxid, req.channel_props)
		return await talkChannel.getAllLatestUserInfo()
	}

	/**
	 * @param {Object} req
	 * @param {string} req.mxid
	 * @param {ChannelProps} req.channel_props
	 * @param {?Long} req.sync_from
	 * @param {?Number} req.limit
	 */
	getChats = async (req) => {
		const talkChannel = await this.#getUserChannel(req.mxid, req.channel_props)

		const res = await talkChannel.getChatListFrom(req.sync_from)
		if (res.success && 0 < req.limit && req.limit < res.result.length) {
			res.result.splice(0, res.result.length - req.limit)
		}
		return res
	}

	/**
	 * @param {Object} req
	 * @param {string} req.mxid
	 */
	listFriends = async (req) => {
		return await this.#getUser(req.mxid).serviceClient.requestFriendList()
	}

	/**
	 * @param {Object} req
	 * @param {string} req.mxid The user whose friend is being looked up.
	 * @param {string} req.friend_id The friend to search for.
	 * @param {string} propertyName The property to retrieve from the specified friend.
	 */
	getFriendProperty = async (req, propertyName) => {
		const res = await this.#getUser(req.mxid).serviceClient.findFriendById(req.friend_id)
		if (!res.success) return res

		return this.#makeCommandResult(res.result.friend[propertyName])
	}

	/**
	 * @param {Object} req
	 * @param {string} req.mxid
	 */
	getMemoIds = (req) => {
		/** @type Long[] */
		const channelIds = []
		const channelList = this.#getUser(req.mxid).talkClient.channelList
		for (const channel of channelList.all()) {
			if (channel.info.type == "MemoChat") {
				channelIds.push(channel.channelId)
			}
		}
		return channelIds
	}

	/**
	 * @param {Object} req
	 * @param {string} req.mxid
	 * @param {ChannelProps} req.channel_props
	 * @param {string} req.text
	 * @param {?ReplyAttachment} req.reply_to
	 * @param {?MentionStruct[]} req.mentions
	 */
	sendChat = async (req) => {
		const talkChannel = await this.#getUserChannel(req.mxid, req.channel_props)

		return await talkChannel.sendChat({
			text: req.text,
			type: !!req.reply_to ? KnownChatType.REPLY : KnownChatType.TEXT,
			attachment: !req.mentions ? req.reply_to : {...req.reply_to, mentions: req.mentions},
		})
	}

	/**
	 * @param {Object} req
	 * @param {string} req.mxid
	 * @param {ChannelProps} req.channel_props
	 * @param {int} req.type
	 * @param {number[]} req.data
	 * @param {string} req.name
	 * @param {?int} req.width
	 * @param {?int} req.height
	 * @param {?string} req.ext
	 */
	sendMedia = async (req) => {
		const talkChannel = await this.#getUserChannel(req.mxid, req.channel_props)

		return await talkChannel.sendMedia(req.type, {
			data: Uint8Array.from(req.data),
			name: req.name,
			width: req.width,
			height: req.height,
			ext: req.ext,
		})
	}

	#makeCommandResult(result) {
		return {
			success: true,
			status: 0,
			result: result
		}
	}

	handleUnknownCommand = () => {
		throw new Error("Unknown command")
	}

	/**
	 * @param {Object} req
	 * @param {string} req.peer_id
	 */
	handleRegister = async (req) => {
		this.peerID = req.peer_id
		this.log(`Registered socket ${this.connID} -> ${this.peerID}`)
		if (this.manager.clients.has(this.peerID)) {
			const oldClient = this.manager.clients.get(this.peerID)
			this.log(`Terminating previous socket ${oldClient.connID} for ${this.peerID}`)
			await oldClient.stop("Socket replaced by new connection")
		}
		this.manager.clients.set(this.peerID, this)
		return { client_exists: this.authClient !== null }
	}

	async handleLine(line) {
		if (this.stopped) {
			this.log("Ignoring line, client is stopped")
			return
		}
		let req
		try {
			req = JSON.parse(line, this.#readReviver)
		} catch (err) {
			this.log("Non-JSON request:", line)
			return
		}
		if (!req.command || !req.id) {
			this.log("Invalid request:", line)
			return
		}
		if (req.id <= this.maxCommandID) {
			this.log("Ignoring old request", req.id)
			return
		}
		this.log("Received request", req.id, "with command", req.command)
		this.maxCommandID = req.id
		let handler
		if (!this.peerID) {
			if (req.command !== "register") {
				this.log("First request wasn't a register request, terminating")
				await this.stop("Invalid first request")
				return
			} else if (!req.peer_id) {
				this.log("Register request didn't contain ID, terminating")
				await this.stop("Invalid register request")
				return
			}
			handler = this.handleRegister
		} else {
			handler = {
				// TODO Wrapper for per-user commands
				generate_uuid: util.randomAndroidSubDeviceUUID,
				register_device: this.registerDevice,
				login: this.handleLogin,
				renew: this.handleRenew,
				start: this.userStart,
				stop: this.userStop,
				connect: this.handleConnect,
				disconnect: this.handleDisconnect,
				get_own_profile: this.getOwnProfile,
				get_profile: this.getProfile,
				get_portal_channel_info: this.getPortalChannelInfo,
				get_participants: this.getParticipants,
				get_chats: this.getChats,
				list_friends: this.listFriends,
				get_friend_dm_id: req => this.getFriendProperty(req, "directChatId"),
				get_memo_ids: this.getMemoIds,
				send_chat: this.sendChat,
				send_media: this.sendMedia,
			}[req.command] || this.handleUnknownCommand
		}
		const resp = { id: req.id }
		delete req.id
		delete req.command
		resp.command = "response"
		try {
			resp.response = await handler(req)
		} catch (err) {
			if (err.isAxiosError) {
				resp.response = {
					success: false,
					status: err.response.status,
				}
			} else {
				resp.command = "error"
				resp.error = err.toString()
				this.log(`Error handling request ${resp.id} ${err.stack}`)
				// TODO Check if session is broken. If it is, close the PeerClient
			}
		}
		await this.write(resp)
	}

	#writeReplacer = function(key, value) {
		if (value instanceof Long) {
			return value.toString()
		} else {
			return value
		}
	}

	#readReviver = function(key, value) {
		if (value instanceof Object) {
			// TODO Use a type map if there will be many possible types
			if (value.__type__ == "Long") {
				return Long.fromString(value.str)
			}
		}
		return value
	}
}


/**
 * @param {TalkChannelList} channelList
 * @param {ChannelType} channelType
 */
function getChannelListForType(channelList, channelType) {
	return isChannelTypeOpen(channelType) ? channelList.open : channelList.normal
}

/**
 * @param {ChannelType} channelType
 */
function isChannelTypeOpen(channelType) {
	switch (channelType) {
	case "OM":
	case "OD":
		return true
	default:
		return false
	}
}