public class RunStepConfig {

    private final String reportDirectory;
    private final String workingDirectory;
    private final String screenshotsDirectory;
    private final Step step;
    private final Map<String, String> urlReplacements;
    private final List<String> chromeParameters;
    private final List<String> firefoxParameters;

    private RunStepConfig(Builder builder) {
        reportDirectory = builder.reportDirectory;
        workingDirectory = builder.workingDirectory;
        screenshotsDirectory = builder.screenshotsDirectory;
        step = builder.step;
        urlReplacements = builder.urlReplacements;
        chromeParameters = builder.chromeParameters;
        firefoxParameters = builder.firefoxParameters;
    }

    public static Builder jLineupRunConfigurationBuilder() {
        return new Builder();
    }

    public static Builder copyOfBuilder(RunStepConfig config) {
        return new Builder(config);
    }

    public String getReportDirectory() {
        return reportDirectory;
    }

    public String getWorkingDirectory() {
        return workingDirectory;
    }

    public String getScreenshotsDirectory() {
        return screenshotsDirectory;
    }

    public Step getStep() {
        return step;
    }

    public Map<String, String> getUrlReplacements() {
        return urlReplacements;
    }

    public List<String> getChromeParameters() {
        return chromeParameters;
    }

    public List<String> getFirefoxParameters() {
        return firefoxParameters;
    }


    public static final class Builder {
        private String reportDirectory;
        private String workingDirectory;
        private String screenshotsDirectory;
        private List<String> chromeParameters = emptyList();
        private List<String> firefoxParameters = emptyList();
        private Map<String, String> urlReplacements = emptyMap();
        private Step step;

        private Builder() {
        }

        private Builder(RunStepConfig copy) {
            this.reportDirectory = copy.getReportDirectory();
            this.workingDirectory = copy.getWorkingDirectory();
            this.screenshotsDirectory = copy.getScreenshotsDirectory();
            this.step = copy.getStep();
            this.urlReplacements = copy.getUrlReplacements();
            this.chromeParameters = copy.getChromeParameters();
            this.firefoxParameters = copy.getFirefoxParameters();
        }

        public Builder withReportDirectory(String val) {
            reportDirectory = val;
            return this;
        }

        public Builder withWorkingDirectory(String val) {
            workingDirectory = val;
            return this;
        }

        public Builder withScreenshotsDirectory(String val) {
            screenshotsDirectory = val;
            return this;
        }

        public Builder withStep(Step val) {
            step = val;
            return this;
        }

        public Builder withFirefoxParameters(List<String> val) {
            firefoxParameters = val;
            return this;
        }

        public Builder withChromeParameters(List<String> val) {
            chromeParameters = val;
            return this;
        }

        public RunStepConfig build() {
            return new RunStepConfig(this);
        }

        public Builder withUrlReplacements(Map<String, String> val) {
            urlReplacements = val;
            return this;
        }
    }
} public class Utils {

    private final static Logger LOG = LoggerFactory.getLogger(lookup().lookupClass());

    private final static AtomicInteger threadCounter = new AtomicInteger();
    public static final String JLINEUP_FILE_APPENDER = "JLineupFileAppender";
    public static final String SIFTING_APPENDER_NAME_IN_LOGBACK_XML = "SIFT";

    public static String readVersion() {
        Properties prop = new Properties();
        try {
            prop.load(Utils.class.getClassLoader().getResourceAsStream("version.properties"));
        } catch (Exception ex) {
            ex.printStackTrace();
        }
        return prop.getProperty("jlineup.version");
    }

    public static String readCommit() {
        Properties prop = new Properties();
        try {
            prop.load(Utils.class.getClassLoader().getResourceAsStream("version.properties"));
        } catch (Exception ex) {
            ex.printStackTrace();
        }
        return prop.getProperty("jlineup.commit");
    }

    public static ExecutorService createThreadPool(int threads, final String baseName) {

        if (threads < 1) {
            threads = 1;
        }

        final ThreadFactory factory = target -> {
            String name = String.format("%s-%d", baseName, threadCounter.getAndIncrement());
            final Thread thread = new Thread(target, name);
            LOG.debug("Created new worker thread.");
            thread.setUncaughtExceptionHandler((t, e) -> LOG.error("Exception", e));
            return thread;
        };
        return Executors.newFixedThreadPool(threads, factory);
    }

    public static void setLogLevelToDebug() {
        //ch.qos.logback.classic.Logger root = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME);
        //root.setLevel(Level.DEBUG);
        ch.qos.logback.classic.Logger otto = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger("de.otto");
        otto.setLevel(Level.DEBUG);
    }

    public static void setDebugLogLevelsOfSelectedThirdPartyLibsToWarn() {
        ch.qos.logback.classic.Logger apache = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger("org.apache.hc");
        apache.setLevel(Level.WARN);
    }

    public static void logToFile(String workingDir) {
        LoggerContext lc = (LoggerContext) LoggerFactory.getILoggerFactory();
        PatternLayoutEncoder ple = new PatternLayoutEncoder();

        ple.setPattern("%date %level [%thread] %logger{10} [%file:%line] %msg%n");
        ple.setContext(lc);
        ple.start();
        FileAppender<ILoggingEvent> fileAppender = new FileAppender<>();
        fileAppender.setName(JLINEUP_FILE_APPENDER);
        fileAppender.setFile(workingDir + "/jlineup.log");
        fileAppender.setEncoder(ple);
        fileAppender.setContext(lc);
        fileAppender.start();

        ch.qos.logback.classic.Logger logger = (ch.qos.logback.classic.Logger)LoggerFactory.getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME);
        logger.addAppender(fileAppender);
        logger.setLevel(Level.DEBUG);
    }

    public static void stopFileLoggers() {
        ch.qos.logback.classic.Logger logger = (ch.qos.logback.classic.Logger)LoggerFactory.getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME);

        Appender<ILoggingEvent> appender = logger.getAppender(JLINEUP_FILE_APPENDER);
        if (appender != null) {
            appender.stop();
        }

        appender = logger.getAppender(SIFTING_APPENDER_NAME_IN_LOGBACK_XML);
        if (appender != null) {
            appender.stop();
        }
    }

    public static boolean shouldUseLegacyReportFormat(JobConfig jobConfig) {
        return (jobConfig.reportFormat != null && jobConfig.reportFormat == 1) || (jobConfig.reportFormat == null && JobConfig.DEFAULT_REPORT_FORMAT == 1);
    }

    public static String getVersion() {
        return String.format("%s [%s]%n", readVersion(), readCommit());
    }

    public static void writeInfosForCommonErrors(String message) {
        if (message.contains("session deleted because of page crash")) {
            System.err.println("\n=====\n");
            System.err.println("It looks like you're using Google Chrome or Chromium and it crashes while browsing to your configured page.");
            System.err.println("Are you running inside a Docker container? Try to run JLineup with --chrome-parameter \"--disable-dev-shm-usage\" and this error might be gone.");
            System.err.println("\n=====\n");
        }
    }
} public class BrowserUtils {

    private static final Logger LOG = LoggerFactory.getLogger(lookup().lookupClass());
    private static final boolean CHROME_DETERMINISTIC_OPTIONS = true;

    public static String buildUrl(String url, String path, final Map<String, String> envMapping) {
        if (envMapping != null && !envMapping.isEmpty()) {
            for (Map.Entry<String, String> envMappingEntry : envMapping.entrySet()) {
                final String fromEnvironment = envMappingEntry.getKey();
                final String toEnvironment = envMappingEntry.getValue();
                url = url.replace("https://" + fromEnvironment + ".", "https://" + toEnvironment + ".");
                url = url.replace("http://" + fromEnvironment + ".", "http://" + toEnvironment + ".");
                url = url.replace("." + fromEnvironment + ".", "." + toEnvironment + ".");
            }
        }
        return buildUrl(url, path);
    }

    static String buildUrl(String url, String path) {
        if (path == null) {
            path = DEFAULT_PATH;
        }
        if (!url.endsWith("/") && !path.equals("") && !path.startsWith("/")) {
            url = url + "/";
        }
        if (url.endsWith("/") && path.startsWith("/")) {
            path = path.substring(1);
        }
        return url + path;
    }

    synchronized WebDriver getWebDriverByConfig(JobConfig jobConfig, RunStepConfig runStepConfig) {
        return getWebDriverByConfig(jobConfig, runStepConfig, new DeviceConfig());
    }

    synchronized WebDriver getWebDriverByConfig(JobConfig jobConfig, RunStepConfig runStepConfig, DeviceConfig device) {
        WebDriver driver;
        if (jobConfig.browser.isFirefox()) {
            FirefoxOptions options = new FirefoxOptions();
            FirefoxProfile firefoxProfileWithDisabledAnimatedGifs = getFirefoxProfileWithDisabledAnimatedGifs();
            options.setProfile(firefoxProfileWithDisabledAnimatedGifs);
            options.addArguments(runStepConfig.getFirefoxParameters());
            if (jobConfig.browser.isHeadless()) {
                options.setHeadless(true);
                options.addArguments("-width", device.width + "", "-height", device.height + "");
            }
            LOG.debug("Creating firefox with options: {}", options.toString());
            driver = WebDriverManager.firefoxdriver().capabilities(options).create();
        } else if (jobConfig.browser.isChrome()) {
            ChromeOptions options = new ChromeOptions();
            //To work in a headless env, this is needed
            options.addArguments("--no-sandbox");
            options.addArguments("--whitelisted-ips");
            options.addArguments(runStepConfig.getChromeParameters());

            //These options my help to convince Chrome to render deterministically
            //This is important for the pixel-perfect comparison of before and after steps
            //There were problems to render Webfonts, SVGs and progressive JPGs deterministically on huge pages (i.e. otto.de)
            //Beware of dragons
            if (CHROME_DETERMINISTIC_OPTIONS) {
                options.addArguments("--disable-threaded-animation");
                //options.addArguments("--disable-threaded-compositing"); // This option breaks rendering completely as of Chrome 70 (2019-01-07)
                options.addArguments("--disable-threaded-scrolling");
                options.addArguments("--num-raster-threads=1");
                options.addArguments("--disable-histogram-customizer");
                options.addArguments("--disable-composited-antialiasing");
            }

            if (device.isMobile()) {
                Map<String, Object> mobileEmulation = new HashMap<>();
                if (!device.deviceName.equalsIgnoreCase("MOBILE")) {
                    mobileEmulation.put("deviceName", device.deviceName);
                } else {
                    Map<String, Object> deviceMetrics = new HashMap<>();
                    deviceMetrics.put("width", device.width);
                    deviceMetrics.put("height", device.height);
                    deviceMetrics.put("pixelRatio", device.pixelRatio);
                    deviceMetrics.put("touch", true);
                    mobileEmulation.put("deviceMetrics", deviceMetrics);
                    if (device.userAgent != null) mobileEmulation.put("userAgent", device.userAgent);
                }
                options.setExperimentalOption("mobileEmulation", mobileEmulation);
            } else if (device.userAgent != null) {
                options.addArguments("--user-agent='" + device.userAgent + "'");
            }

            if (jobConfig.browser.isHeadless()) {
                options.setHeadless(true);
                options.addArguments("--window-size=" + device.width + "," + device.height);
            }

            LOG.debug("Creating chrome with options: {}", options.toString());
            driver = WebDriverManager.chromedriver().capabilities(options).create();
        } else if (jobConfig.browser.isChromium()) {
            driver = WebDriverManager.chromiumdriver().create();
        } else if (jobConfig.browser.isSafari()) {
            driver = WebDriverManager.safaridriver().create();
        } else {
            LOG.error("You need either Firefox or Chrome / Chromium to make JLineup work. Install one of them and try again.");
            return null;
        }
        driver.manage().timeouts().pageLoadTimeout(Duration.of(jobConfig.pageLoadTimeout, SECONDS));
        return driver;
    }

    private FirefoxProfile getFirefoxProfileWithDisabledAnimatedGifs() {
        FirefoxProfile firefoxProfileHeadless = new FirefoxProfile();
        firefoxProfileHeadless.setPreference("image.animation_mode", "none");
        return firefoxProfileHeadless;
    }

    public static List<ScreenshotContext> buildScreenshotContextListFromConfigAndState(RunStepConfig runStepConfig, JobConfig jobConfig) {
        List<ScreenshotContext> screenshotContextList = new ArrayList<>();
        Map<String, UrlConfig> urls = jobConfig.urls;

        for (final Map.Entry<String, UrlConfig> urlConfigEntry : urls.entrySet()) {
            final UrlConfig urlConfig = urlConfigEntry.getValue();

            final List<DeviceConfig> deviceConfigs;
            if (urlConfig.devices == null) {
                deviceConfigs = new ArrayList<>();
                urlConfig.windowWidths.forEach(width -> deviceConfigs.add(deviceConfigBuilder().withWidth(width).withHeight(jobConfig.windowHeight).build()));
            } else {
                deviceConfigs = urlConfig.devices;
            }

            AtomicBoolean dontShareBrowser = new AtomicBoolean(false);
            deviceConfigs.forEach(config -> {
                if (config.isMobile()) {
                    dontShareBrowser.set(true);
                }
            });

            final List<String> paths = urlConfig.paths;
            for (final String path : paths) {
                //In most cases, there are no alternatingCookies
                //TODO: Look for a more beautiful distinction
                if (urlConfig.alternatingCookies.isEmpty()) {
                    screenshotContextList.addAll(
                            deviceConfigs.stream()
                                    .map(deviceConfig ->
                                            new ScreenshotContext(prepareDomain(runStepConfig, urlConfigEntry.getKey()), path, deviceConfig,
                                                    urlConfig.cookies, runStepConfig.getStep(), urlConfig, getFullPathOfReportDir(runStepConfig), dontShareBrowser.get(), urlConfigEntry.getKey()))
                                    .collect(Collectors.toList()));
                } else {
                    screenshotContextList.addAll(
                            deviceConfigs.stream()
                                    .flatMap(deviceConfig ->
                                            urlConfig.alternatingCookies.stream().map(alteringCookies -> {
                                                ArrayList<Cookie> newCookies = urlConfig.cookies != null ? new ArrayList<>(urlConfig.cookies) : new ArrayList<>();
                                                newCookies.addAll(alteringCookies);
                                                return new ScreenshotContext(prepareDomain(runStepConfig, urlConfigEntry.getKey()), path, deviceConfig,
                                                        newCookies, runStepConfig.getStep(), urlConfig, getFullPathOfReportDir(runStepConfig), dontShareBrowser.get(), urlConfigEntry.getKey());
                                            })).collect(Collectors.toList()));
                }
            }
        }
        return screenshotContextList;
    }

    public static List<ScreenshotContext> buildTestSetupContexts(RunStepConfig runStepConfig, JobConfig jobConfig) {
        return buildTestContexts(runStepConfig, jobConfig, true);
    }

    public static List<ScreenshotContext> buildTestCleanupContexts(RunStepConfig runStepConfig, JobConfig jobConfig) {
        return buildTestContexts(runStepConfig, jobConfig, false);
    }

    private static List<ScreenshotContext> buildTestContexts(RunStepConfig runStepConfig, JobConfig jobConfig, boolean setupPhase) {
        final List<ScreenshotContext> screenshotContextList = new ArrayList<>();
        final Map<String, UrlConfig> urls = jobConfig.urls;
        for (final Map.Entry<String, UrlConfig> urlConfigEntry : urls.entrySet()) {
            final List<String> paths = setupPhase ? urlConfigEntry.getValue().setupPaths : urlConfigEntry.getValue().cleanupPaths;
            for (final String path : paths) {
                //TODO: alternatingCookies
                screenshotContextList.add(new ScreenshotContext(prepareDomain(runStepConfig, urlConfigEntry.getKey()), path, deviceConfigBuilder().build(),
                        Collections.emptyList(), runStepConfig.getStep(), urlConfigEntry.getValue(), getFullPathOfReportDir(runStepConfig), false, urlConfigEntry.getKey()));
            }
        }
        return screenshotContextList;
    }

    public static String getFullPathOfReportDir(RunStepConfig runStepConfig) {
        if (runStepConfig.getReportDirectory() == null) {
            return null;
        }
        return Paths.get(runStepConfig.getWorkingDirectory(), runStepConfig.getReportDirectory()).toAbsolutePath().toString();
    }

    public static String prepareDomain(final RunStepConfig runStepConfig, final String url) {
        String processedUrl = url;
        for (Map.Entry<String, String> replacement : runStepConfig.getUrlReplacements().entrySet()) {
            processedUrl = processedUrl.replace(replacement.getKey(), replacement.getValue());
        }
        return processedUrl;
    }

    public static String prependHTTPIfNotThereAndToLowerCase(String url) {
        String ret = url.toLowerCase();
        if (!url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("file://") && !url.startsWith("ftp://")) {
            ret = "http://" + ret;
        }
        return ret;
    }
} @JsonDeserialize(builder = ScreenshotContext.Builder.class)
public final class ScreenshotContext  {
    public final String url;
    public final String urlSubPath;
    public final DeviceConfig deviceConfig;
    public final List<Cookie> cookies;
    @JsonIgnore
    public final Step step;
    @JsonIgnore
    public final UrlConfig urlConfig;
    @JsonIgnore
    final String fullPathOfReportDir;
    @JsonIgnore
    final boolean dontShareBrowser;
    @JsonIgnore
    final String originalUrl;

    ScreenshotContext(String url, String urlSubPath, DeviceConfig deviceConfig, List<Cookie> cookies, Step step, UrlConfig urlConfig, String fullPathOfReportDir, boolean dontShareBrowser, String originalUrl) {
        this.url = url;
        this.urlSubPath = urlSubPath;
        this.deviceConfig = deviceConfig;
        this.cookies = cookies;
        this.step = step;
        this.urlConfig = urlConfig;
        this.fullPathOfReportDir = fullPathOfReportDir;
        this.dontShareBrowser = dontShareBrowser;
        this.originalUrl = originalUrl;
    }

    //Used by Jackson
    private ScreenshotContext() {
        this(screenshotContextBuilder());
    }

    private ScreenshotContext(Builder builder) {
        url = builder.url;
        urlSubPath = builder.urlSubPath;
        deviceConfig = builder.deviceConfig;
        cookies = builder.cookies;
        step = builder.step;
        urlConfig = builder.urlConfig;
        fullPathOfReportDir = builder.fullPathOfReportDir;
        dontShareBrowser = builder.dontShareBrowser;
        originalUrl = builder.originalUrl;
    }

    //Used in Tests only
    public static ScreenshotContext of(String url, String path, DeviceConfig deviceConfig, Step step, UrlConfig urlConfig, List<Cookie> cookies, String originalUrl) {
        return new ScreenshotContext(url, path, deviceConfig, cookies, step, urlConfig, null, false, originalUrl);
    }

    //Used in Tests only
    public static ScreenshotContext of(String url, String path, DeviceConfig deviceConfig, Step step, UrlConfig urlConfig, List<Cookie> cookies) {
        return new ScreenshotContext(url, path, deviceConfig, cookies, step, urlConfig, null, false, url);
    }

    //Used in Tests only
    public static ScreenshotContext of(String url, String path, DeviceConfig deviceConfig, Step step, UrlConfig urlConfig) {
        return new ScreenshotContext(url, path, deviceConfig, urlConfig.cookies, step, urlConfig, null, false, url);
    }

    public static Builder screenshotContextBuilder() {
        return new Builder();
    }

    public static Builder copyOfBuilder(ScreenshotContext copy) {
        Builder builder = new Builder();
        builder.url = copy.url;
        builder.urlSubPath = copy.urlSubPath;
        builder.deviceConfig = copy.deviceConfig;
        builder.cookies = copy.cookies;
        builder.step = copy.step;
        builder.urlConfig = copy.urlConfig;
        builder.fullPathOfReportDir = copy.fullPathOfReportDir;
        builder.dontShareBrowser = copy.dontShareBrowser;
        builder.originalUrl = copy.originalUrl;
        return builder;
    }

    /*
     *
     *
     *
     *  BEGIN of getters block
     *
     *  For GraalVM (JSON is empty if no getters are here)
     *
     *
     *
     */

    public String getUrl() {
        return url;
    }

    public String getUrlSubPath() {
        return urlSubPath;
    }

    public DeviceConfig getDeviceConfig() {
        return deviceConfig;
    }

    public List<Cookie> getCookies() {
        return cookies;
    }

    /*
     *
     *
     *
     *  END of getters block
     *
     *  For GraalVM (JSON is empty if no getters are here)
     *
     *
     *
     */

    public int contextHash() {
        return Objects.hash(originalUrl, urlSubPath, deviceConfig, cookies);
    }

    public boolean equalsIgnoreStep(ScreenshotContext that) {
        if (this == that) return true;
        if (that == null) return false;
        return Objects.equals(url, that.url) &&
                Objects.equals(urlSubPath, that.urlSubPath) &&
                Objects.equals(deviceConfig, that.deviceConfig) &&
                Objects.equals(urlConfig, that.urlConfig) &&
                Objects.equals(fullPathOfReportDir, that.fullPathOfReportDir) &&
                Objects.equals(originalUrl, that.originalUrl);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        ScreenshotContext that = (ScreenshotContext) o;
        return dontShareBrowser == that.dontShareBrowser && Objects.equals(url, that.url) && Objects.equals(urlSubPath, that.urlSubPath) && Objects.equals(deviceConfig, that.deviceConfig) && Objects.equals(cookies, that.cookies) && step == that.step && Objects.equals(urlConfig, that.urlConfig) && Objects.equals(fullPathOfReportDir, that.fullPathOfReportDir) && Objects.equals(originalUrl, that.originalUrl);
    }

    @Override
    public int hashCode() {
        return Objects.hash(url, urlSubPath, deviceConfig, cookies, step, urlConfig, fullPathOfReportDir, dontShareBrowser, originalUrl);
    }

    @Override
    public String toString() {
        return "ScreenshotContext{" +
                "url='" + url + '\'' +
                ", urlSubPath='" + urlSubPath + '\'' +
                ", deviceConfig=" + deviceConfig +
                ", cookies=" + cookies +
                ", step=" + step +
                ", urlConfig=" + urlConfig +
                ", fullPathOfReportDir='" + fullPathOfReportDir + '\'' +
                ", dontShareBrowser=" + dontShareBrowser +
                ", originalUrl='" + originalUrl + '\'' +
                '}';
    }

    public static final class Builder {
        private String url;
        private String urlSubPath;
        private DeviceConfig deviceConfig;
        private List<Cookie> cookies = Collections.emptyList();
        private Step step;
        private UrlConfig urlConfig;
        private String fullPathOfReportDir;
        private boolean dontShareBrowser;
        private String originalUrl;

        private Builder() {
        }

        public Builder withUrl(String val) {
            url = val;
            return this;
        }

        public Builder withUrlSubPath(String val) {
            urlSubPath = val;
            return this;
        }

        public Builder withDeviceConfig(DeviceConfig val) {
            deviceConfig = val;
            return this;
        }

        public Builder withCookies(List<Cookie> val) {
            cookies = val;
            return this;
        }

        public Builder withStep(Step val) {
            step = val;
            return this;
        }

        public Builder withUrlConfig(UrlConfig val) {
            urlConfig = val;
            return this;
        }

        public Builder withFullPathOfReportDir(String val) {
            fullPathOfReportDir = val;
            return this;
        }

        public Builder withDontShareBrowser(boolean val) {
            dontShareBrowser = val;
            return this;
        }

        public Builder withOriginalUrl(String val) {
            originalUrl = val;
            return this;
        }

        public ScreenshotContext build() {
            return new ScreenshotContext(this);
        }
    }
} @JsonDeserialize(builder = DeviceConfig.Builder.class)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
//@JsonNaming(PropertyNamingStrategy.KebabCaseStrategy.class)
public class DeviceConfig  {

    static final String DESKTOP_DEVICE_NAME = "DESKTOP";
    static final String MOBILE_DEVICE_NAME = "MOBILE";
    private static final String DEFAULT_DEVICE_NAME = DESKTOP_DEVICE_NAME;
    private static final String DEFAULT_USER_AGENT = null;
    private static final boolean DEFAULT_TOUCH_OPTION = false;

    //
    // See http://chromedriver.chromium.org/mobile-emulation for information about Chrome mobile emulation
    //

    public final int width;

    public final int height;

    @JsonAlias("pixelRatio")
    public final float pixelRatio;

    @JsonAlias("deviceName")
    public final String deviceName;

    @JsonAlias("userAgent")
    public final String userAgent;

    public final boolean touch;

    public DeviceConfig() {
        deviceName = DEFAULT_DEVICE_NAME;
        width = DEFAULT_WINDOW_WIDTH;
        height = DEFAULT_WINDOW_HEIGHT;
        pixelRatio = DEFAULT_PIXEL_RATIO;
        touch = DEFAULT_TOUCH_OPTION;
        userAgent = DEFAULT_USER_AGENT;
    }

    private DeviceConfig(int width, int height, float pixelRatio, String deviceName, String userAgent, boolean touch) {
        this.width = width;
        this.height = height;
        this.pixelRatio = pixelRatio;
        this.deviceName = deviceName;
        this.userAgent = userAgent;
        this.touch = touch;
    }

    private DeviceConfig(Builder builder) {
        width = builder.width;
        height = builder.height;
        pixelRatio = builder.pixelRatio;
        deviceName = builder.deviceName;
        userAgent = builder.userAgent;
        touch = builder.touch;
    }

    public static DeviceConfig deviceConfig(int width, int height) {
        return DeviceConfig.deviceConfigBuilder().withWidth(width).withHeight(height).build();
    }

    /*
     *
     *
     *
     *  BEGIN of getters block
     *
     *  For GraalVM (JSON is empty if no getters are here)
     *
     *
     *
     */

    public int getWidth() {
        return width;
    }

    public int getHeight() {
        return height;
    }

    public float getPixelRatio() {
        return pixelRatio;
    }

    public String getDeviceName() {
        return deviceName;
    }

    public String getUserAgent() {
        return userAgent;
    }
////@JsonNaming(PropertyNamingStrategy.KebabCaseStrategy.class)
    public boolean isTouch() {
        return touch;
    }

    /*
     *
     *
     *
     *  END of getters block
     *
     *  For GraalVM (JSON is empty if no getters are here)
     *
     *
     *
     */

    public static Builder deviceConfigBuilder() {
        return new Builder();
    }

    @JsonIgnore
    public boolean isDesktop() {
        return DESKTOP_DEVICE_NAME.equalsIgnoreCase(deviceName.trim());
    }

    @JsonIgnore
    public boolean isMobile() {
        return !DESKTOP_DEVICE_NAME.equalsIgnoreCase(deviceName.trim());
    }

    @JsonIgnore
    public boolean isGenericMobile() {
        return MOBILE_DEVICE_NAME.equalsIgnoreCase(deviceName.trim());
    }

    public static final class Builder {
        private int width = DEFAULT_WINDOW_WIDTH;
        private int height = DEFAULT_WINDOW_HEIGHT;
        private float pixelRatio = DEFAULT_PIXEL_RATIO;
        private String deviceName = DEFAULT_DEVICE_NAME;
        private String userAgent = DEFAULT_USER_AGENT;
        private boolean touch = DEFAULT_TOUCH_OPTION;

        private Builder() {
        }

        public Builder withWidth(int val) {
            width = val;
            return this;
        }

        public Builder withHeight(int val) {
            height = val;
            return this;
        }

        @JsonAlias("pixelRatio")
        public Builder withPixelRatio(float val) {
            pixelRatio = val;
            return this;
        }

        @JsonAlias("deviceName")
        public Builder withDeviceName(String val) {
            deviceName = val;
            return this;
        }

        @JsonAlias("userAgent")
        public Builder withUserAgent(String val) {
            userAgent = val;
            return this;
        }

        public Builder withTouch(boolean val) {
            touch = val;
            return this;
        }

        public DeviceConfig build() {
            return new DeviceConfig(this);
        }
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        DeviceConfig that = (DeviceConfig) o;
        return width == that.width &&
                height == that.height &&
                Float.compare(that.pixelRatio, pixelRatio) == 0 &&
                touch == that.touch &&
                Objects.equals(deviceName, that.deviceName) &&
                Objects.equals(userAgent, that.userAgent);
    }

    @Override
    public int hashCode() {
        return Objects.hash(width, height, pixelRatio, deviceName, userAgent, touch);
    }

    @Override
    public String toString() {
        return "DeviceConfig{" +
                "width=" + width +
                ", height=" + height +
                ", pixelRatio=" + pixelRatio +
                ", deviceName='" + deviceName + '\'' +
                ", userAgent='" + userAgent + '\'' +
                ", touch=" + touch +
                '}';
    }
} @JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@JsonDeserialize(builder = JobConfig.Builder.class)
//@JsonNaming(PropertyNamingStrategy.KebabCaseStrategy.class)
public final class JobConfig  {

    static final String LINEUP_CONFIG_DEFAULT_PATH = "./lineup.json";
    static final String EXAMPLE_URL = "https://www.example.com";

    public static final int DEFAULT_WARMUP_BROWSER_CACHE_TIME = 0;
    public static final int DEFAULT_REPORT_FORMAT = 2;

    static final Browser.Type DEFAULT_BROWSER = Browser.Type.CHROME_HEADLESS;
    static final float DEFAULT_MAX_DIFF = 0;
    static final int DEFAULT_WINDOW_WIDTH = 800;
    public static final int DEFAULT_WINDOW_HEIGHT = 800;
    static final float DEFAULT_PIXEL_RATIO = 1.0f;
    static final float DEFAULT_GLOBAL_WAIT_AFTER_PAGE_LOAD = 0f;
    public static final String DEFAULT_PATH = "";
    static final int DEFAULT_MAX_SCROLL_HEIGHT = 100000;
    static final float DEFAULT_WAIT_AFTER_PAGE_LOAD = 0;
    static final float DEFAULT_WAIT_AFTER_SCROLL = 0;
    static final float DEFAULT_WAIT_FOR_NO_ANIMATION_AFTER_SCROLL = 0;
    static final float DEFAULT_WAIT_FOR_FONTS_TIME = 0;
    public static final float DEFAULT_MAX_COLOR_DISTANCE = 2.3f;
    static final int DEFAULT_THREADS = 0; // '0' means not set which is transformed to '1' when creating the threadpool
    static final int DEFAULT_PAGELOAD_TIMEOUT = 120;
    static final int DEFAULT_SCREENSHOT_RETRIES = 0;
    static final int DEFAULT_GLOBAL_TIMEOUT = 600;
    public static final float DEFAULT_WAIT_FOR_SELECTORS_TIMEOUT = 10.0f;

    public final Map<String, UrlConfig> urls;
    public final Browser.Type browser;

    @JsonInclude(Include.NON_DEFAULT)
    public final String name;

    @JsonProperty("wait-after-page-load")
    @JsonAlias({"async-wait"})
    public final Float globalWaitAfterPageLoad;
    public final int pageLoadTimeout;
    public final Integer windowHeight;
    @JsonInclude(value = Include.CUSTOM, valueFilter = ReportFormatFilter.class)
    public final Integer reportFormat;
    @JsonInclude(Include.NON_DEFAULT)
    public final int screenshotRetries;
    @JsonInclude(Include.NON_DEFAULT)
    public final int threads;
    @JsonProperty("timeout")
    public final int globalTimeout;
    public final boolean debug;
    @JsonInclude(Include.NON_DEFAULT)
    public final boolean logToFile;
    public final boolean checkForErrorsInLog;
    @JsonInclude(value = Include.CUSTOM, valueFilter = HttpCheckFilter.class)
    public final HttpCheckConfig httpCheck;

    public JobConfig() {
        this(jobConfigBuilder());
    }

    private JobConfig(Builder builder) {
        urls = builder.urls;
        browser = builder.browser;
        globalWaitAfterPageLoad = builder.globalWaitAfterPageLoad;
        pageLoadTimeout = builder.pageLoadTimeout;
        windowHeight = builder.windowHeight;
        threads = builder.threads;
        screenshotRetries = builder.screenshotRetries;
        reportFormat = builder.reportFormat;
        globalTimeout = builder.globalTimeout;
        debug = builder.debug;
        logToFile = builder.logToFile;
        checkForErrorsInLog = builder.checkForErrorsInLog;
        httpCheck = builder.httpCheck;
        name = builder.name;
    }

    public static String prettyPrint(JobConfig jobConfig) {
        return JacksonWrapper.serializeObject(jobConfig);
    }

    public static String prettyPrintWithAllFields(JobConfig jobConfig) {
        ObjectMapper objectMapper = JsonMapper.builder()
                .configure(JsonReadFeature.ALLOW_TRAILING_COMMA, true)
                .configure(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS, true)
                .configure(ACCEPT_CASE_INSENSITIVE_ENUMS, true)
                .configure(ALLOW_COMMENTS, true)
                .configure(INDENT_OUTPUT, true)
                .build();
        objectMapper.setPropertyNamingStrategy(PropertyNamingStrategies.KEBAB_CASE);
        objectMapper.addMixIn(JobConfig.class, JobConfigMixIn.class);
        objectMapper.addMixIn(UrlConfig.class, UrlConfigMixIn.class);
        try {
            return objectMapper.writeValueAsString(jobConfig);
        } catch (JsonProcessingException e) {
            throw new RuntimeException("There is a problem while writing the " + jobConfig.getClass().getCanonicalName() + " with Jackson.", e);
        }
    }

    /*
     *
     *
     *
     *  BEGIN of getters block
     *
     *  For GraalVM (JSON is empty if no getters are here)
     *
     *
     *
     */

    public Map<String, UrlConfig> getUrls() {
        return urls;
    }

    public Browser.Type getBrowser() {
        return browser;
    }

    public String getName() {
        return name;
    }

    public Float getGlobalWaitAfterPageLoad() {
        return globalWaitAfterPageLoad;
    }

    public int getPageLoadTimeout() {
        return pageLoadTimeout;
    }

    public Integer getWindowHeight() {
        return windowHeight;
    }

    public Integer getReportFormat() {
        return reportFormat;
    }

    public int getScreenshotRetries() {
        return screenshotRetries;
    }

    public int getThreads() {
        return threads;
    }

    public int getGlobalTimeout() {
        return globalTimeout;
    }

    public boolean isDebug() {
        return debug;
    }

    public boolean isLogToFile() {
        return logToFile;
    }

    public boolean isCheckForErrorsInLog() {
        return checkForErrorsInLog;
    }

    public HttpCheckConfig getHttpCheck() {
        return httpCheck;
    }

    /*
     *
     *
     *
     *  END of getters block
     *
     *  For GraalVM (JSON is empty if no getters are here)
     *
     *
     *
     */

    public static Builder copyOfBuilder(JobConfig jobConfig) {
        return jobConfigBuilder()
                .withName(jobConfig.name)
                .withUrls(jobConfig.urls)
                .withHttpCheck(jobConfig.httpCheck)
                .withBrowser(jobConfig.browser)
                .withGlobalWaitAfterPageLoad(jobConfig.globalWaitAfterPageLoad)
                .withPageLoadTimeout(jobConfig.pageLoadTimeout)
                .withWindowHeight(jobConfig.windowHeight)
                .withThreads(jobConfig.threads)
                .withScreenshotRetries(jobConfig.screenshotRetries)
                .withReportFormat(jobConfig.reportFormat)
                .withGlobalTimeout(jobConfig.globalTimeout)
                .withDebug(jobConfig.debug)
                .withLogToFile(jobConfig.logToFile)
                .withCheckForErrorsInLog(jobConfig.checkForErrorsInLog);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        JobConfig jobConfig = (JobConfig) o;
        return pageLoadTimeout == jobConfig.pageLoadTimeout &&
                screenshotRetries == jobConfig.screenshotRetries &&
                threads == jobConfig.threads &&
                globalTimeout == jobConfig.globalTimeout &&
                debug == jobConfig.debug &&
                logToFile == jobConfig.logToFile &&
                checkForErrorsInLog == jobConfig.checkForErrorsInLog &&
                Objects.equals(urls, jobConfig.urls) &&
                browser == jobConfig.browser &&
                Objects.equals(name, jobConfig.name) &&
                Objects.equals(globalWaitAfterPageLoad, jobConfig.globalWaitAfterPageLoad) &&
                Objects.equals(windowHeight, jobConfig.windowHeight) &&
                Objects.equals(reportFormat, jobConfig.reportFormat) &&
                Objects.equals(httpCheck, jobConfig.httpCheck);
    }

    @Override
    public int hashCode() {
        return Objects.hash(urls, browser, name, globalWaitAfterPageLoad, pageLoadTimeout, windowHeight, reportFormat, screenshotRetries, threads, globalTimeout, debug, logToFile, checkForErrorsInLog, httpCheck);
    }

    @Override
    public String toString() {
        return "JobConfig{" +
                "urls=" + urls +
                ", browser=" + browser +
                ", name='" + name + '\'' +
                ", globalWaitAfterPageLoad=" + globalWaitAfterPageLoad +
                ", pageLoadTimeout=" + pageLoadTimeout +
                ", windowHeight=" + windowHeight +
                ", reportFormat=" + reportFormat +
                ", screenshotRetries=" + screenshotRetries +
                ", threads=" + threads +
                ", globalTimeout=" + globalTimeout +
                ", debug=" + debug +
                ", logToFile=" + logToFile +
                ", checkForErrorsInLog=" + checkForErrorsInLog +
                ", httpCheck=" + httpCheck +
                '}';
    }

    public static JobConfig defaultConfig() {
        return defaultConfig(EXAMPLE_URL);
    }

    public static JobConfig defaultConfig(String url) {
        return jobConfigBuilder().withName("Default").withUrls(ImmutableMap.of(url, urlConfigBuilder().build())).build();
    }

    public static Builder jobConfigBuilder() {
        return new Builder();
    }

    public static JobConfig exampleConfig() {
        return exampleConfigBuilder().build();
    }

    public static JobConfig.Builder exampleConfigBuilder() {
        return jobConfigBuilder()
                .withName("Example")
                .withCheckForErrorsInLog(true)
                .withUrls(ImmutableMap.of("http://www.example.com",

                        urlConfigBuilder()
                                .withPaths(ImmutableList.of("/"))
                                .withCookies(ImmutableList.of(
                                        new Cookie("exampleCookieName", "exampleValue", "www.example.com", "/", new Date(1000L), false, false)
                                ))
                                .withEnvMapping(ImmutableMap.of("live", "www"))
                                .withLocalStorage(ImmutableMap.of("exampleLocalStorageKey", "value"))
                                .withSessionStorage(ImmutableMap.of("exampleSessionStorageKey", "value"))
                                .withDevices(ImmutableList.of(deviceConfig(850,600), deviceConfig(1000, 850), deviceConfig(1200, 1000)))
                                .withJavaScript("console.log('This is JavaScript!')")
                                .withHttpCheck(new HttpCheckConfig(true))
                                .withStrictColorComparison(false)
                                .withRemoveSelectors(ImmutableSet.of("#removeNodeWithThisId", ".removeNodesWithThisClass"))
                                .withWaitForSelectors(ImmutableSet.of("h1"))
                                .withFailIfSelectorsNotFound(false)
                                .build())
                );
    }


    public static JobConfig readConfig(final String workingDir, final String configFileName) throws IOException {
        List<String> searchPaths = new ArrayList<>();
        Path configFilePath = Paths.get(workingDir + "/" + configFileName);
        searchPaths.add(configFilePath.toString());
        if (!Files.exists(configFilePath)) {
            configFilePath = Paths.get(configFileName);
            searchPaths.add(configFilePath.toString());
            if (!Files.exists(configFilePath)) {
                configFilePath = Paths.get(LINEUP_CONFIG_DEFAULT_PATH);
                searchPaths.add(configFilePath.toString());
                if (!Files.exists(configFilePath)) {
                    String cwd = Paths.get(".").toAbsolutePath().normalize().toString();
                    throw new FileNotFoundException("JobConfig file not found. Search locations were: " + Arrays.toString(searchPaths.toArray()) + " - working dir: " + cwd);
                }
            }
        }
        BufferedReader br = new BufferedReader(new FileReader(configFilePath.toString()));
        return JacksonWrapper.deserializeConfig(br);
    }

    public static final class Builder {
        private String name = null;
        private Map<String, UrlConfig> urls = null;
        private Browser.Type browser = DEFAULT_BROWSER;
        private float globalWaitAfterPageLoad = DEFAULT_GLOBAL_WAIT_AFTER_PAGE_LOAD;
        private int pageLoadTimeout = DEFAULT_PAGELOAD_TIMEOUT;
        private int windowHeight = DEFAULT_WINDOW_HEIGHT;
        private int reportFormat = DEFAULT_REPORT_FORMAT;
        private int screenshotRetries = DEFAULT_SCREENSHOT_RETRIES;
        private int threads = DEFAULT_THREADS;
        private int globalTimeout = DEFAULT_GLOBAL_TIMEOUT;
        private boolean debug = false;
        private boolean logToFile = false;
        private boolean checkForErrorsInLog = false;
        private HttpCheckConfig httpCheck = new HttpCheckConfig();

        private Builder() {
        }

        public Builder withName(String val) {
            name = val;
            return this;
        }

        public Builder withUrls(Map<String, UrlConfig> val) {
            urls = val;
            return this;
        }

        public Builder withBrowser(Browser.Type val) {
            browser = val;
            return this;
        }

        @JsonProperty("wait-after-page-load")
        @JsonAlias({"async-wait"})
        public Builder withGlobalWaitAfterPageLoad(float val) {
            globalWaitAfterPageLoad = val;
            return this;
        }

        public Builder withPageLoadTimeout(int val) {
            pageLoadTimeout = val;
            return this;
        }

        public Builder withWindowHeight(int val) {
            windowHeight = val;
            return this;
        }

        @JsonInclude(value = Include.CUSTOM, valueFilter = ReportFormatFilter.class)
        public Builder withReportFormat(int val) {
            reportFormat = val;
            return this;
        }

        public Builder withScreenshotRetries(int val) {
            screenshotRetries = val;
            return this;
        }

        public Builder withThreads(int val) {
            threads = val;
            return this;
        }

        public Builder withDebug(boolean val) {
            debug = val;
            return this;
        }

        public Builder withLogToFile(boolean val) {
            logToFile = val;
            return this;
        }

        @JsonProperty("timeout")
        public Builder withGlobalTimeout(int val) {
            globalTimeout = val;
            return this;
        }

        public Builder withCheckForErrorsInLog(boolean val) {
            checkForErrorsInLog = val;
            return this;
        }

        @JsonInclude(value = Include.CUSTOM, valueFilter = HttpCheckFilter.class)
        public Builder withHttpCheck(HttpCheckConfig val) {
            httpCheck = val;
            return this;
        }

        public JobConfig build() {
            return new JobConfig(this);
        }

        public Builder addUrlConfig(String url, UrlConfig urlConfig) {
            if (urls == null) {
                urls = ImmutableMap.of(url, urlConfig);
            }
            else {
                urls = ImmutableMap.<String, UrlConfig>builder().putAll(urls).put(url, urlConfig).build();
            }
            return this;
        }
    }
} public enum Step {
    before,
    after,
    compare
} public class FileService {

    private final static Logger LOG = LoggerFactory.getLogger(lookup().lookupClass());

    public final static String FILE_SEPARATOR = FileSystems.getDefault().getSeparator();

    public static final String DIVIDER = "_";
    public static final String PNG_EXTENSION = ".png";
    private static final int MAX_URL_TO_FILENAME_LENGTH = 180;

    public static final String FILETRACKER_FILENAME = "files.json";
    public static final String REPORT_HTML_FILENAME = "report.html";
    public static final String LEGACY_REPORT_HTML_FILENAME = "report_legacy.html";
    public static final String REPORT_JSON_FILENAME = "report.json";

    private final RunStepConfig runStepConfig;

    public FileTracker getFileTracker() {
        return fileTracker;
    }

    public ScreenshotContext getRecordedContext(int hash) {
        return fileTracker.getScreenshotContextFileTracker(hash).screenshotContext;
    }

    private final FileTracker fileTracker;

    public FileService(RunStepConfig runStepConfig, JobConfig jobConfig) {
        this.runStepConfig = runStepConfig;
        if (runStepConfig.getStep() == Step.before) {
            this.fileTracker = FileTracker.create(jobConfig);
        } else {
            Path path = Paths.get(runStepConfig.getWorkingDirectory(), runStepConfig.getReportDirectory(), FILETRACKER_FILENAME);
            this.fileTracker = JacksonWrapper.readFileTrackerFile(path.toFile());
        }
    }

    @VisibleForTesting
    Path createDirIfNotExists(String dirPath) throws IOException {
        Path path = Paths.get(dirPath);
        Files.createDirectories(path);
        return path;
    }


    public void createOrClearReportDirectory() throws IOException {
        createOrClearDirectoryBelowWorkingDir(runStepConfig.getWorkingDirectory(), runStepConfig.getReportDirectory());
    }

    @VisibleForTesting
    Path getScreenshotDirectory() {
        return Paths.get(String.format("%s/%s", runStepConfig.getWorkingDirectory(), runStepConfig.getScreenshotsDirectory()));
    }

    private Path getReportDirectory() {
        return Paths.get(String.format("%s/%s", runStepConfig.getWorkingDirectory(), runStepConfig.getReportDirectory()));
    }

    public void createWorkingDirectoryIfNotExists() throws IOException {
        try {
            createDirIfNotExists(runStepConfig.getWorkingDirectory());
        } catch (IOException e) {
            throw new IOException("Could not create or open working directory.", e);
        }
    }

    public void createOrClearScreenshotsDirectory() throws IOException {
        createOrClearDirectoryBelowWorkingDir(runStepConfig.getWorkingDirectory(), runStepConfig.getScreenshotsDirectory());
    }

    private void createOrClearDirectoryBelowWorkingDir(String workingDirectory, String subDirectory) throws IOException {
        try {
            final String subDirectoryPath = workingDirectory + FILE_SEPARATOR + subDirectory;
            createDirIfNotExists(subDirectoryPath);
            clearDirectory(subDirectoryPath);
        } catch (IOException e) {
            throw new IOException("Could not create or open " + subDirectory + " directory.", e);
        }
    }

    @VisibleForTesting
    String generateScreenshotFileName(String url, String urlSubPath, int width, int yPosition, String type) {

        String fileName = generateScreenshotFileNamePrefix(url, urlSubPath)
                + String.format("%04d", width)
                + DIVIDER
                + String.format("%05d", yPosition)
                + DIVIDER
                + type;

        fileName = fileName + PNG_EXTENSION;

        return fileName;
    }

    private String generateScreenshotFileNamePrefix(String url, String urlSubPath) {

        @SuppressWarnings("deprecation") String hash = Hashing.sha1().hashString(url + urlSubPath, Charsets.UTF_8).toString().substring(0, 7);

        if (urlSubPath.equals("/") || urlSubPath.equals("")) {
            urlSubPath = "root";
        }
        if (url.endsWith("/")) {
            url = url.substring(0, url.length() - 1);
        }

        String fileNamePrefix = url + DIVIDER + urlSubPath + DIVIDER;
        fileNamePrefix = fileNamePrefix.replace("://", DIVIDER);
        fileNamePrefix = fileNamePrefix.replaceAll("[^A-Za-z0-9\\-_]", DIVIDER);

        if (fileNamePrefix.length() > MAX_URL_TO_FILENAME_LENGTH) {
            fileNamePrefix = fileNamePrefix.substring(0, MAX_URL_TO_FILENAME_LENGTH) + DIVIDER;
        }

        fileNamePrefix = fileNamePrefix + hash + DIVIDER;

        return fileNamePrefix;
    }

    @VisibleForTesting
    String getScreenshotPath(String url, String urlSubPath, int width, int yPosition, String step) {
        return runStepConfig.getWorkingDirectory() + (runStepConfig.getWorkingDirectory().endsWith(FILE_SEPARATOR) ? "" : FILE_SEPARATOR)
                + runStepConfig.getScreenshotsDirectory() + (runStepConfig.getScreenshotsDirectory().endsWith(FILE_SEPARATOR) ? "" : FILE_SEPARATOR)
                + generateScreenshotFileName(url, urlSubPath, width, yPosition, step);
    }

    private String getScreenshotPath(String fileName) {
        return runStepConfig.getWorkingDirectory() + (runStepConfig.getWorkingDirectory().endsWith(FILE_SEPARATOR) ? "" : FILE_SEPARATOR)
                + runStepConfig.getScreenshotsDirectory() + (runStepConfig.getScreenshotsDirectory().endsWith(FILE_SEPARATOR) ? "" : FILE_SEPARATOR)
                + fileName;
    }

    public BufferedImage readScreenshot(String fileName) throws IOException {
        return ImageIO.read(new File(getScreenshotPath(fileName)));
    }

    private static void writeScreenshot(String fileName, BufferedImage image) throws IOException {
        LOG.debug("Writing screenshot to {}", fileName);
        File screenshotFile = new File(fileName);
        ImageIO.write(image, "png", screenshotFile);
    }

    public String writeScreenshot(ScreenshotContext screenshotContext, BufferedImage image,
                                  int yPosition) throws IOException {

        createDirIfNotExists(getScreenshotDirectory().toString() + FILE_SEPARATOR + screenshotContext.contextHash());
        String fileName = getScreenshotFilenameBelowScreenshotsDir(screenshotContext, yPosition);
        writeScreenshot(Paths.get(getScreenshotDirectory().toString(), fileName).toString(), image);
        fileTracker.addScreenshot(screenshotContext, fileName, yPosition);
        return fileName;
    }

    private String getScreenshotFilenameBelowScreenshotsDir(ScreenshotContext screenshotContext, int yPosition) {
        return screenshotContext.contextHash() + FILE_SEPARATOR + generateScreenshotFileName(screenshotContext.url, screenshotContext.urlSubPath, screenshotContext.deviceConfig.width, yPosition, screenshotContext.step.name());
    }

    public String getRelativePathFromReportDirToScreenshotsDir() {
        Path screenshotDirectory = getScreenshotDirectory().toAbsolutePath();
        Path reportDirectory = getReportDirectory().toAbsolutePath();
        Path relative = reportDirectory.relativize(screenshotDirectory);
        return relative.toString().equals("") ? "" : relative.toString() + FILE_SEPARATOR;
    }

    public void writeJsonReport(String reportJson) throws FileNotFoundException {
        try (PrintStream out = new PrintStream(new FileOutputStream(getReportDirectory().toString() + FILE_SEPARATOR + REPORT_JSON_FILENAME))) {
            out.print(reportJson);
        }
    }

    public void writeHtmlReport(String htmlReport) throws FileNotFoundException {
        writeHtmlReport(htmlReport, LEGACY_REPORT_HTML_FILENAME);
    }

    public void writeHtmlReport(String htmlReport, String filename) throws FileNotFoundException {
        try (PrintStream out = new PrintStream(new FileOutputStream(getReportDirectory().toString() + FILE_SEPARATOR + filename))) {
            out.print(htmlReport);
        }
    }

    public void writeHtml(String html, Step step) throws FileNotFoundException {
        try (PrintStream out = new PrintStream(new FileOutputStream(getReportDirectory().toString() + FILE_SEPARATOR + step + ".html"))) {
            out.print(html);
        }
    }

    public void writeFileTrackerData() throws IOException {
        Path path = Paths.get(getReportDirectory().toString(), FILETRACKER_FILENAME);
        Files.write(path, JacksonWrapper.serializeObject(fileTracker).getBytes());
    }

    public void setBrowserAndVersion(ScreenshotContext screenshotContext, String browserAndVersion) {
        fileTracker.setBrowserAndVersion(screenshotContext, browserAndVersion);
    }

    public Map<Step, String> getBrowsers() {
        return fileTracker.browsers;
    }
} public class FileUtils {


    public static void clearDirectory(String path) throws IOException {
        Path directory = Paths.get(path);
        try(DirectoryStream<Path> paths = Files.newDirectoryStream(directory);) {
            paths.forEach(file -> {
                try {
                    if (Files.isDirectory(file)) {
                        clearDirectory(file.toAbsolutePath().toString());
                    }

                    Files.delete(file);
                } catch (IOException e) {
                    throw new UncheckedIOException(e);
                }
            });
        }
    }

    public static void deleteDirectory(Path path) throws IOException {
        clearDirectory(path.toString());
        Files.deleteIfExists(path);
    }

    public static void deleteDirectory(String path) throws IOException {
        deleteDirectory(Paths.get(path));
    }

} public class ImageService {

    public static final int SAME_COLOR = Color.BLACK.getRGB();
    public static final int HIGHLIGHT_COLOR = Color.WHITE.getRGB();
    public static final int DIFFERENT_SIZE_COLOR = Color.GRAY.getRGB();
    public static final int LOOK_SAME_COLOR = Color.BLUE.getRGB();
    public static final int ANTI_ALIAS_DETECTED_COLOR = Color.GREEN.getRGB();

    public static class ImageComparisonResult {
        private final BufferedImage differenceImage;
        private final double difference;
        private final int acceptedDifferentPixels;

        public ImageComparisonResult(BufferedImage differenceImage, double difference, int acceptedDifferentPixels) {
            this.differenceImage = differenceImage;
            this.difference = difference;
            this.acceptedDifferentPixels = acceptedDifferentPixels;
        }

        public Optional<BufferedImage> getDifferenceImage() {
            return Optional.ofNullable(differenceImage);
        }

        public double getDifference() {
            return difference;
        }

        public int getAcceptedDifferentPixels() {
            return acceptedDifferentPixels;
        }
    }

    public ImageComparisonResult compareImages(BufferedImage image1, BufferedImage image2, int viewportHeight) {
        return this.compareImages(image1, image2, viewportHeight, false, true, JobConfig.DEFAULT_MAX_COLOR_DISTANCE);
    }

    public ImageComparisonResult compareImages(BufferedImage image1, BufferedImage image2, int viewportHeight, boolean ignoreAntiAliased, boolean strictColorComparison, float maxColorDistance) {

        if (image1 == null || image2 == null) throw new NullPointerException("Can't compare null imagebuffers");

        if (bufferedImagesEqualQuick(image1, image2)) {
            return new ImageComparisonResult(null, 0, 0);
        }

        // cache image widths and heights
        final int width1 = image1.getWidth();
        final int height1 = image1.getHeight();
        final int width2 = image2.getWidth();
        final int height2 = image2.getHeight();

        // calculate max dimensions
        final int maxWidth = max(image1.getWidth(), image2.getWidth());
        final int maxHeight = max(image1.getHeight(), image2.getHeight());

        // calculate min width
        final int minWidth = min(image1.getWidth(), image2.getWidth());

        // convert images to pixel arrays
        final int[] image1Pixels = image1.getRGB(0, 0, width1, height1, null, 0, width1);
        final int[] image2Pixels = image2.getRGB(0, 0, width2, height2, null, 0, width2);

        // calculate pixel counts of img1 and img2
        final int pixelCount1 = width1 * height1;
        final int pixelCount2 = width2 * height2;

        // calculate pixel count min and max
        final int maxPixelCount = maxWidth * maxHeight;
        final int minPixelCount = min(pixelCount1, pixelCount2);

        // compare img1 to img2, pixel by pixel. If different, highlight differenceSum image pixel
        int diffPixelCounter = 0;
        int antiAliasedDiffPixelCounter = 0;
        int lookSameDiffPixelCounter = 0;
        float maxDetectedColorDistance = 0f;

        final int[] differenceImagePixels = new int[maxPixelCount];
        //i1 and i2 are the indices in the image pixel arrays of image1pixels and image2pixels
        //iD is the index of the differenceSum image
        for (int i1 = 0, i2 = 0, iD = 0, x = 0, y = 0; iD < maxPixelCount; ) {
            //mark same pixels with same_color and different pixels in highlight_colors
            if (image1Pixels[i1] != image2Pixels[i2]) {
                if (!strictColorComparison && doColorsLookSame(image1Pixels[i1], image2Pixels[i2], maxColorDistance)) {
                    differenceImagePixels[iD] = LOOK_SAME_COLOR;
                    lookSameDiffPixelCounter++;
                } else if (ignoreAntiAliased && AntiAliasingIgnoringComparator.checkIsAntialiased(image1, image2, x, y)) {
                    differenceImagePixels[iD] = ANTI_ALIAS_DETECTED_COLOR;
                    antiAliasedDiffPixelCounter++;
                } else {
                    differenceImagePixels[iD] = HIGHLIGHT_COLOR;
                    diffPixelCounter++;
                }
            } else {
                differenceImagePixels[iD] = SAME_COLOR;
            }

            //advance all indices
            i1++;
            i2++;
            iD++;

            //Just calc x and y pos of image1 for anti alias comparison
            if (i1 % width1 == 0) {
                x = 0;
                y++;
            } else {
                x++;
            }

            //one of the two images has a smaller width than the other
            //move index of other picture to end of line and mark pixels
            //with different_size_color
            if (width1 < width2 && i1 % minWidth == 0) {
                while (i2 % maxWidth != 0) {
                    i2++;
                    differenceImagePixels[iD] = DIFFERENT_SIZE_COLOR;
                    diffPixelCounter++;
                    iD++;
                }
            } else if (width2 < width1 && i2 % minWidth == 0) {
                while (i1 % maxWidth != 0) {
                    i1++;
                    differenceImagePixels[iD] = DIFFERENT_SIZE_COLOR;
                    diffPixelCounter++;
                    iD++;
                }
            }

            //one of the two pictures is over
            //mark pixels within width of other remaining picture
            //with different_size_color and mark pixels
            //that neither exist in img1 nor in img2 as same_color
            //(both images don't exist in that area, so they are the same there ;))
            if (i1 == minPixelCount || i2 == minPixelCount) {
                while (iD < maxPixelCount) {
                    if (iD % maxWidth < minWidth) {
                        differenceImagePixels[iD] = DIFFERENT_SIZE_COLOR;
                        diffPixelCounter++;
                    } else {
                        differenceImagePixels[iD] = SAME_COLOR;
                    }
                    iD++;
                }
            }
        }

        double difference = (1d * diffPixelCounter) / min(maxPixelCount, maxWidth * viewportHeight);

        // save differenceImagePixels to a new BufferedImage
        final BufferedImage out = new BufferedImage(maxWidth, maxHeight, BufferedImage.TYPE_INT_RGB);
        out.setRGB(0, 0, maxWidth, maxHeight, differenceImagePixels, 0, maxWidth);
        return new ImageComparisonResult(out, difference, lookSameDiffPixelCounter + antiAliasedDiffPixelCounter);
    }

    private static int[] getARGB(int pixel) {

        int alpha = (pixel >> 24) & 0xFF;
        int red = (pixel >> 16) & 0xFF;
        int green = (pixel >> 8) & 0xFF;
        int blue = (pixel) & 0xFF;

        return new int[]{alpha, red, green, blue};
    }

    static Color getColor(int pixel) {
        int[] argb = getARGB(pixel);
        return new Color(argb[1], argb[2], argb[3], argb[0]);
    }

    //Helper function to compare two BufferedImage instances (BufferedImage doesn't override equals())
    public static boolean bufferedImagesEqual(BufferedImage image1, BufferedImage image2) {
        if (image1.getWidth() == image2.getWidth() && image1.getHeight() == image2.getHeight()) {
            for (int xPosition = 0; xPosition < image1.getWidth(); xPosition++) {
                for (int yPosition = 0; yPosition < image1.getHeight(); yPosition++) {
                    if (image1.getRGB(xPosition, yPosition) != image2.getRGB(xPosition, yPosition))
                        return false;
                }
            }
        } else {
            return false;
        }
        return true;
    }

    static boolean doColorsLookSame(int argbColor1, int argbColor2, double maxColorDistance) {
        int[] argb1 = getARGB(argbColor1);
        int[] argb2 = getARGB(argbColor2);
        LAB lab1 = LAB.fromRGB(argb1[1], argb1[2], argb1[3], 0);
        LAB lab2 = LAB.fromRGB(argb2[1], argb2[2], argb2[3], 0);

        double distance = LAB.ciede2000(lab1, lab2);
        return distance <= maxColorDistance;
    }

    //A very fast byte buffer based image comparison for images containing INT or BYTE type representations
    public static boolean bufferedImagesEqualQuick(BufferedImage image1, BufferedImage image2) {
        DataBuffer dataBuffer1 = image1.getRaster().getDataBuffer();
        DataBuffer dataBuffer2 = image2.getRaster().getDataBuffer();
        if (dataBuffer1 instanceof DataBufferByte && dataBuffer2 instanceof DataBufferByte) {
            DataBufferByte dataBufferBytes1 = (DataBufferByte) dataBuffer1;
            DataBufferByte dataBufferBytes2 = (DataBufferByte) dataBuffer2;
            for (int bank = 0; bank < dataBufferBytes1.getNumBanks(); bank++) {
                byte[] bytes1 = dataBufferBytes1.getData(bank);
                byte[] bytes2 = dataBufferBytes2.getData(bank);
                if (!Arrays.equals(bytes1, bytes2)) {
                    return false;
                }
            }
        } else if (dataBuffer1 instanceof DataBufferInt && dataBuffer2 instanceof DataBufferInt) {
            DataBufferInt dataBufferInt1 = (DataBufferInt) dataBuffer1;
            DataBufferInt dataBufferInt2 = (DataBufferInt) dataBuffer2;
            for (int bank = 0; bank < dataBufferInt1.getNumBanks(); bank++) {
                int[] ints1 = dataBufferInt1.getData(bank);
                int[] ints2 = dataBufferInt2.getData(bank);
                if (!Arrays.equals(ints1, ints2)) {
                    return false;
                }
            }
        } else {
            return false;
        }
        return true;
    }


}
import de.otto.jlineup.RunStepConfig;
import de.otto.jlineup.Utils;
import de.otto.jlineup.browser.BrowserUtils;
import de.otto.jlineup.browser.ScreenshotContext;
import de.otto.jlineup.config.DeviceConfig;
import de.otto.jlineup.config.JobConfig;
import de.otto.jlineup.config.Step;
import de.otto.jlineup.file.FileService;
import de.otto.jlineup.file.FileUtils;
import de.otto.jlineup.image.ImageService;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
import java.io.FileNotFoundException;
import java.util.*;
package de.otto.jlineup.report;



public class HTMLReportWriter {

    private final FileService fileService;

    ClassLoaderTemplateResolver templateResolver = new ClassLoaderTemplateResolver();
    TemplateEngine templateEngine = new TemplateEngine();

    public HTMLReportWriter(FileService fileService) {
        this.fileService = fileService;

        templateResolver.setTemplateMode("HTML");
        templateResolver.setPrefix("templates/");
        templateResolver.setSuffix(".html");
        templateEngine = new TemplateEngine();
        templateEngine.setTemplateResolver(templateResolver);
    }

    public void writeReport(Report report) throws FileNotFoundException {
        fileService.writeHtmlReport(renderReport("report", report.config, report.getFlatResultList()));
    }

    public void writeReportV2(ReportV2 report) throws FileNotFoundException {
        fileService.writeHtmlReport(renderReportV2("report_wip", report), FileService.REPORT_HTML_FILENAME);
    }

    public void writeNotFinishedReport(RunStepConfig runStepConfig, JobConfig jobConfig) throws FileNotFoundException {
        fileService.writeHtmlReport(renderNotFinishedReport("report_not_finished", runStepConfig, jobConfig));
    }

    String renderReport(String template, JobConfig config, List<ScreenshotComparisonResult> screenshotComparisonResults) {
        final Map<String, Object> variables = prepareVariablesForReportTemplate(screenshotComparisonResults);
        variables.put("config", config);
        return templateEngine.process(template, new Context(Locale.US, variables));
    }

    private String renderReport(String templateName, Report report) {
        final Map<String, Object> variables = new HashMap<>();
        variables.put("report", report);
        return templateEngine.process(templateName, new Context(Locale.US, variables));
    }

    private String renderReportV2(String templateName, ReportV2 report) {
        final Map<String, Object> variables = new HashMap<>();
        variables.put("report", report);
        enrichVariables(variables);
        return templateEngine.process(templateName, new Context(Locale.US, variables));
    }

    String renderNotFinishedReport(String template, RunStepConfig runStepConfig, JobConfig config) {
        Map<String, Object> variables = new HashMap<>();
        enrichVariables(variables);
        variables.put("config", config);
        variables.put("report_dir", runStepConfig.getReportDirectory());
        variables.put("working_dir", runStepConfig.getWorkingDirectory());
        return templateEngine.process(template, new Context(Locale.US, variables));
    }

    private Map<String, Object> prepareVariablesForReportTemplate(final List<ScreenshotComparisonResult> screenshotComparisonResults) {
        Map<String, Object> variables = new HashMap<>();
        List<ScreenshotComparisonResultContext> screenshotComparisonResultContexts = new LinkedList<>();

        int lastContextKey = 0;
        ScreenshotComparisonResultContext currentContext = null;
        for (ScreenshotComparisonResult screenshotComparisonResult : screenshotComparisonResults) {
            int context = getContextHash(screenshotComparisonResult);
            if (context != lastContextKey) {
                lastContextKey = context;
                currentContext = new ScreenshotComparisonResultContext(screenshotComparisonResult.contextHash);
                screenshotComparisonResultContexts.add(currentContext);
            }
            currentContext.addResult(screenshotComparisonResult);
        }

        variables.put("resultContexts", screenshotComparisonResultContexts);
        enrichVariables(variables);

        return variables;
    }

    private void enrichVariables(Map<String, Object> variables) {
        variables.put("jlineup_version", Utils.readVersion());
        variables.put("jlineup_commit", Utils.readCommit());

        variables.put("legend_same_rgb", "#" + Integer.toHexString(ImageService.SAME_COLOR).substring(2));
        variables.put("legend_look_same_rgb", "#" + Integer.toHexString(ImageService.LOOK_SAME_COLOR).substring(2));
        variables.put("legend_anti_alias_rgb", "#" + Integer.toHexString(ImageService.ANTI_ALIAS_DETECTED_COLOR).substring(2));
        variables.put("legend_different_rgb", "#" + Integer.toHexString(ImageService.HIGHLIGHT_COLOR).substring(2));
        variables.put("legend_different_size_rgb", "#" + Integer.toHexString(ImageService.DIFFERENT_SIZE_COLOR).substring(2));
    }

    private int getContextHash(final ScreenshotComparisonResult screenshotComparisonResult) {
        return screenshotComparisonResult.contextHash;
    }
    private class ScreenshotComparisonResultContext {


        private final List<ScreenshotComparisonResult> results;
        private final ScreenshotContext screenshotContext;
        private final int contextHash;
        private final Map<Step, String> browsers;