# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# frozen_string_literal: true

module PotatoMesh
  module App
    module Routes
      module Root
        module Helpers
          # Map of XML predefined entities used by {#xml_escape}.
          XML_ESCAPE_REPLACEMENTS = {
            "&" => "&amp;",
            "<" => "&lt;",
            ">" => "&gt;",
            '"' => "&quot;",
            "'" => "&apos;",
          }.freeze

          # Pattern matching any XML metacharacter that requires escaping.
          XML_ESCAPE_PATTERN = Regexp.union(XML_ESCAPE_REPLACEMENTS.keys).freeze

          # Return the fixed dark theme identifier. Light mode is no longer
          # supported; theme selection and cookie persistence have been removed.
          #
          # @return [String] always 'dark'.
          def resolve_initial_theme
            "dark"
          end

          # Render a dashboard-oriented ERB template within the shared layout.
          #
          # @param template [Symbol] identifier for the ERB template.
          # @param view_mode [Symbol, String] logical view identifier for CSS hooks.
          # @param view_meta [Symbol, String, nil] meta-tag selector. Defaults to
          #   +view_mode+ so most callers can omit it; pass an explicit value
          #   when the layout view differs from the meta archetype (e.g. the
          #   dynamic +/pages/:slug+ routes whose view_mode is per-slug).
          # @param meta_overrides [Hash, nil] explicit replacements for
          #   individual meta values (title, description, image, noindex).
          # @param extra_locals [Hash] additional locals merged into the rendering context.
          # @return [String] rendered ERB output.
          def render_root_view(template, view_mode: :dashboard, view_meta: nil, meta_overrides: nil, extra_locals: {})
            view_mode_sym = view_mode.respond_to?(:to_sym) ? view_mode.to_sym : view_mode
            view_meta_sym = view_meta.nil? ? view_mode_sym : (view_meta.respond_to?(:to_sym) ? view_meta.to_sym : view_meta)
            meta = meta_configuration(view: view_meta_sym, overrides: meta_overrides)
            config = frontend_app_config
            theme = resolve_initial_theme

            base_locals = {
              site_name: meta[:name],
              meta_title: meta[:title],
              meta_name: meta[:name],
              meta_description: meta[:description],
              meta_image_url: meta[:image],
              meta_noindex: meta[:noindex] == true,
              channel: sanitized_channel,
              frequency: sanitized_frequency,
              map_center_lat: PotatoMesh::Config.map_center_lat,
              map_center_lon: PotatoMesh::Config.map_center_lon,
              max_distance_km: PotatoMesh::Config.max_distance_km,
              contact_link: sanitized_contact_link,
              contact_link_url: sanitized_contact_link_url,
              version: display_version(app_constant(:APP_VERSION)),
              private_mode: private_mode?,
              federation_enabled: federation_enabled?,
              refresh_interval_seconds: PotatoMesh::Config.refresh_interval_seconds,
              app_config_json: JSON.generate(config),
              initial_theme: theme,
              current_view_mode: view_mode_sym,
              map_zoom: PotatoMesh::Config.map_zoom,
              static_pages: PotatoMesh::App::Pages.static_pages,
            }
            sanitized_locals = extra_locals.is_a?(Hash) ? extra_locals : {}
            merged_locals = base_locals.merge(sanitized_locals)

            erb template, layout: :"layouts/app", locals: merged_locals
          end

          # Remove keys with +nil+ values from the provided hash, returning a
          # shallow copy. Hash#compact is only available in newer Ruby
          # versions; this helper keeps behaviour consistent across supported
          # releases.
          #
          # @param value [Hash, nil] collection subject to filtering.
          # @return [Hash] hash excluding +nil+ values.
          def reject_nil_values(value)
            return {} unless value.is_a?(Hash)

            value.each_with_object({}) do |(key, entry), memo|
              memo[key] = entry unless entry.nil?
            end
          end

          # Assemble the payload embedded into the node detail view. The
          # payload provides a canonical identifier alongside any cached node,
          # telemetry, or position rows that may already exist in the
          # database. When no persisted data is available the method returns
          # +nil+ so the caller can surface a 404 error.
          #
          # @param node_ref [Object] raw node identifier from the request.
          # @return [Hash, nil] structured node reference payload or nil when
          #   the node cannot be located.
          def build_node_detail_reference(node_ref)
            tokens = canonical_node_parts(node_ref)
            search_ref = tokens ? tokens.first : node_ref

            node_row = query_nodes(1, node_ref: search_ref).first
            telemetry_row = query_telemetry(1, node_ref: search_ref).first
            position_row = query_positions(1, node_ref: search_ref).first

            candidates = [node_row, telemetry_row, position_row].compact
            return nil if candidates.empty?

            canonical_id = string_or_nil(node_row&.fetch("node_id", nil))
            canonical_id ||= string_or_nil(telemetry_row&.fetch("node_id", nil))
            canonical_id ||= string_or_nil(position_row&.fetch("node_id", nil))
            canonical_id ||= string_or_nil(tokens&.fetch(0, nil))
            if canonical_id
              canonical_id = canonical_id.start_with?("!") ? canonical_id : "!#{canonical_id}"
            end
            return nil unless canonical_id

            numeric_id = coerce_integer(node_row&.fetch("num", nil))
            numeric_id ||= coerce_integer(telemetry_row&.fetch("node_num", nil))
            numeric_id ||= coerce_integer(position_row&.fetch("node_num", nil))
            numeric_id ||= tokens&.fetch(1, nil)

            short_id = string_or_nil(node_row&.fetch("short_name", nil))
            short_id ||= string_or_nil(telemetry_row&.fetch("short_name", nil))
            short_id ||= string_or_nil(position_row&.fetch("short_name", nil))
            short_id ||= tokens&.fetch(2, nil)

            fallback_row = node_row || telemetry_row || position_row
            fallback = fallback_row ? compact_api_row(fallback_row) : nil
            telemetry = telemetry_row ? compact_api_row(telemetry_row) : nil
            position = position_row ? compact_api_row(position_row) : nil

            {
              "nodeId" => canonical_id,
              "nodeNum" => numeric_id,
              "shortId" => short_id,
              "fallback" => fallback,
              "telemetry" => telemetry,
              "position" => position,
            }
          end

          # Resolve the canonical absolute base URL for the running request.
          # Prefers an operator-supplied override (+INSTANCE_DOMAIN+) so
          # generated absolute URLs match the public-facing hostname, falling
          # back to the request's own +base_url+ for development.
          #
          # @return [String] base URL (scheme + authority) without trailing slash.
          def public_base_url
            domain = string_or_nil(app_constant(:INSTANCE_DOMAIN))
            return request.base_url unless domain

            scheme = request.scheme || "https"
            "#{scheme}://#{domain}"
          end

          # Construct the OG image URL referenced from the layout. Operators
          # may provide an explicit override via +OG_IMAGE_URL+; otherwise
          # the runtime-generated +/og-image.png+ URL is returned.
          #
          # The override is rejected unless it carries an +http(s)+ scheme.
          # +data:+ and +javascript:+ URIs do not render in any social
          # platform's link preview and would only serve as a content
          # security foot-gun, so they are silently dropped in favour of
          # the runtime URL.
          #
          # @return [String] absolute URL to the social preview image.
          def og_image_url
            override = string_or_nil(PotatoMesh::Config.og_image_url)
            return override if override && override.match?(%r{\Ahttps?://}i)

            "#{public_base_url}/og-image.png"
          end

          # Build the title segment for the node detail view from the data
          # already resolved by {build_node_detail_reference}.
          #
          # @param short_name [String, nil] sanitized short identifier.
          # @param long_name [String, nil] sanitized long name.
          # @param canonical_id [String, nil] canonical "!hex" identifier.
          # @return [String] human-friendly node label.
          def node_detail_title_label(short_name:, long_name:, canonical_id:)
            short = string_or_nil(short_name)
            long = string_or_nil(long_name)
            return "#{short} (#{long})" if short && long
            return short if short
            return long if long
            return "Node #{canonical_id}" if canonical_id

            "Node detail"
          end

          # Compose meta overrides for the +/nodes/:id+ view.
          #
          # @param short_name [String, nil] sanitized short identifier.
          # @param long_name [String, nil] sanitized long name.
          # @param canonical_id [String, nil] canonical "!hex" identifier.
          # @return [Hash] override hash for {meta_configuration}.
          def node_detail_meta_overrides(short_name:, long_name:, canonical_id:)
            site = sanitized_site_name
            label = node_detail_title_label(
              short_name: short_name,
              long_name: long_name,
              canonical_id: canonical_id,
            )
            description_subject = string_or_nil(short_name) || string_or_nil(long_name) ||
                                  canonical_id || "this node"

            {
              title: site && !site.empty? ? "#{label} · #{site}" : label,
              description: "Telemetry, position history, and live status for node #{description_subject} on #{site}.",
            }
          end

          # Compose meta overrides for the +/pages/:slug+ view from a static
          # page entry plus any frontmatter the operator defined.
          #
          # Only keys that carry meaningful values are included so the
          # downstream {meta_configuration} call is not asked to filter
          # +nil+ values out of an otherwise sparse hash.
          #
          # @param page [PotatoMesh::App::Pages::PageEntry] resolved page entry.
          # @return [Hash] override hash for {meta_configuration}.
          def static_page_meta_overrides(page)
            site = sanitized_site_name
            title_segment = string_or_nil(page&.title) || ""
            composed_title = if !title_segment.empty? && site && !site.empty?
                "#{title_segment} · #{site}"
              elsif !title_segment.empty?
                title_segment
              else
                site
              end

            overrides = { title: composed_title }
            description = string_or_nil(page&.description)
            overrides[:description] = description if description
            image = string_or_nil(page&.image)
            overrides[:image] = image if image
            overrides[:noindex] = true if page&.noindex == true
            overrides
          end

          # Render the +robots.txt+ body honoring private-mode preferences.
          # Private deployments emit a blanket disallow; public deployments
          # whitelist the dashboard while disallowing instrumentation paths.
          #
          # @param sitemap_url [String] absolute URL of the public sitemap.
          # @return [String] +robots.txt+ payload, terminated with a newline.
          def build_robots_txt(sitemap_url)
            if private_mode?
              "User-agent: *\nDisallow: /\n"
            else
              <<~TXT
                User-agent: *
                Disallow: /metrics
                Disallow: /api/

                Sitemap: #{sitemap_url}
              TXT
            end
          end

          # Build the URL list emitted by +/sitemap.xml+ for a public
          # deployment. Each entry is a hash with +:loc+ and an optional
          # +:lastmod+ / +:changefreq+ pair.
          #
          # +lastmod+ is intentionally omitted for top-level dashboard
          # routes. The data behind those views changes continuously, so
          # advertising +Time.now+ on every crawl trains crawlers to ignore
          # the field (Google explicitly discourages noisy +lastmod+
          # values). Static pages keep a meaningful +lastmod+ derived from
          # +File.mtime+.
          #
          # The handler at +/sitemap.xml+ already 404s in private mode, so
          # this method does not need to filter chat — it is unreachable
          # otherwise.
          #
          # @param base_url [String] absolute base URL prefix.
          # @return [Array<Hash>] ordered list of sitemap entries.
          def build_sitemap_entries(base_url)
            entries = []
            entries << { loc: "#{base_url}/", changefreq: "daily" }
            entries << { loc: "#{base_url}/map", changefreq: "daily" }
            entries << { loc: "#{base_url}/chat", changefreq: "daily" }
            entries << { loc: "#{base_url}/charts", changefreq: "daily" }
            entries << { loc: "#{base_url}/nodes", changefreq: "daily" }
            entries << { loc: "#{base_url}/federation", changefreq: "weekly" } if federation_enabled?

            PotatoMesh::App::Pages.static_pages.each do |page|
              next if page.noindex
              next unless page.path

              lastmod = begin
                  File.mtime(page.path).utc.strftime("%Y-%m-%d")
                rescue SystemCallError
                  nil
                end
              entry = { loc: "#{base_url}/pages/#{page.slug}", changefreq: "weekly" }
              entry[:lastmod] = lastmod if lastmod
              entries << entry
            end

            entries
          end

          # Escape a string for inclusion as XML character data.
          #
          # Replaces the five XML predefined entities in a single pass.
          # Used by the sitemap renderer instead of
          # {Rack::Utils.escape_html} so apostrophes become the canonical
          # +&apos;+ entity rather than an HTML-style numeric character
          # reference.
          #
          # @param value [Object] input fragment; coerced to a string.
          # @return [String] XML-safe representation.
          def xml_escape(value)
            value.to_s.gsub(XML_ESCAPE_PATTERN, XML_ESCAPE_REPLACEMENTS)
          end

          # Render a sitemap entry list as +urlset+ XML.
          #
          # @param entries [Array<Hash>] entries produced by
          #   {build_sitemap_entries}.
          # @return [String] XML document body.
          def render_sitemap_xml(entries)
            lines = [%(<?xml version="1.0" encoding="UTF-8"?>),
                     %(<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">)]
            entries.each do |entry|
              lines << "  <url>"
              lines << "    <loc>#{xml_escape(entry[:loc])}</loc>"
              lines << "    <lastmod>#{xml_escape(entry[:lastmod])}</lastmod>" if entry[:lastmod]
              lines << "    <changefreq>#{xml_escape(entry[:changefreq])}</changefreq>" if entry[:changefreq]
              lines << "  </url>"
            end
            lines << "</urlset>"
            lines.join("\n") + "\n"
          end
        end

        def self.registered(app)
          app.helpers Helpers
          app.get "/favicon.ico" do
            cache_control :public, max_age: PotatoMesh::Config.week_seconds
            ico_path = File.join(settings.public_folder, "favicon.ico")
            if File.file?(ico_path)
              send_file ico_path, type: "image/x-icon"
            else
              send_file File.join(settings.public_folder, "potatomesh-logo.svg"), type: "image/svg+xml"
            end
          end

          app.get "/potatomesh-logo.svg" do
            path = File.expand_path("potatomesh-logo.svg", settings.public_folder)
            settings.logger&.info("logo_path=#{path} exist=#{File.exist?(path)} file=#{File.file?(path)}")
            halt 404, "Not Found" unless File.exist?(path) && File.readable?(path)

            content_type "image/svg+xml"
            last_modified File.mtime(path)
            cache_control :public, max_age: 3600
            send_file path
          end

          app.get "/robots.txt" do
            content_type "text/plain"
            cache_control :public, max_age: 3600
            build_robots_txt("#{public_base_url}/sitemap.xml")
          end

          app.get "/sitemap.xml" do
            halt 404, "Not Found" if private_mode?

            content_type "application/xml"
            cache_control :public, max_age: 3600
            render_sitemap_xml(build_sitemap_entries(public_base_url))
          end

          app.get "/og-image.png" do
            override = string_or_nil(PotatoMesh::Config.og_image_url)
            redirect override, 302 if override && override.match?(%r{\Ahttps?://}i)

            begin
              payload = PotatoMesh::OgImage.serve(base_url: public_base_url)
            rescue PotatoMesh::OgImage::CaptureError
              halt 503, "Preview unavailable"
            end

            content_type "image/png"
            cache_control :public, max_age: payload[:max_age]
            last_modified payload[:last_modified] if payload[:last_modified]
            payload[:bytes]
          end

          app.get "/" do
            render_root_view(:index, view_mode: :dashboard)
          end

          app.get %r{/map/?} do
            render_root_view(:map, view_mode: :map)
          end

          app.get %r{/chat/?} do
            render_root_view(:chat, view_mode: :chat)
          end

          app.get %r{/charts/?} do
            render_root_view(:charts, view_mode: :charts)
          end

          app.get %r{/federation/?} do
            halt 404 unless federation_enabled?
            render_root_view(:federation, view_mode: :federation)
          end

          app.get "/pages/:slug" do
            slug = params.fetch("slug", "")
            halt 400, "Bad Request" unless slug.match?(PotatoMesh::App::Pages::SLUG_PATTERN)

            page = PotatoMesh::App::Pages.find_page_by_slug(slug)
            halt 404, "Not Found" unless page

            page_html = PotatoMesh::App::Pages.render_page_content(page)
            halt 500, "Internal Server Error" unless page_html

            render_root_view(
              :page,
              view_mode: :"page_#{slug}",
              meta_overrides: static_page_meta_overrides(page),
              extra_locals: {
                page_title: page.title,
                page_content_html: page_html,
              },
            )
          end

          app.get "/nodes/:id" do
            node_ref = params.fetch("id", nil)
            reference_payload = build_node_detail_reference(node_ref)
            halt 404, "Not Found" unless reference_payload

            fallback = reference_payload["fallback"] || {}
            short_name = string_or_nil(fallback["short_name"]) || reference_payload["shortId"]
            long_name = string_or_nil(fallback["long_name"])
            role = string_or_nil(fallback["role"])
            canonical_id = string_or_nil(reference_payload["nodeId"])

            render_root_view(
              :node_detail,
              view_mode: :node_detail,
              meta_overrides: node_detail_meta_overrides(
                short_name: short_name,
                long_name: long_name,
                canonical_id: canonical_id,
              ),
              extra_locals: {
                node_reference_json: JSON.generate(reject_nil_values(reference_payload)),
                node_page_short_name: short_name,
                node_page_long_name: long_name,
                node_page_role: role,
                node_page_identifier: canonical_id,
              },
            )
          end

          app.get %r{/nodes/?} do
            render_root_view(:nodes, view_mode: :nodes)
          end

          app.get "/metrics" do
            content_type ::Prometheus::Client::Formats::Text::CONTENT_TYPE
            ::Prometheus::Client::Formats::Text.marshal(::Prometheus::Client.registry)
          end
        end
      end
    end
  end
end
