/* This file is part of the pageguard project.
 * Copyright (C) Eugene 'Vindex' Stulin, 2026.
 * Distributed under the Boost Software License 1.0.
 * This file is offered as-is, without any warranty.
 */

static import std.ascii;
import std.array : appender, array;
import std.algorithm :
    canFind, endsWith, findSplitAfter, predSwitch, startsWith;
import std.base64 : Base64;
import std.conv : to;
static import std.file;
import std.file: exists, isDir, mkdirRecurse;
import std.getopt;
import std.path : buildPath, dirName;
import std.range : empty;
import std.stdio;
import std.string : indexOf, lastIndexOf, replace, strip, toLower;
static import std.utf;

import amalthea.dataprocessing : isAmong, stripRight, stripLeft;
import amalthea.encoding : UniString;
static import amalthea.net;
static import amalthea.sys;
alias jv = amalthea;

static import html;

immutable app = "pageguard";
immutable appVersion = import("version").strip;

enum defaultTimeout = 10_000;  // 10 seconds
enum defaultUserAgent =
    "Mozilla/5.0 (X11; Linux x86_64; rv:147.0) Gecko/20100101 Firefox/147.0";


int main(string[] args) {
    if (args.length == 2 && args[1] == "--version") {
        writeln(appVersion);
        return 0;
    }
    try {
        if (args.length == 2 && args[1] == "--help") {
            std.stdio.write(jv.sys.readAppHelpText(app));
            return 0;
        }
    } catch(Exception e) {
        stderr.writeln("Help file not found.");
        return 1;
    }
    string outputPath;
    bool keepJS = false;
    bool utf8 = false;
    args.getopt(
        config.caseSensitive,
        "o", &outputPath,
        "js", &keepJS,
        "utf8", &utf8
    );
    string url = args[1];
    try {
        string fileName = savePage(url, outputPath, utf8, keepJS);
        writeln(fileName);
    } catch(Exception e) {
        stderr.writeln(e.msg);
        return 1;
    }
    return 0;
}


string shortenLine(string line, size_t maxBytes = 200) {
    if (line.length <= maxBytes) {
        return line;
    }
    size_t i = 0;
	size_t unitLen = 0;
    while (i < maxBytes) {
        unitLen = std.utf.stride(line, i);
        if (i + unitLen <= maxBytes) {
            i += unitLen;
        } else {
            break;
        }
    }
    return line[0 .. i];
}


/*******************************************************************************
 * Function for saving HTML-pages with resources and conversion of links.
 * Returns:
 *     saved file path.
 */
string savePage(
    string url,
    string outputPath = "",
    bool forceUtf8 = false,
    bool keepJS = false
) {
    if (!outputPath.empty) {
        string dir = dirName(outputPath);
        if (!exists(dir)) {
            mkdirRecurse(dir);
        }
    }
    if (!jv.net.isLinkToHTML(url)) {
        throw new Exception("This URL does not lead to HTML-page.");
    }
    UniString page = amalthea.net.getText(url);
    string utf8Page = page.toString;
    string title = "Untitled";
    {
        // get UTF-8 version for extracting title
        auto doc = html.createDocument(utf8Page);  
        foreach(titleNode; doc.elementsByTagName("title")) {
            auto t = titleNode.text.strip;
            writeln("t: ", t);
            if (!t.empty) {
                title = t.idup.replace("/", "|");
                title = shortenLine(title);
            }
            break;
        }
    }
    writeln("title: ", title);
    string htmlText = "<!-- " ~ url ~ " -->\n";
    htmlText ~= forceUtf8 ? utf8Page : cast(string) page.getRawData;
    auto doc = html.createDocument(htmlText);
    embedImages(doc, url);
    if (keepJS) {
       embedJS(doc, url, !forceUtf8);
    } else {
       removeJS(doc);
    }
    embedCSS(doc, url, !forceUtf8);
    fixLinks(doc, url);
    if (forceUtf8) {
        fixCharset(doc);
    }

    if (outputPath.empty) {
        outputPath = title ~ ".html";
    } else if (outputPath.exists && outputPath.isDir) {
        outputPath = buildPath(outputPath, title ~ ".html");
    } else if (!outputPath.exists && outputPath.endsWith('/')) {
        mkdirRecurse(outputPath);
        outputPath = buildPath(outputPath, title ~ ".html");
    }
    std.file.write(outputPath, doc.root.html);
    return outputPath;
}


void embedImages(html.Document doc, string pageURL) {
    foreach (img; doc.elementsByTagName("img")) {
        auto src = img.attr("src");
        if (src.empty) {
            continue;
        }
        auto resolved = resolveURL(pageURL, src.idup);
        if (resolved.empty) {
            continue;
        }
        auto mime = toMimeFromExtension(getExtensionFromURL(src.idup));
        if (mime.empty) {
            continue;
        }
        ubyte[] data;
        try {
            data = getRaw(resolved);
        } catch (Exception e) {
            stderr.writeln("Warning: could not fetch image: ", resolved);
            continue;
        }
        if (data.empty) {
            continue;
        }
        string encoded = Base64.encode(data);
        img.attr("src", "data:" ~ mime ~ ";base64," ~ encoded);
        img.removeAttr("srcset");
        img.removeAttr("sizes");
    }
}


void embedJS(html.Document doc, string pageURL, bool keepOriginalEncoding) {
    foreach(script; doc.elementsByTagName("script")) {
        auto src = script.attr("src");
        if (src.empty) {
            continue;
        }
        auto resolved = resolveURL(pageURL, src.idup);
        if (resolved.empty) {
            continue;
        }
        string content;
        try {
            content = getEncodedPage(resolved, keepOriginalEncoding);
        } catch (Exception e) {
            stderr.writeln("Warning: could not fetch script: ", resolved);
            continue;
        }
        script.removeAttr("src");
        script.text = content;
    }
}


void removeJS(html.Document doc) {
    foreach(script; doc.elementsByTagName("script").array) {
        script.destroy();
    }
}


string getEncodedPage(string url, bool keepOriginalEncoding = false) {
    if (keepOriginalEncoding) {
        return cast(string) getPage(url).getRawData;
    }
    return getPage(url).toString;
}


void embedCSS(html.Document doc, string pageURL, bool keepOriginalEncoding) {
    foreach (style; doc.elementsByTagName("style")) {
        auto cssText = style.text;
        if (!cssText.empty && cssText.indexOf("url(") != -1) {
            style.text = embedCSSResources(cssText.idup, pageURL);
        }
    }
    html.Node[] toRemove;
    auto links = doc.elementsByTagName("link").array;
    foreach(link; links) {
        auto rel = link.attr("rel");
        if (rel.toLower != "stylesheet") {
            continue;
        }
        auto href = link.attr("href");
        if (href.empty) {
            continue;
        }
        auto resolved = resolveURL(pageURL, href.idup);
        if (resolved.empty) {
            continue;
        }
        string cssContent;
        try {
            cssContent = getEncodedPage(resolved, keepOriginalEncoding);
        } catch (Exception e) {
            stderr.writeln("Warning: could not fetch stylesheet: ", resolved);
            continue;
        }
        cssContent = embedCSSResources(cssContent, resolved);
        auto style = doc.createElement("style");
        style.attr("type", "text/css");
        auto media = link.attr("media");
        if (!media.empty) {
            style.attr("media", media.idup);
        }
        doc.createTextNode(cssContent, style);
        style.insertBefore(link);
        toRemove ~= link;
    }
    foreach_reverse(link; toRemove) {
        link.destroy();
    }
}


string embedCSSResources(string css, string cssURL) {
    enum urlToken = "url(";
    enum urlTokenLen = urlToken.length;
    auto result = appender!string();
    size_t pos = 0;

    while (pos < css.length) {
        auto index = css.indexOf(urlToken, pos);
        if (index == -1) {
            result.put(css[pos .. $]);
            break;
        }
        result.put(css[pos .. index]);

        size_t i = index + urlTokenLen;
        while (i < css.length && std.ascii.isWhite(css[i])) i++;
        if (i >= css.length) {
            result.put(css[index .. $]);
            break;
        }

        char quote = '\0';
        if (css[i] == '"' || css[i] == '\'') {
            quote = css[i];
            i++;
        }
        // extract URL
        size_t urlStart = i;
        size_t urlEnd = i;
        if (quote != '\0') {
            while (urlEnd < css.length && css[urlEnd] != quote) urlEnd++;
        } else {  // without quotes
            while(urlEnd < css.length && css[urlEnd] != ')') urlEnd++;
            size_t trimmed = urlEnd;
            while(trimmed > urlStart && std.ascii.isWhite(css[trimmed - 1])) {
                trimmed--;
            }
            urlEnd = trimmed;
        }
        if (urlEnd >= css.length) {  // for strange cases
            result.put(css[index .. index + urlTokenLen]);
            pos = index + urlTokenLen;
            continue;
        }
        string resourceAddr = css[urlStart .. urlEnd];

        // find ')'
        size_t closeParen = urlEnd;
        if (quote != '\0') {
            closeParen++;  // skip closing quote
            while (closeParen < css.length && css[closeParen] != ')') {
                closeParen++;
            }
        }
        if (closeParen >= css.length) {  // for other strange cases
            result.put(css[index .. index + urlTokenLen]);
            pos = index + urlTokenLen;
            continue;
        }

        // replace content to base64 representation
        auto resolved = resolveURL(cssURL, resourceAddr);
        auto mime = toMimeFromExtension(getExtensionFromURL(resourceAddr));
        bool embedded = false;
        if (!resolved.empty && !mime.empty) {
            try {
                auto data = getRaw(resolved);
                if (!data.empty) {
                    auto encoded = Base64.encode(data);
                    result.put("url('data:");
                    result.put(mime);
                    result.put(";base64,");
                    result.put(encoded);
                    result.put("')");
                    embedded = true;
                }
            } catch (Exception) {
                stderr.writeln(
                    "Warning: could not fetch CSS resource: ", resolved
                );
            }
        }
        if (!embedded) {
            result.put(css[index .. closeParen + 1]);
        }
        pos = closeParen + 1;
    }
    return result.data;
}


void fixLinks(html.Document doc, string pageURL) {
    void changeLinkAttribute(string tag, string attr) {
        foreach(node; doc.elementsByTagName(tag)) {
            auto value = node.attr(attr);
            if (value.empty) {
                continue;
            }
            string newValue = resolveURL(pageURL, value.idup);
            if (!newValue.empty) {
                node.attr(attr, newValue);
            }
        }
    }
    changeLinkAttribute("a", "href");
    changeLinkAttribute("area", "href");
    changeLinkAttribute("form", "action");
}


void fixCharset(html.Document doc) {
    foreach(meta; doc.elementsByTagName("meta")) {
        auto charset = meta.attr("charset");
        if (!charset.empty) {
            meta.attr("charset", "UTF-8");
            continue;
        }
        auto httpEquiv = meta.attr("http-equiv");
        if (httpEquiv.toLower == "content-type") {
            meta.attr("content", "text/html; charset=UTF-8");
        }
    }
}


string getSiteAddressFromURL(string url) {
    auto protocolEnd = url.indexOf("://");
    protocolEnd = (protocolEnd == -1) ? 0 : protocolEnd + 3;
    auto hostEnd = url.indexOf('/', protocolEnd);
    if (hostEnd == -1) {
        return url;
    }
    return url[0 .. hostEnd];
}


string resolveURL(string baseURL, string addr) {
    string[] innerMarks = ["#", "data:", "javascript:", "mailto:"];
    string result;
    if (addr.startsWith("//")) {
        auto protocolEnd = baseURL.indexOf("://");
        if (protocolEnd != -1) {
            string protocol = baseURL[0 .. protocolEnd + 3];
            result = protocol ~ addr.stripLeft('/');
        } else {
            result = addr.stripLeft('/');
        }
    } else if (addr.startsWith("/")) {
        result = getSiteAddressFromURL(baseURL) ~ addr;
    } else if (addr.startsAmong(["http://", "https://"])) {
        result = addr;
    } else if (addr.startsWith("//")) {
        result = "http:" ~ addr;
    } else if (addr.empty || addr.startsAmong(innerMarks)) {
        result = "";
    } else {
        result = getBaseDir(baseURL) ~ addr;
    }
    return result;
}


bool startsAmong(string line, string[] prefixes) {
    foreach(prefix; prefixes) {
        if (line.startsWith(prefix)) {
            return true;
        }
    }
    return false;
}


string getBaseDir(string url) {
    auto protocolEnd = url.indexOf("://");
    auto lastSlash = url.lastIndexOf('/');
    if (protocolEnd == -1 && lastSlash == -1) {
        return url ~ "/";
    } else if (protocolEnd != -1 && lastSlash <= protocolEnd + 2) {
        return url ~ "/";
    }
    return url[0 .. lastSlash + 1];
}


string toMimeFromExtension(string ext) {
    if (ext.empty) {
        return "";
    }
    return ext.predSwitch(
        ".bmp",   "image/bmp",
        ".gif",   "image/gif",
        ".ico",   "image/x-icon",
        ".jp2",   "image/jp2",
        ".jpeg",  "image/jpeg",
        ".jpg",   "image/jpeg",
        ".png",   "image/png",
        ".svg",   "image/svg+xml",
        ".tif",   "image/tiff",
        ".tiff",  "image/tiff",
        ".webp",  "image/webp",
        ".eot",   "application/vnd.ms-fontobject",
        ".otf",   "application/x-font-otf",
        ".ttf",   "application/x-font-ttf",
        ".woff",  "font/woff",
        ".woff2", "font/woff2",
        ""
    );
}


string getExtensionFromURL(string url) {
    auto qmarkPos = url.indexOf('?');
    if (qmarkPos != -1) {
        url = url[0 .. qmarkPos];
    }
    auto hashPos = url.indexOf('#');
    if (hashPos != -1) {
        url = url[0 .. hashPos];
    }
    auto dotPos = url.lastIndexOf('.');
    if (dotPos == -1 || url.lastIndexOf('/') > dotPos) {
        return "";
    }
    return url[dotPos .. $].idup.toLower;
}
unittest {
    auto addr = "https://www.site.net/opennews/art.shtml?num=64727";
    assert(getExtensionFromURL(addr) == ".shtml");
    auto addr2 = "https://www.site.net/dir.123/subdir";
    assert(getExtensionFromURL(addr2) == "");
}


/*******************************************************************************
 * GET request to raw content.
 * Returns: Byte array.
 */
ubyte[] getRaw(string url) {
    return jv.net.get(url, defaultTimeout, defaultUserAgent);
}


/*******************************************************************************
 * Get text content as amalthea.encoding.UniString by URL.
 */
UniString getPage(string url) {
    return jv.net.getText(url, defaultTimeout, defaultUserAgent);
}
