模組:线路时刻表

local p = {}

local getArgs = require('Module:Arguments').getArgs

---@param funcName string
---@return fun(frame: frame)
local function makeInvokeFunction(funcName)
	return function(frame)
		local args = getArgs(frame, { parentOnly = true })
		return p[funcName](args, frame)
	end
end

---@type fun(_: {[1]: string}): string
local delink = require('Module:delink').delink

---@alias spanTerminus { [1]: string[], [2]: string[] }
---@alias spanDay { [1]: spanTerminus, [2]: spanTerminus }
---@alias span { line: string, type: string?, stations: string[], data: { [string]: spanDay } }
---@alias spanLTerminus { [1]: string[], [2]: string[][] }
---@alias spanLDay spanLTerminus[]
---@alias spanL { line: string, type: string?, stations: string[], data: { [string]: spanLDay }, override_termini: string[], sub_termini: { [1]: string[], [2]: string[], scope: 'last' } }
---@alias periodDay { [1]: string[], [2]: string[] }
---@alias pointDay { [1]: { [1]: integer }, [2]: { [1]: integer } }
---@alias point { line: string|string[], type: string|string[]?, periods: periodDay[], data: { [string]: pointDay[] } }
---@alias timeEntry { days: string[], ref: string, timespans: (span|spanL)[], timepoints: point[] }
---@type timeEntry
local data

---@alias adjType { title: string, color: string, circular: boolean, ['left terminus']: string, ['right terminus']: string }
---@alias adjLine { types: { [string]: adjType }, title: string, color: string, circular: boolean, ['left terminus']: string, ['right terminus']: string }
---@alias adjEntry { lines: { [string]: adjLine }, aliases: { [string]: string } }
---@type adjEntry
local adj_data

---加载时刻数据
---@param system string
---@return timeEntry?
local function getData(system)
	local success, result = pcall(mw.loadData, 'Module:线路时刻表/' .. system)
	if success then
		return result
	else
		return nil
	end
end

---加载线网数据
---@param system string
---@return adjEntry?
local function getAdjData(system)
	local success, result = pcall(mw.loadData, 'Module:Adjacent stations/' .. system)
	if success then
		return result
	else
		return nil
	end
end

---加载数据
---@param system string
local function initData(system)
	local d = getData(system)
	if d then
		data = d
	else
		error('不存在模块:线路时刻表/' .. system)
	end
	local a = getAdjData(system)
	if a then
		adj_data = a
	else
		error('不存在模块:Adjacent stations/' .. system)
	end
end

---获取线路数据
---@param line string
---@return adjLine
local function getLineData(line)
	if line then
		if adj_data['aliases'] then
			line = adj_data['aliases'][mw.ustring.lower(line)] or line
		end
		local default = adj_data['lines']['_default'] or {}
		local line_data = {}
		-- Shadow clone to avoid edit immutable objects
		for k, v in pairs(adj_data['lines'][line] or {}) do
			line_data[k] = v
		end
		for k, v in pairs(default) do
			if v then line_data[k] = line_data[k] or v end
		end
		line_data['title'] = line_data['title'] and mw.ustring.gsub(line_data['title'], '%%1', line)
		return line_data
	else
		error('Null argument')
	end
end

---获取支线数据
---@param line_data adjLine
---@param _type string
---@return adjType
local function getTypeData(line_data, _type)
	_type = adj_data.aliases and adj_data.aliases[mw.ustring.lower(_type)] or _type
	return line_data.types and line_data.types[_type] or {}
end

---获取线路标题
---@param line string
---@param _type string
---@return string
local function getTitle(line, _type)
	local line_data = getLineData(line)
	if _type then
		local type_data = getTypeData(line_data, _type)
		return line_data.title .. (type_data.title or _type)
	else
		return line_data.title
	end
end

---获取支线标题
---@param line string
---@param _type string
---@return string
local function getSubtitle(line, _type)
	local line_data = getLineData(line)
	if _type then
		local type_data = getTypeData(line_data, _type)
		return type_data.title or _type
	else
		error('Null argument')
	end
end

---获取线路颜色
---@param line string
---@param _type string
---@return string
local function getColor(line, _type)
	local line_data = getLineData(line)
	if _type then
		local type_data = getTypeData(line_data, _type)
		return '#' .. (type_data.color or line_data.color)
	else
		return '#' .. line_data.color
	end
end

---@alias terminus string|{ [integer]: string, via: string? }
---@alias termini { [1]: terminus, [2]: terminus }

---获取线路终点
---@param line string
---@param _type string
---@return termini
local function getTermini(line, _type)
	local line_data = getLineData(line)
	if _type then
		local type_data = getTypeData(line_data, _type)
		return {
			type_data['right terminus'] or line_data['right terminus'],
			type_data['left terminus'] or line_data['left terminus']
		}
	else
		return {
			line_data['right terminus'],
			line_data['left terminus']
		}
	end
end

---获取车站链接
---@param frame frame
---@param name string
---@param system string
---@param line string
---@param _type string
---@return string
local function getStation(frame, name, system, line, _type)
	return require('Module:Adjacent stations')._station(
		{ system, name, line, _type },
		frame)
end

---构造终点站表头
---@param frame frame
---@param terminus terminus
---@param system string
---@param line string
---@param _type string
---@return string
local function makeTerminus(frame, terminus, system, line, _type)
	local circular
	if _type and _type ~= '' then
		circular = adj_data.lines[line].types[_type].circular
		if circular == nil then
			circular = adj_data.lines[line].circular
		end
	else
		circular = adj_data.lines[line].circular
	end
	if type(terminus) == 'table' then
		if circular then
			return table.concat(terminus, '或')
		else
			local names = {}
			for _, name in ipairs(terminus) do
				table.insert(names, getStation(frame, name, system, line, _type))
			end
			return '往' .. table.concat(names, '或')
		end
	else
		if circular then
			return terminus
		else
			return '往' .. getStation(frame, terminus, system, line, _type)
		end
	end
end

---构造次级终点站表头
---@param frame frame
---@param subTerminus string
---@param system string
---@param line string
---@param _type string
---@return string
local function makeSubTerminus(frame, subTerminus, system, line, _type)
	return '至' .. getStation(frame, subTerminus, system, line, _type)
end

---检测车站是否运营
---@param t span|spanDay|spanTerminus|spanL|spanLDay|spanLTerminus|string
---@return boolean
local function checkOperated(t)
	if type(t) == 'table' then
		return checkOperated(t[1])
	else
		return t ~= nil
	end
end

---平面化数组
---@param input table
---@param flattened string[]?
---@return string[]
local function flattenMatrix(input, flattened)
	flattened = flattened or {}
	for i, element in ipairs(input) do
		if type(element) == 'table' then
			flattenMatrix(element, flattened)
		else
			table.insert(flattened, element)
		end
	end
	return flattened
end

---克隆单层数组
---@generic T
---@param input T[]?
---@return T[]?
local function cloneArray(input)
	if not input then return nil end

	local result = {}
	for i, item in ipairs(input) do
		result[i] = item
	end
	return result
end

---构建车站信息框时刻
---@param args string[]
---@param frame frame
---@return html
function p._as_station(args, frame)
	local system = args[1]
	local station = args[2]
	local all_days = args[3] -- 使用完整时刻表

	initData(system)
	local days
	if all_days then
		days = cloneArray(data.days)
	else
		days = { data.days[1] }
	end

	local tbl = mw.html.create('table')
		:addClass('wikitable')
		:addClass('station-infobox-timetable')

	--表头
	if #days > 1 then
		local tr_day = mw.html.create('tr')
		tr_day
			:tag('th')
			:attr('colspan', 3):attr('rowspan', 2)
			:wikitext(frame:preprocess(data.ref)):done()
		for _, day in ipairs(days) do
			tr_day
				:tag('th')
				:attr('colspan', 2):attr('scope', 'colgroup')
				:wikitext(day):done()
		end
		tr_day:allDone()
		tbl:node(tr_day)

		local tr_type = mw.html.create('tr')
		for _ = 1, #days do
			tr_type
				:tag('th')
				:attr('scope', 'col')
				:wikitext('首班'):done()
				:tag('th')
				:attr('scope', 'col')
				:wikitext('末班'):done()
		end
		tr_type:allDone()
		tbl:node(tr_type)
	elseif #days == 1 then
		tbl:tag('tr')
			:tag('th')
			:attr('colspan', 3)
			:wikitext(days[1], frame:preprocess(data.ref)):done()
			:tag('th')
			:attr('scope', 'col')
			:wikitext('首班'):done()
			:tag('th')
			:attr('scope', 'col')
			:wikitext('末班'):done()
			:done()
	else
		error('缺少运行图元信息')
	end

	for _, route_data in ipairs(data.timespans) do
		local _data = route_data.data[station]
		if _data and checkOperated(_data) then
			-- 允许系统内部分线路退化为单day
			if #days > 1 and #_data == 1 then
				for _ = 1, #days - #_data do
					table.insert(_data, _data[1])
				end
			end

			local color = getColor(route_data.line, route_data.type)
			local termini = route_data.override_termini or getTermini(route_data.line, route_data.type)

			-- 有子终点站
			if route_data.sub_termini then
				if route_data.sub_termini.scope == 'last' then
					---@cast _data spanLDay

					local sum_row = 0
					local t_map = {}
					local s_map = {}

					-- 预处理:有效行
					for t, terminus in ipairs(termini) do
						table.insert(s_map, {})

						for s, _ in ipairs(route_data.sub_termini[t]) do
							for d = 1, #days do
								if _data[d][2][t][s] and _data[d][2][t][s] ~= '' then
									table.insert(s_map[#s_map], s)
									break
								end
							end
						end

						-- 处理终点站(末班为空)
						if #s_map[#s_map] == 0 then
							if terminus == station then
								table.insert(s_map[#s_map], 0) -- 标记终点站
							end
						end

						if #s_map[#s_map] == 0 then
							table.remove(s_map)
						else
							table.insert(t_map, t)
						end

						sum_row = sum_row + #s_map[#s_map]
					end

					-- 线路色条
					local tr = mw.html.create('tr')
					tr:tag('td')
						:addClass('bar')
						:attr('rowspan', sum_row):attr('role', 'rowheader')
						:attr('aria-label', delink { getTitle(route_data.line, route_data.type) })
						:css('background-color', color):done()

					for it, t in ipairs(t_map) do
						local terminus = termini[t]

						if it > 1 then
							tr = mw.html.create('tr')
						end

						-- 终点站
						if s_map[it][1] == 0 then
							tr:tag('td')
								:attr('colspan', 2):addClass('table-rh'):attr('role', 'rowheader')
								:wikitext(makeTerminus(frame, terminus, system, route_data.line, route_data.type)):done()
								:tag('td')
								:addClass('note'):attr('colspan', 2 * #days)
								:wikitext('终点站'):done()
								:allDone()

							tbl:node(tr)

							-- 仅全程
						elseif s_map[it][1] == 1 and #s_map[it] == 1 and terminus == route_data.sub_termini[t][1] then
							tr:tag('td')
								:attr('colspan', 2):addClass('table-rh'):attr('role', 'rowheader')
								:wikitext(makeTerminus(frame, terminus, system, route_data.line, route_data.type)):done()

							for d = 1, #days do
								tr:tag('td')
									:attr('rowspan', #s_map[it])
									:wikitext(_data[d][1][t]):done()
									:tag('td')
									:wikitext(_data[d][2][t][1]):done()
							end

							tbl:node(tr:allDone())
						elseif s_map[it][1] > 0 then
							tr:tag('td')
								:attr('rowspan', #s_map[it]):addClass('table-rh-parent'):attr('role', 'rowheader')
								:wikitext(makeTerminus(frame, terminus, system, route_data.line, route_data.type)):done()

							for is, s in ipairs(s_map[it]) do
								local sub_terminus = route_data.sub_termini[t][s]

								if is > 1 then
									tr = mw.html.create('tr')
								end

								if terminus == sub_terminus then
									tr:tag('td')
										:addClass('table-rh-child'):attr('role', 'rowheader')
										:wikitext('全程'):done()
								else
									tr:tag('td')
										:addClass('table-rh-child'):attr('role', 'rowheader')
										:wikitext(makeSubTerminus(frame, sub_terminus, system, route_data.line,
											route_data.type)):done()
								end

								for d = 1, #days do
									if is == 1 then
										tr:tag('td')
											:attr('rowspan', #s_map[it])
											:wikitext(_data[d][1][t]):done()
									end
									tr:tag('td')
										:wikitext(_data[d][2][t][s]):done()
								end

								tbl:node(tr:allDone())
							end
						else
							error('无效的索引值')
						end
					end
				else
					error('不支持的子终点站作用域') -- 目前仅实现末班车的子终点站
				end

				-- 无子终点站
			else
				---@cast _data spanDay

				local t_map = {}

				-- 预处理:有效行
				for t, terminus in ipairs(termini) do
					if terminus == station then
						table.insert(t_map, t)
					else
						for d = 1, #days do
							if (_data[d][1][t] and _data[d][1][t] ~= '') or (_data[d][2][t] and _data[d][2][t] ~= '') then
								table.insert(t_map, t)
								break
							end
						end
					end
				end

				local tr = mw.html.create('tr')
				tr:tag('td')
					:addClass('bar')
					:attr('rowspan', #t_map):attr('role', 'rowheader')
					:attr('aria-label', delink { getTitle(route_data.line, route_data.type) })
					:css('background-color', color):done()

				for it, t in ipairs(t_map) do
					local terminus = termini[t]

					if it > 1 then
						tr = mw.html.create('tr')
					end

					tr:tag('td')
						:attr('colspan', 2):addClass('table-rh'):attr('role', 'rowheader')
						:wikitext(makeTerminus(frame, terminus, system, route_data.line, route_data.type)):done()

					if terminus == station then
						tr:tag('td')
							:addClass('note'):attr('colspan', 2 * #days)
							:wikitext('终点站'):done()
					else
						for d = 1, #days do
							tr:tag('td')
								:wikitext(_data[d][1][t]):done()
							tr:tag('td')
								:wikitext(_data[d][2][t]):done()
						end
					end

					tr:allDone()
					tbl:node(tr)
				end
			end
		end
	end

	return tbl:allDone()
end

---构建线路时刻
---@param args string[]
---@param frame frame
---@return html?
function p._as_line(args, frame)
	local system = args[1]
	local line = args[2]
	local _type = args[3]

	initData(system)
	for _, route_data in ipairs(data.timespans) do
		if line == route_data.line and _type == route_data.type then
			local tbl = mw.html.create('table')
				:addClass('wikitable')
				:addClass('line-timetable')

			local color = getColor(route_data.line, route_data.type)
			local termini = cloneArray(route_data.override_termini) or getTermini(route_data.line, route_data.type)

			local num_cols
			local num_ref_rows = 2

			local days
			if #cloneArray(route_data.data[route_data.stations[1]]) == 1 then -- 允许线路退化为无 day
				days = { data.days[1] }
			else
				days = cloneArray(data.days)
			end

			if #days > 1 then num_ref_rows = num_ref_rows + 1 end
			if route_data.sub_termini then num_ref_rows = num_ref_rows + 1 end
			local ref = mw.html.create('th')
				:addClass('ref'):attr('rowspan', num_ref_rows)
				:wikitext('车站<br/>', frame:preprocess(data.ref))
				:allDone()

			local bar = mw.html.create('tr')
			---@type html[]
			local hr = {}
			if route_data.sub_termini then
				if route_data.sub_termini.scope == 'last' then
					local num_f_cols = #termini
					local num_l_cols = #flattenMatrix(route_data.sub_termini)
					num_cols = #days * (num_f_cols + num_l_cols)
					bar:tag('td')
						:addClass('bar'):attr('colspan', num_cols + 1):css('background-color', color)
						:allDone()

					local hT1 = mw.html.create()
					local hT2 = mw.html.create()
					local hS = mw.html.create()
					for t, terminus in ipairs(termini) do
						local sub_termini = cloneArray(route_data.sub_termini[t])

						local th_text = makeTerminus(frame, terminus, system, line, _type)
						hT1:tag('th')
							:addClass('major'):attr('rowspan', 2)
							:wikitext(th_text)
							:done()
						hT2:tag('th')
							:addClass('major'):attr('colspan', #sub_termini)
							:wikitext(th_text)
							:done()
						for s, sub_terminus in ipairs(sub_termini) do
							if terminus == sub_terminus then
								hS:tag('th'):addClass('minor'):wikitext('全程'):done()
							else
								hS:tag('th')
									:addClass('minor')
									:wikitext(makeSubTerminus(frame, sub_terminus, system, line, _type))
									:done()
							end
						end
					end

					local hFL = mw.html.create()
						:tag('th'):attr('colspan', num_f_cols):wikitext('首班'):done()
						:tag('th'):attr('colspan', num_l_cols):wikitext('末班')
						:allDone()

					hr[1] = mw.html.create('tr')
					hr[2] = mw.html.create('tr')
					hr[3] = mw.html.create('tr')
					hr[4] = mw.html.create('tr')
					if #days > 1 then
						hr[1]:node(ref)
					else
						hr[2]:node(ref)
					end
					for d, day in ipairs(days) do
						hr[1]:tag('th'):attr('colspan', num_f_cols + num_l_cols):wikitext(day):done()
						hr[2]:node(hFL)
						hr[3]:node(hT1):node(hT2)
						hr[4]:node(hS)
					end
					hr[1]:allDone()
					hr[2]:allDone()
					hr[3]:allDone()
					hr[4]:allDone()
				else
					error('不支持的子终点站作用域')
				end
			else
				num_cols = 2 * #days * #termini
				bar:tag('td')
					:addClass('bar'):attr('colspan', num_cols + 1):css('background-color', color)
					:allDone()

				local hT = mw.html.create('')
				for t, terminus in ipairs(termini) do
					hT:tag('th'):addClass('th'):wikitext(makeTerminus(frame, terminus, system, line, _type)):done()
				end
				hT:allDone()

				local hFL = mw.html.create()
					:tag('th'):attr('colspan', #termini):wikitext('首班'):done()
					:tag('th'):attr('colspan', #termini):wikitext('末班')
					:allDone()

				hr[1] = mw.html.create('tr')
				hr[2] = mw.html.create('tr')
				hr[3] = mw.html.create('tr')
				if #days > 1 then
					hr[1]:node(ref)
				else
					hr[2]:node(ref)
				end
				for _, day in ipairs(days) do
					hr[1]:tag('th'):attr('colspan', 2 * #termini):wikitext(day):done()
					hr[2]:node(hFL)
					hr[3]:node(hT):node(hT)
				end
				hr[1]:allDone()
				hr[2]:allDone()
				hr[3]:allDone()
			end

			if #days == 1 then table.remove(hr, 1) end
			tbl:node(bar)
			for _, row in ipairs(hr) do
				tbl:node(row)
			end
			tbl:node(bar)

			local tr
			for _, station in ipairs(route_data.stations) do
				local _data = flattenMatrix(route_data.data[station])
				tr = mw.html.create('tr'):tag('td'):addClass('table-rh'):wikitext(station):done()

				if checkOperated(_data) then
					for _, time in ipairs(_data) do
						tr:tag('td'):wikitext(time):done()
					end
				else
					tr:tag('td'):addClass('closed'):attr('colspan', num_cols):wikitext('未运营'):done()
				end

				tbl:node(tr:allDone())
			end

			tbl:node(bar)
			return tbl:allDone()
		end
	end
end

---@param str string
---@return integer
---@return integer
local function parseTime(str)
	return tonumber(string.sub(str, 1, 2)) or 0, tonumber(string.sub(str, 4, 5)) or 0
end

---@param h integer
---@param m integer
---@param other integer
---@return integer
---@return integer
local function addMinutes(h, m, other)
	h = h
	m = m + other
	if m >= 60 then
		local x = math.floor(m / 60)
		h = h + x
		m = m - 60 * x
	end
	return h, m
end

---构建车站车次时刻
---@param args string[]
---@param frame frame
---@return html
function p._as_station_ext(args, frame)
	local system = args[1]
	local station = args[2]

	initData(system)
	local days = cloneArray(data.days)

	local tbl = mw.html.create('table')
		:addClass('wikitable')
		:addClass('station-route-timetable')

	for _, route_data in ipairs(data.timepoints) do
		local _data = route_data.data[station]
		local route_periods = cloneArray(route_data.periods)

		if _data then
			if type(route_data.line) == 'table' then
				if type(route_data.type) == 'table' then
					for l, line in ipairs(route_data.line --[[ @as string[] ]]) do
						tbl:tag('tr')
							:tag('td')
							:addClass('bar'):addClass('composed'):attr('colspan', 2 * #days + 1)
							:css('background-color', getColor(line, route_data.type[l]))
							:allDone()
					end
				else
					for _, line in ipairs(route_data.line --[[ @as string[] ]]) do
						tbl:tag('tr')
							:tag('td')
							:addClass('bar'):addClass('composed'):attr('colspan', 2 * #days + 1)
							:css('background-color', getColor(line, route_data.type --[[ @as string ]]))
							:allDone()
					end
				end
			else
				tbl:tag('tr')
					:tag('td')
					:addClass('bar'):attr('colspan', 2 * #days + 1)
					:css('background-color',
						getColor(route_data.line --[[ @as string ]], route_data.type --[[ @as string ]]))
					:allDone()
			end

			local num_days = #route_periods
			local day_span = 1
			if num_days < #days then
				assert(num_days == 1, #route_periods)
				day_span = #days
			end

			---@type { [1]: { [integer]: string[] }, [2]: { [integer]: string[] } }[]
			local points = {}
			local h_min = 23
			local h_max = 0
			local update_h_bound = function(h)
				h_min = math.min(h, h_min)
				h_max = math.max(h, h_max)
			end
			for d = 1, num_days, 1 do
				local _points = {}

				_points[1] = {}
				for _, point in ipairs(route_periods[d][1]) do
					local h, m = parseTime(point)
					h, m = addMinutes(h, m, _data[d][1][1])
					h = h % 24
					if _points[1][h] then
						table.insert(_points[1][h], string.format('%02d', m))
					else
						_points[1][h] = { string.format('%02d', m) }
						update_h_bound(h)
					end
				end

				_points[2] = {}
				for _, point in ipairs(route_periods[d][2]) do
					local h, m = parseTime(point)
					h, m = addMinutes(h, m, _data[d][2][1])
					h = h % 24
					if _points[2][h] then
						table.insert(_points[2][h], string.format('%02d', m))
					else
						_points[2][h] = { string.format('%02d', m) }
						update_h_bound(h)
					end
				end

				table.insert(points, _points)
			end

			tbl:tag('tr'):tag('th')
				:addClass('ref'):wikitext(frame:preprocess(getSubtitle(
				route_data.line[1] or route_data.line,
				route_data.type[1] or route_data.type)))
				:attr('colspan', 2 * #days + 1)
				:done():done()

			local termini = getTermini(route_data.line[1] or route_data.line,
				route_data.type[1] or route_data.type)
			tbl:tag('tr')
				:tag('th')
				:wikitext(makeTerminus(frame, termini[1], system, route_data.line[1] or route_data.line,
					route_data.type[1] or route_data.type))
				:attr('colspan', #days)
				:done()
				:tag('th'):wikitext('时')
				:attr('rowspan', num_days > 1 and 2 or 1)
				:done()
				:tag('th')
				:wikitext(makeTerminus(frame, termini[2], system, route_data.line[1] or route_data.line,
					route_data.type[1] or route_data.type))
				:attr('colspan', #days)
				:done()
				:done()

			if num_days > 1 then
				local tr = mw.html.create('tr')
				for d = #days, 1, -1 do
					tr:tag('th'):wikitext(days[d]):done()
				end
				for d = 1, #days do
					tr:tag('th'):wikitext(days[d]):done()
				end
				tr:allDone()
				tbl:node(tr)
			end

			for h = h_min, h_max do
				local tr = mw.html.create('tr')
				for d = num_days, 1, -1 do
					tr:tag('td')
						:attr('style', 'direction: rtl')
						:wikitext(
							frame:expandTemplate {
								title = 'hlist',
								args = points[d][1][h]
							})
						:attr('colspan', day_span)
						:done()
				end
				tr:tag('td'):addClass('table-rh'):wikitext(string.format('%02d', h)):done()
				for d = 1, num_days do
					tr:tag('td')
						:wikitext(
							frame:expandTemplate {
								title = 'hlist',
								args = points[d][2][h]
							})
						:attr('colspan', day_span)
						:done()
				end
				tr:allDone()
				tbl:node(tr)
			end
		end
	end

	return tbl:allDone()
end

p.as_station = makeInvokeFunction('_as_station')
p.as_line = makeInvokeFunction('_as_line')
p.as_station_ext = makeInvokeFunction('_as_station_ext')

return p