import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;

/**
 * Reads "links.txt" and:
 *
 *  1) Saves every line into folder "id_files" as "<lineNumber>.txt"
 *     (the file content is the original line, unchanged). links.txt
 *     itself is never modified.
 *
 *  2) Re-reads the same lines, replaces every special/non-alphanumeric
 *     character with a space, and splits each line into pieces (words).
 *
 *  3) Builds an id index, keyed by category, using one of two methods
 *     depending on the chosen style (see "Styles" below):
 *       - "simple"/"full": categories are whole words (skipping any word
 *         with a digit, a stopword, or fewer than 3 letters).
 *       - "advanced": categories are substrings of each piece (see below).
 *     For every category, creates an HTML page named "<category>.html"
 *     inside "html_pages" listing, as clickable links, every id (line
 *     number) that produced that category.
 *
 *     If a category's page would exceed 100 KB, the ids overflow into
 *     additional numbered pages: "<category>_2.html", "<category>_3.html",
 *     and so on. Every multi-page category gets Previous/Next navigation
 *     buttons and a "Page X of Y" indicator.
 *
 *  4) Generates a landing page ("index.html") showing the first 5
 *     categories found, plus a search box with up to 10 live suggestions
 *     and a direct "Open" button that jumps straight to any typed
 *     category's page, whether or not it's one of those 5 or exists at all.
 *
 * Resulting layout (index.html sits one level ABOVE "html_pages", as a
 * sibling of it and of "id_files" - not inside "html_pages"):
 *
 *   ./links.txt
 *   ./index.html            <- landing page: first 5 categories + search
 *   ./id_files/1.txt, 2.txt, ...
 *   ./html_pages/common.js  <- single shared script, used by every page
 *   ./html_pages/common.css <- single shared stylesheet, used by every page
 *   ./html_pages/hot.html, com.html, ...
 *
 *  A single shared JavaScript file ("common.js") and a single shared
 *  stylesheet ("common.css") are generated once and imported by every
 *  HTML page (category pages + landing page), so none of the rendering
 *  logic or styling is duplicated.
 *
 * Styles:
 *   - "simple":   each id is shown as a plain label, e.g. "Id 3", and
 *                 clicking it opens id_files/<id>.txt in a new tab.
 *                 Categories are whole words.
 *   - "full":     each category page's inline data block contains ONLY the
 *                 bare ids (e.g. ids = [1,7,42];) - no label/href text at
 *                 all. At runtime, common.js fetch()es id_files/<id>.txt
 *                 for each id, and uses that file's content (the original
 *                 link) as both the clickable label and the href, so
 *                 clicking it opens the real link itself in a new tab -
 *                 not the .txt file. This requires the site to be served
 *                 over http(s) (e.g. `python -m http.server`), since
 *                 browsers block fetch() of local files opened directly
 *                 from disk (file:// CORS restrictions). If a fetch fails,
 *                 the link falls back to opening the local
 *                 id_files/<id>.txt file instead, so the page never ends
 *                 up dead. Categories are whole words.
 *   - "advanced": same runtime click behavior as "full" (fetch + open the
 *                 real link), but categories are every substring, length
 *                 3 to 26, of each piece found in a line - skipping any
 *                 substring that itself contains a digit (a piece can
 *                 never contain a special character, since those already
 *                 act as separators). E.g. "banana" (6 letters) yields:
 *                 ban, ana, nan, bana, anan, nana, banan, anana, banana.
 *                 See buildSubstringIndex(). Because one piece can yield
 *                 many substrings, "advanced" can generate far more
 *                 category pages than "simple"/"full" for the same input.
 *
 *   The style can be passed as a command-line argument (for scripting),
 *   e.g. "java LinkProcessor full". If no argument is given, the program
 *   asks the user interactively which style to use.
 *
 * Usage:
 *   java LinkProcessor            (asks interactively for the style)
 *   java LinkProcessor simple     (skips the prompt)
 *   java LinkProcessor full       (skips the prompt)
 *   java LinkProcessor advanced   (skips the prompt)
 *
 * Compatible with Java 8.
 */
public class LinkProcessor {

    private static final String LINKS_FILE = "links.txt";
    private static final String ID_FILES_DIR = "id_files";
    private static final String HTML_DIR = "html_pages";

    private static final String STYLE_SIMPLE = "simple";
    private static final String STYLE_FULL = "full";
    private static final String STYLE_ADVANCED = "advanced";
    // "advanced" style: category keys are every substring (length MIN_SUBSTRING_LENGTH to
    // MAX_SUBSTRING_LENGTH) of each letters/digits piece found in a line, skipping any
    // substring that itself contains a digit. See buildSubstringIndex().
    private static final int MIN_SUBSTRING_LENGTH = 3;
    private static final int MAX_SUBSTRING_LENGTH = 26;
    // Only this many words (in first-seen order, not sorted) are embedded on the landing
    // page. Sorting/embedding every distinct word doesn't scale to very large links.txt
    // files (huge index.html, slow/failed generation), so the homepage stays intentionally
    // tiny. Any other word can still be reached directly via the "Open" search button.
    private static final int LANDING_CATEGORIES_LIMIT = 5;
    private static final int SUGGESTION_LIMIT = 10;
    // 100 KB (binary kilobyte). Once a word page's estimated size would exceed this,
    // the remaining ids spill over into "<word>_2.html", "<word>_3.html", and so on.
    private static final int MAX_PAGE_SIZE_BYTES = 100 * 1024;

    // Strips a leading scheme such as "http://" or "https://"
    private static final Pattern PROTOCOL = Pattern.compile("^[a-zA-Z][a-zA-Z0-9+.-]*://");
    // Anything that is not a letter or a digit becomes a separator
    private static final Pattern SPECIAL_CHARS = Pattern.compile("[^a-zA-Z0-9]+");
    private static final Pattern HAS_DIGIT = Pattern.compile(".*\\d.*");

    // Words that never get their own page: too generic/noisy to be useful as an index entry.
    private static final Set<String> STOPWORDS =
            new HashSet<String>(Arrays.asList("http", "https", "com"));
    // Words with length <= this are skipped too (e.g. "a", "to", "of", "id", "ok").
    private static final int MIN_WORD_LENGTH = 3;

    public static void main(String[] args) throws IOException {
        String style = resolveStyle(args);

        List<String> lines = readLines(LINKS_FILE);

        saveIdFiles(lines);

        // "advanced" categorizes by substring instead of whole word; "simple"/"full" both
        // categorize by whole word and only differ in runtime click behavior (see below).
        Map<String, List<Integer>> wordToIds = STYLE_ADVANCED.equals(style)
                ? buildSubstringIndex(lines)
                : buildWordIndex(lines);

        Path rootDir = Paths.get("").toAbsolutePath().normalize();
        Path htmlDir = Paths.get(HTML_DIR);
        Files.createDirectories(htmlDir);

        // "advanced" reuses "full"'s runtime behavior (fetch id_files/<id>.txt at runtime and
        // open the real link) - only the categorization step above differs.
        String runtimeLinkStyle = STYLE_ADVANCED.equals(style) ? STYLE_FULL : style;

        writeCommonJs(htmlDir);
        writeCommonCss(htmlDir);
        generateWordPages(htmlDir, wordToIds, runtimeLinkStyle);
        generateLandingPage(rootDir, wordToIds);

        Path idFilesDir = Paths.get(ID_FILES_DIR).toAbsolutePath().normalize();
        Path htmlDirAbs = htmlDir.toAbsolutePath().normalize();
        Path indexFile = rootDir.resolve("index.html").normalize();

        System.out.println();
        System.out.println("Style used: " + style
                + " (simple = 'Id N' labels, full = full link text, advanced = full link text + substring categories)");
        System.out.println("Processed " + lines.size() + " link(s) from " + LINKS_FILE);
        System.out.println("Created " + lines.size() + " file(s) in: " + idFilesDir);
        System.out.println("Created " + wordToIds.size() + " category page(s) plus common.js and common.css in: " + htmlDirAbs);
        System.out.println("Landing page written to: " + indexFile);
    }

    /**
     * Decides which display/categorization style to use:
     *   - "simple":   each id shown as a plain label, e.g. "Id 3". Categories are whole words.
     *   - "full":     each id shown using the full original link text. Categories are whole words.
     *   - "advanced": same runtime link behavior as "full" (fetches and opens the real link),
     *                 but categories are every substring (length 3 to 26) of each letters/
     *                 digits piece found in a line, skipping any substring that contains a
     *                 digit. See buildSubstringIndex().
     *
     * If a valid style is passed as a command-line argument, it is used directly
     * (handy for scripting/automation). Otherwise the user is asked interactively.
     */
    private static String resolveStyle(String[] args) throws IOException {
        if (args.length > 0) {
            String arg = args[0].trim().toLowerCase(Locale.ROOT);
            if (STYLE_SIMPLE.equals(arg) || STYLE_FULL.equals(arg) || STYLE_ADVANCED.equals(arg)) {
                return arg;
            }
            System.out.println("Unknown style '" + args[0] + "'. Valid options are 'simple', 'full' or 'advanced'.");
        }
        return askStyleInteractively();
    }

    private static String askStyleInteractively() throws IOException {
        BufferedReader console = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
        System.out.println("How should the id links be generated?");
        System.out.println("  1) simple   - show each id as a plain label, e.g. \"Id 3\"");
        System.out.println("  2) full     - show each id using the full original link text");
        System.out.println("  3) advanced - like full, but categories are word substrings (length 3-26)");
        while (true) {
            System.out.print("Enter 1 (simple), 2 (full) or 3 (advanced) [default: 1]: ");
            System.out.flush();
            String input = console.readLine();
            if (input == null || input.trim().isEmpty()) {
                return STYLE_SIMPLE;
            }
            String choice = input.trim().toLowerCase(Locale.ROOT);
            if (choice.equals("1") || STYLE_SIMPLE.equals(choice)) {
                return STYLE_SIMPLE;
            }
            if (choice.equals("2") || STYLE_FULL.equals(choice)) {
                return STYLE_FULL;
            }
            if (choice.equals("3") || STYLE_ADVANCED.equals(choice)) {
                return STYLE_ADVANCED;
            }
            System.out.println("Please type 1, 2, 3, \"simple\", \"full\" or \"advanced\".");
        }
    }

    /** Reads links.txt line by line. The file itself is never modified. */
    private static List<String> readLines(String path) throws IOException {
        List<String> lines = new ArrayList<String>();
        BufferedReader reader = new BufferedReader(
                new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8));
        try {
            String line;
            while ((line = reader.readLine()) != null) {
                lines.add(line);
            }
        } finally {
            reader.close();
        }
        return lines;
    }

    /** Step 1: one file per line, named after the line number, holding the line's content. */
    private static void saveIdFiles(List<String> lines) throws IOException {
        Path dir = Paths.get(ID_FILES_DIR);
        Files.createDirectories(dir);
        for (int i = 0; i < lines.size(); i++) {
            int id = i + 1;
            Path file = dir.resolve(id + ".txt");
            writeFile(file, lines.get(i));
        }
    }

    /**
     * Step 2: builds word -> list of ids, skipping:
     *   - words that contain a digit
     *   - stopwords ("http", "https", "com")
     *   - words shorter than {@link #MIN_WORD_LENGTH} letters (e.g. "a", "to", "of")
     */
    private static Map<String, List<Integer>> buildWordIndex(List<String> lines) {
        // LinkedHashMap keeps generation order predictable (first-seen word order)
        Map<String, List<Integer>> map = new LinkedHashMap<String, List<Integer>>();
        // Per-word id set, kept alongside the map for O(1) "already has this id" checks
        // instead of the O(n) List.contains() used previously.
        Map<String, Set<Integer>> seenIds = new LinkedHashMap<String, Set<Integer>>();

        for (int i = 0; i < lines.size(); i++) {
            int id = i + 1;
            String line = lines.get(i);

            // Remove a leading scheme (http://, https://, ftp://, ...) so words like
            // "https" don't generate their own page - matches the example in the spec.
            String withoutProtocol = PROTOCOL.matcher(line).replaceFirst("");

            // Replace every special character with a space
            String cleaned = SPECIAL_CHARS.matcher(withoutProtocol).replaceAll(" ").trim();
            if (cleaned.isEmpty()) {
                continue;
            }

            String[] words = cleaned.split("\\s+");
            for (String rawWord : words) {
                if (rawWord.isEmpty()) {
                    continue;
                }
                if (HAS_DIGIT.matcher(rawWord).matches()) {
                    continue; // skip words that contain numbers
                }
                if (rawWord.length() < MIN_WORD_LENGTH) {
                    continue; // skip 1-2 letter words
                }

                String word = rawWord.toLowerCase(Locale.ROOT);
                if (STOPWORDS.contains(word)) {
                    continue; // skip noisy/generic words
                }

                Set<Integer> ids = seenIds.get(word);
                if (ids == null) {
                    ids = new LinkedHashSet<Integer>();
                    seenIds.put(word, ids);
                    map.put(word, new ArrayList<Integer>());
                }
                if (ids.add(id)) {
                    map.get(word).add(id);
                }
            }
        }
        return map;
    }

    /**
     * Step 2 (alternative, for "advanced" style): builds substring -> list of ids.
     *
     * Each line is divided into pieces exactly like {@link #buildWordIndex}: a leading
     * protocol is stripped, then every run of non-alphanumeric characters becomes a
     * separator. Unlike {@link #buildWordIndex}, a piece containing a digit is NOT skipped
     * wholesale - instead, every substring of that piece is checked individually and only
     * digit-free substrings become categories (a piece can never contain a "special
     * character" itself, since those already act as separators).
     *
     * For every piece, every substring with length between {@link #MIN_SUBSTRING_LENGTH}
     * and {@link #MAX_SUBSTRING_LENGTH} (inclusive) becomes its own category, e.g. "banana"
     * (6 letters) yields: ban, ana, nan, bana, anan, nana, banan, anana, banana.
     *
     * Note: this can generate far more categories (and therefore far more html pages) than
     * whole-word indexing, especially for long pieces - each additional letter in a piece
     * adds several more substrings.
     */
    private static Map<String, List<Integer>> buildSubstringIndex(List<String> lines) {
        // LinkedHashMap keeps generation order predictable (first-seen substring order)
        Map<String, List<Integer>> map = new LinkedHashMap<String, List<Integer>>();
        Map<String, Set<Integer>> seenIds = new LinkedHashMap<String, Set<Integer>>();

        for (int i = 0; i < lines.size(); i++) {
            int id = i + 1;
            String line = lines.get(i);

            String withoutProtocol = PROTOCOL.matcher(line).replaceFirst("");
            String cleaned = SPECIAL_CHARS.matcher(withoutProtocol).replaceAll(" ").trim();
            if (cleaned.isEmpty()) {
                continue;
            }

            String[] pieces = cleaned.split("\\s+");
            for (String rawPiece : pieces) {
                if (rawPiece.isEmpty()) {
                    continue;
                }
                String piece = rawPiece.toLowerCase(Locale.ROOT);
                int len = piece.length();

                for (int start = 0; start < len; start++) {
                    int maxSubLenHere = Math.min(MAX_SUBSTRING_LENGTH, len - start);
                    for (int subLen = MIN_SUBSTRING_LENGTH; subLen <= maxSubLenHere; subLen++) {
                        String substring = piece.substring(start, start + subLen);
                        if (HAS_DIGIT.matcher(substring).matches()) {
                            continue; // skip this specific substring if it contains a number
                        }

                        Set<Integer> ids = seenIds.get(substring);
                        if (ids == null) {
                            ids = new LinkedHashSet<Integer>();
                            seenIds.put(substring, ids);
                            map.put(substring, new ArrayList<Integer>());
                        }
                        if (ids.add(id)) {
                            map.get(substring).add(id);
                        }
                    }
                }
            }
        }
        return map;
    }

    /** Single JS file shared/imported by every generated HTML page (word pages + landing page). */
    private static void writeCommonJs(Path htmlDir) throws IOException {
        String js =
                "// Shared script used by every generated html page.\n" +
                "//\n" +
                "// Word pages define:\n" +
                "//   var wordName = \"...\";\n" +
                "//   var linkStyle = \"simple\" | \"full\";\n" +
                "//   var ids = [1, 7, 42, ...];              (bare id numbers)\n" +
                "//\n" +
                "// 'simple' style: each id becomes a plain 'Id N' label that opens\n" +
                "//   ../id_files/<id>.txt in a new tab.\n" +
                "// 'full' style: for each id, the content of ../id_files/<id>.txt is\n" +
                "//   fetched at runtime and used as both the label and the href, so\n" +
                "//   clicking opens the real original link in a new tab. Requires the\n" +
                "//   page to be served over http(s) (fetch() can't read file:// URLs).\n" +
                "//   If the fetch fails, the link falls back to opening the local\n" +
                "//   id_files/<id>.txt file so it's never left dead.\n" +
                "//\n" +
                "// The landing page (index.html) defines:\n" +
                "//   var wordsData = [\"word1\", \"word2\", ...];  (first " + LANDING_CATEGORIES_LIMIT + " words found, no counts)\n" +
                "document.addEventListener('DOMContentLoaded', function () {\n" +
                "    if (window.ids) {\n" +
                "        renderIdLinks();\n" +
                "    }\n" +
                "    if (window.wordsData) {\n" +
                "        renderTopWords();\n" +
                "        initSearch();\n" +
                "    }\n" +
                "});\n" +
                "\n" +
                "// Local id_files/<id>.txt path, relative to a word page in '" + HTML_DIR + "'.\n" +
                "function idFilePath(id) {\n" +
                "    return '../" + ID_FILES_DIR + "/' + id + '.txt';\n" +
                "}\n" +
                "\n" +
                "// Renders the list of clickable ids on a word page.\n" +
                "// 'simple' style: label/href are computed immediately, no fetch needed.\n" +
                "// 'full' style: each link starts as a placeholder, then common.js fetches\n" +
                "// id_files/<id>.txt and swaps in the real link text as both the label\n" +
                "// and the href - so clicking opens the real link, not the .txt file.\n" +
                "function renderIdLinks() {\n" +
                "    var container = document.getElementById('links');\n" +
                "    if (!container) {\n" +
                "        return;\n" +
                "    }\n" +
                "\n" +
                "    var title = document.getElementById('word-title');\n" +
                "    if (title && window.wordName) {\n" +
                "        title.textContent = window.wordName;\n" +
                "    }\n" +
                "\n" +
                "    var isFull = window.linkStyle === 'full';\n" +
                "\n" +
                "    window.ids.forEach(function (id) {\n" +
                "        var a = document.createElement('a');\n" +
                "        a.target = '_blank';\n" +
                "        a.rel = 'noopener noreferrer';\n" +
                "        a.className = 'link-item';\n" +
                "\n" +
                "        if (!isFull) {\n" +
                "            a.href = idFilePath(id);\n" +
                "            a.textContent = 'Id ' + id;\n" +
                "            container.appendChild(a);\n" +
                "            return;\n" +
                "        }\n" +
                "\n" +
                "        a.href = idFilePath(id);\n" +
                "        a.textContent = 'Id ' + id + ' (loading...)';\n" +
                "        container.appendChild(a);\n" +
                "\n" +
                "        fetch(idFilePath(id))\n" +
                "            .then(function (res) {\n" +
                "                if (!res.ok) {\n" +
                "                    throw new Error('HTTP ' + res.status);\n" +
                "                }\n" +
                "                return res.text();\n" +
                "            })\n" +
                "            .then(function (text) {\n" +
                "                var link = text.trim();\n" +
                "                a.href = link;\n" +
                "                a.textContent = link;\n" +
                "            })\n" +
                "            .catch(function () {\n" +
                "                // Fetch can fail (e.g. page opened via file:// instead of a\n" +
                "                // real http server). Fall back to opening the local .txt file\n" +
                "                // itself so the link is never left dead.\n" +
                "                a.href = idFilePath(id);\n" +
                "                a.textContent = 'Id ' + id + ' (could not load link - opens local file)';\n" +
                "            });\n" +
                "    });\n" +
                "}\n" +
                "\n" +
                "// Renders the small set of starter categories embedded in wordsData\n" +
                "// (already limited server-side to keep the landing page small/safe for\n" +
                "// very large inputs - see LANDING_CATEGORIES_LIMIT in LinkProcessor.java).\n" +
                "function renderTopWords() {\n" +
                "    var container = document.getElementById('top-words');\n" +
                "    if (!container) {\n" +
                "        return;\n" +
                "    }\n" +
                "    window.wordsData.forEach(function (word) {\n" +
                "        container.appendChild(buildWordLink(word));\n" +
                "    });\n" +
                "}\n" +
                "\n" +
                "// The landing page (index.html) lives one level above '" + HTML_DIR + "',\n" +
                "// so word pages are reached with '" + HTML_DIR + "/<word>.html'.\n" +
                "function wordPageUrl(word) {\n" +
                "    return '" + HTML_DIR + "/' + word + '.html';\n" +
                "}\n" +
                "\n" +
                "function buildWordLink(word) {\n" +
                "    var a = document.createElement('a');\n" +
                "    a.href = wordPageUrl(word);\n" +
                "    a.className = 'word-item';\n" +
                "    a.textContent = word;\n" +
                "    return a;\n" +
                "}\n" +
                "\n" +
                "// Live search box with up to " + SUGGESTION_LIMIT + " suggestions (matched only against the\n" +
                "// small starter set in wordsData), plus a direct \"Open\" button/Enter fallback that\n" +
                "// jumps straight to the typed word's page even if it isn't in that starter set or\n" +
                "// doesn't exist at all - the browser will simply show its usual not-found page.\n" +
                "function initSearch() {\n" +
                "    var input = document.getElementById('search-input');\n" +
                "    var suggestions = document.getElementById('suggestions');\n" +
                "    var goButton = document.getElementById('search-go');\n" +
                "    if (!input || !suggestions) {\n" +
                "        return;\n" +
                "    }\n" +
                "\n" +
                "    function openTypedWord() {\n" +
                "        var word = input.value.trim().toLowerCase();\n" +
                "        if (!word) {\n" +
                "            return;\n" +
                "        }\n" +
                "        suggestions.style.display = 'none';\n" +
                "        window.location.href = wordPageUrl(word);\n" +
                "    }\n" +
                "\n" +
                "    function updateSuggestions() {\n" +
                "        var query = input.value.trim().toLowerCase();\n" +
                "        suggestions.innerHTML = '';\n" +
                "        if (!query) {\n" +
                "            suggestions.style.display = 'none';\n" +
                "            return;\n" +
                "        }\n" +
                "        var matches = window.wordsData.filter(function (word) {\n" +
                "            return word.indexOf(query) !== -1;\n" +
                "        }).slice(0, " + SUGGESTION_LIMIT + ");\n" +
                "\n" +
                "        if (matches.length === 0) {\n" +
                "            var empty = document.createElement('div');\n" +
                "            empty.className = 'suggestion-empty';\n" +
                "            empty.textContent = 'No matching words in the starter list - click Open to try that exact page anyway';\n" +
                "            suggestions.appendChild(empty);\n" +
                "            suggestions.style.display = 'block';\n" +
                "            return;\n" +
                "        }\n" +
                "\n" +
                "        matches.forEach(function (word) {\n" +
                "            var div = document.createElement('div');\n" +
                "            div.className = 'suggestion-item';\n" +
                "            div.textContent = word;\n" +
                "            div.addEventListener('click', function () {\n" +
                "                window.location.href = wordPageUrl(word);\n" +
                "            });\n" +
                "            suggestions.appendChild(div);\n" +
                "        });\n" +
                "        suggestions.style.display = 'block';\n" +
                "    }\n" +
                "\n" +
                "    // 'input' fires on every kind of user input: typing, pasting, cutting, dragging text in, etc.\n" +
                "    input.addEventListener('input', updateSuggestions);\n" +
                "    input.addEventListener('focus', updateSuggestions);\n" +
                "\n" +
                "    input.addEventListener('keydown', function (e) {\n" +
                "        if (e.key === 'Enter') {\n" +
                "            var first = suggestions.querySelector('.suggestion-item');\n" +
                "            if (first) {\n" +
                "                first.click();\n" +
                "            } else {\n" +
                "                // No matching suggestion (or none loaded) - try opening the typed\n" +
                "                // word's page directly instead of doing nothing.\n" +
                "                openTypedWord();\n" +
                "            }\n" +
                "        } else if (e.key === 'Escape') {\n" +
                "            suggestions.style.display = 'none';\n" +
                "        }\n" +
                "    });\n" +
                "\n" +
                "    if (goButton) {\n" +
                "        goButton.addEventListener('click', openTypedWord);\n" +
                "    }\n" +
                "\n" +
                "    document.addEventListener('click', function (e) {\n" +
                "        if (e.target !== input && e.target !== goButton && e.target.parentNode !== suggestions) {\n" +
                "            suggestions.style.display = 'none';\n" +
                "        }\n" +
                "    });\n" +
                "}\n";
        writeFile(htmlDir.resolve("common.js"), js);
    }

    /**
     * Single external stylesheet shared/imported by every generated HTML page
     * (word pages + landing page), so no CSS is duplicated inline per page.
     * Written once into "html_pages/common.css".
     */
    private static void writeCommonCss(Path htmlDir) throws IOException {
        String css =
                "/* Shared stylesheet used by every generated html page. */\n" +
                "body { font-family: Arial, sans-serif; margin: 40px; }\n" +
                "h1 { color: #333; margin-bottom: 4px; }\n" +
                "h2 { color: #333; margin-top: 40px; }\n" +
                "a.back { display: inline-block; margin-bottom: 16px; color: #555; text-decoration: none; }\n" +
                "a.back:hover { text-decoration: underline; }\n" +
                ".page-indicator { color: #777; margin: 0 0 20px 0; font-size: 14px; }\n" +
                ".link-item { display: block; margin: 6px 0; color: #0645ad; text-decoration: none; word-break: break-all; }\n" +
                ".link-item:hover { text-decoration: underline; }\n" +
                ".pagination { display: flex; align-items: center; gap: 16px; margin-top: 28px; }\n" +
                ".page-btn { padding: 8px 14px; background: #f0f4ff; color: #0645ad; text-decoration: none; border-radius: 6px; font-size: 14px; }\n" +
                ".page-btn:hover { background: #dbe6ff; }\n" +
                ".page-btn.disabled { color: #aaa; background: #f2f2f2; cursor: default; }\n" +
                ".page-info { color: #555; font-size: 14px; }\n" +
                "/* Landing page only */\n" +
                "body.landing { max-width: 720px; }\n" +
                ".search-box { position: relative; margin-top: 12px; }\n" +
                ".search-row { display: flex; gap: 8px; }\n" +
                "#search-input { flex: 1; min-width: 0; box-sizing: border-box; padding: 10px 12px; font-size: 16px; border: 1px solid #ccc; border-radius: 6px; }\n" +
                "#search-go { padding: 10px 18px; font-size: 15px; background: #0645ad; color: #fff; border: none; border-radius: 6px; cursor: pointer; white-space: nowrap; }\n" +
                "#search-go:hover { background: #033a8c; }\n" +
                "#suggestions { display: none; position: absolute; top: 100%; left: 0; right: 0; background: #fff; border: 1px solid #ccc; border-top: none; border-radius: 0 0 6px 6px; max-height: 260px; overflow-y: auto; z-index: 10; }\n" +
                ".suggestion-item { padding: 8px 12px; cursor: pointer; }\n" +
                ".suggestion-item:hover { background: #f0f4ff; }\n" +
                ".suggestion-empty { padding: 8px 12px; color: #888; }\n" +
                "#top-words { display: flex; flex-wrap: wrap; gap: 8px; }\n" +
                ".word-item { display: inline-block; padding: 6px 12px; background: #f0f4ff; color: #0645ad; text-decoration: none; border-radius: 16px; font-size: 14px; }\n" +
                ".word-item:hover { background: #dbe6ff; }\n";
        writeFile(htmlDir.resolve("common.css"), css);
    }

    private static void generateWordPages(Path htmlDir, Map<String, List<Integer>> wordToIds,
                                           String style) throws IOException {
        for (Map.Entry<String, List<Integer>> entry : wordToIds.entrySet()) {
            writeWordPages(htmlDir, entry.getKey(), entry.getValue(), style);
        }
    }

    /**
     * Builds every id's page, splitting them across multiple files once a page would
     * exceed {@link #MAX_PAGE_SIZE_BYTES}. The first page keeps the plain word name
     * ("cat.html"); overflow pages are numbered from 2 onward ("cat_2.html",
     * "cat_3.html", ...). Every page gets Previous/Next navigation buttons whenever
     * there is more than one page.
     *
     * The page only ever embeds the bare id numbers - never any label/href text.
     * The chosen style ("simple" or "full") is embedded too, and common.js decides
     * at runtime how to turn each id into a clickable link:
     *   - "simple": link opens the local "../id_files/<id>.txt" file directly.
     *   - "full":   common.js fetches "../id_files/<id>.txt" at runtime and uses
     *               its content as both the label and the href, so clicking opens
     *               the real original link (e.g. https://hot.com) in a new tab.
     */
    private static void writeWordPages(Path htmlDir, String word, List<Integer> ids,
                                        String style) throws IOException {
        // Worst-case overhead estimate: a page with both nav buttons and generously-sized
        // page numbers, so real pagination text (which is much shorter) never pushes an
        // actually-written page over the limit.
        int overheadBytes = utf8ByteLength(buildWordPageHtml(word, 2, 9999, style, "[]"));

        List<List<Integer>> pages = splitIntoPages(ids, overheadBytes);
        int totalPages = pages.size();

        for (int i = 0; i < totalPages; i++) {
            int pageNumber = i + 1;
            String idsJson = buildIdsJson(pages.get(i));
            String html = buildWordPageHtml(word, pageNumber, totalPages, style, idsJson);
            writeFile(htmlDir.resolve(pageFileName(word, pageNumber)), html);
        }
    }

    /** Greedily groups bare ids so that each page's estimated byte size stays under the limit. */
    private static List<List<Integer>> splitIntoPages(List<Integer> ids, int overheadBytes) {
        List<List<Integer>> pages = new ArrayList<List<Integer>>();
        List<Integer> current = new ArrayList<Integer>();
        int currentJsonBytes = 0; // bytes used by ids already in 'current', joined by commas

        for (Integer id : ids) {
            String json = String.valueOf(id);
            int jsonBytes = utf8ByteLength(json);
            int addedBytes = jsonBytes + (current.isEmpty() ? 0 : 1); // +1 for the joining comma

            if (!current.isEmpty() && overheadBytes + currentJsonBytes + addedBytes > MAX_PAGE_SIZE_BYTES) {
                pages.add(current);
                current = new ArrayList<Integer>();
                currentJsonBytes = 0;
                addedBytes = jsonBytes; // first item of the new page, no leading comma
            }

            current.add(id);
            currentJsonBytes += addedBytes;
        }

        pages.add(current); // always at least one page, even if ids is empty
        return pages;
    }

    private static String buildIdsJson(List<Integer> ids) {
        StringBuilder sb = new StringBuilder("[");
        for (int i = 0; i < ids.size(); i++) {
            if (i > 0) {
                sb.append(",");
            }
            sb.append(ids.get(i));
        }
        sb.append("]");
        return sb.toString();
    }

    /** "cat.html" for page 1, "cat_2.html", "cat_3.html", ... for later pages. */
    private static String pageFileName(String word, int pageNumber) {
        return pageNumber == 1 ? (word + ".html") : (word + "_" + pageNumber + ".html");
    }

    private static String buildWordPageHtml(String word, int pageNumber, int totalPages, String style, String idsJson) {
        StringBuilder html = new StringBuilder();
        html.append("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
        html.append("<meta charset=\"UTF-8\">\n");
        html.append("<title>").append(escapeHtml(word));
        if (totalPages > 1) {
            html.append(" (page ").append(pageNumber).append(" of ").append(totalPages).append(")");
        }
        html.append("</title>\n");
        html.append("<link rel=\"stylesheet\" href=\"common.css\">\n");
        html.append("</head>\n<body>\n");
        html.append("<a class=\"back\" href=\"../index.html\">&larr; Back to all words</a>\n");
        html.append("<h1 id=\"word-title\">").append(escapeHtml(word)).append("</h1>\n");
        if (totalPages > 1) {
            html.append("<p class=\"page-indicator\">Page ").append(pageNumber).append(" of ").append(totalPages).append("</p>\n");
        }
        html.append("<div id=\"links\"></div>\n");

        if (totalPages > 1) {
            html.append("<div class=\"pagination\">\n");
            if (pageNumber > 1) {
                html.append("  <a class=\"page-btn\" href=\"").append(pageFileName(word, pageNumber - 1))
                    .append("\">&laquo; Previous</a>\n");
            } else {
                html.append("  <span class=\"page-btn disabled\">&laquo; Previous</span>\n");
            }
            html.append("  <span class=\"page-info\">Page ").append(pageNumber).append(" of ").append(totalPages).append("</span>\n");
            if (pageNumber < totalPages) {
                html.append("  <a class=\"page-btn\" href=\"").append(pageFileName(word, pageNumber + 1))
                    .append("\">Next &raquo;</a>\n");
            } else {
                html.append("  <span class=\"page-btn disabled\">Next &raquo;</span>\n");
            }
            html.append("</div>\n");
        }

        html.append("<script>\n");
        html.append("  var wordName = ").append(jsonString(word)).append(";\n");
        html.append("  var linkStyle = ").append(jsonString(style)).append(";\n");
        html.append("  var ids = ").append(idsJson).append(";\n");
        html.append("</script>\n");
        html.append("<script src=\"common.js\"></script>\n");
        html.append("</body>\n</html>\n");

        return html.toString();
    }

    /**
     * Landing page: a handful of starter categories, plus a live search box (max 10
     * suggestions) and a direct "Open" button/Enter-key fallback for jumping straight to
     * any word's page by typed name, whether or not it's one of the starter categories -
     * or even whether or not it actually exists.
     *
     * Only the first {@link #LANDING_CATEGORIES_LIMIT} words are read and embedded, in
     * first-seen order - no sorting over the full word set and no per-word counts. This
     * keeps index.html small and fast to generate no matter how large links.txt is;
     * previously, sorting and embedding every distinct word (with counts) could produce a
     * page too large to write/open, so a very large links.txt could fail to generate a
     * landing page at all.
     */
    private static void generateLandingPage(Path rootDir, Map<String, List<Integer>> wordToIds) throws IOException {
        List<String> starterWords = new ArrayList<String>(LANDING_CATEGORIES_LIMIT);
        for (String word : wordToIds.keySet()) {
            if (starterWords.size() >= LANDING_CATEGORIES_LIMIT) {
                break;
            }
            starterWords.add(word);
        }

        StringBuilder wordsJson = new StringBuilder("[");
        for (int i = 0; i < starterWords.size(); i++) {
            if (i > 0) {
                wordsJson.append(",");
            }
            wordsJson.append(jsonString(starterWords.get(i)));
        }
        wordsJson.append("]");

        StringBuilder html = new StringBuilder();
        html.append("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
        html.append("<meta charset=\"UTF-8\">\n");
        html.append("<title>Links index</title>\n");
        html.append("<link rel=\"stylesheet\" href=\"").append(HTML_DIR).append("/common.css\">\n");
        html.append("</head>\n<body class=\"landing\">\n");
        html.append("<h1>Links index</h1>\n");
        html.append("<div class=\"search-box\">\n");
        html.append("  <div class=\"search-row\">\n");
        html.append("    <input type=\"text\" id=\"search-input\" placeholder=\"Search or type an exact word...\" autocomplete=\"off\">\n");
        html.append("    <button type=\"button\" id=\"search-go\">Open</button>\n");
        html.append("  </div>\n");
        html.append("  <div id=\"suggestions\"></div>\n");
        html.append("</div>\n");
        html.append("<h2>First ").append(LANDING_CATEGORIES_LIMIT).append(" categories found</h2>\n");
        html.append("<div id=\"top-words\"></div>\n");
        html.append("<script>\n");
        html.append("  var wordsData = ").append(wordsJson).append(";\n");
        html.append("</script>\n");
        html.append("<script src=\"").append(HTML_DIR).append("/common.js\"></script>\n");
        html.append("</body>\n</html>\n");

        writeFile(rootDir.resolve("index.html"), html.toString());
    }

    private static String jsonString(String s) {
        return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"")
                        .replace("\r", "\\r").replace("\n", "\\n") + "\"";
    }

    private static int utf8ByteLength(String s) {
        return s.getBytes(StandardCharsets.UTF_8).length;
    }

    private static String escapeHtml(String s) {
        return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
    }

    private static void writeFile(Path path, String content) throws IOException {
        Writer w = new OutputStreamWriter(new FileOutputStream(path.toFile()), StandardCharsets.UTF_8);
        try {
            w.write(content);
        } finally {
            w.close();
        }
    }
}
