'use strict';

const DeepEqual = require('@hapi/hoek/lib/deepEqual');
const Pinpoint = require('@sideway/pinpoint');

const Errors = require('./errors');


const internals = {
    codes: {
        error: 1,
        pass: 2,
        full: 3
    },
    labels: {
        0: 'never used',
        1: 'always error',
        2: 'always pass'
    }
};


exports.setup = function (root) {

    const trace = function () {

        root._tracer = root._tracer || new internals.Tracer();
        return root._tracer;
    };

    root.trace = trace;
    root[Symbol.for('@hapi/lab/coverage/initialize')] = trace;

    root.untrace = () => {

        root._tracer = null;
    };
};


exports.location = function (schema) {

    return schema.$_setFlag('_tracerLocation', Pinpoint.location(2));                       // base.tracer(), caller
};


internals.Tracer = class {

    constructor() {

        this.name = 'Joi';
        this._schemas = new Map();
    }

    _register(schema) {

        const existing = this._schemas.get(schema);
        if (existing) {
            return existing.store;
        }

        const store = new internals.Store(schema);
        const { filename, line } = schema._flags._tracerLocation || Pinpoint.location(5);   // internals.tracer(), internals.entry(), exports.entry(), validate(), caller
        this._schemas.set(schema, { filename, line, store });
        return store;
    }

    _combine(merged, sources) {

        for (const { store } of this._schemas.values()) {
            store._combine(merged, sources);
        }
    }

    report(file) {

        const coverage = [];

        // Process each registered schema

        for (const { filename, line, store } of this._schemas.values()) {
            if (file &&
                file !== filename) {

                continue;
            }

            // Process sub schemas of the registered root

            const missing = [];
            const skipped = [];

            for (const [schema, log] of store._sources.entries()) {

                // Check if sub schema parent skipped

                if (internals.sub(log.paths, skipped)) {
                    continue;
                }

                // Check if sub schema reached

                if (!log.entry) {
                    missing.push({
                        status: 'never reached',
                        paths: [...log.paths]
                    });

                    skipped.push(...log.paths);
                    continue;
                }

                // Check values

                for (const type of ['valid', 'invalid']) {
                    const set = schema[`_${type}s`];
                    if (!set) {
                        continue;
                    }

                    const values = new Set(set._values);
                    const refs = new Set(set._refs);
                    for (const { value, ref } of log[type]) {
                        values.delete(value);
                        refs.delete(ref);
                    }

                    if (values.size ||
                        refs.size) {

                        missing.push({
                            status: [...values, ...[...refs].map((ref) => ref.display)],
                            rule: `${type}s`
                        });
                    }
                }

                // Check rules status

                const rules = schema._rules.map((rule) => rule.name);
                for (const type of ['default', 'failover']) {
                    if (schema._flags[type] !== undefined) {
                        rules.push(type);
                    }
                }

                for (const name of rules) {
                    const status = internals.labels[log.rule[name] || 0];
                    if (status) {
                        const report = { rule: name, status };
                        if (log.paths.size) {
                            report.paths = [...log.paths];
                        }

                        missing.push(report);
                    }
                }
            }

            if (missing.length) {
                coverage.push({
                    filename,
                    line,
                    missing,
                    severity: 'error',
                    message: `Schema missing tests for ${missing.map(internals.message).join(', ')}`
                });
            }
        }

        return coverage.length ? coverage : null;
    }
};


internals.Store = class {

    constructor(schema) {

        this.active = true;
        this._sources = new Map();          // schema -> { paths, entry, rule, valid, invalid }
        this._combos = new Map();           // merged -> [sources]
        this._scan(schema);
    }

    debug(state, source, name, result) {

        state.mainstay.debug && state.mainstay.debug.push({ type: source, name, result, path: state.path });
    }

    entry(schema, state) {

        internals.debug(state, { type: 'entry' });

        this._record(schema, (log) => {

            log.entry = true;
        });
    }

    filter(schema, state, source, value) {

        internals.debug(state, { type: source, ...value });

        this._record(schema, (log) => {

            log[source].add(value);
        });
    }

    log(schema, state, source, name, result) {

        internals.debug(state, { type: source, name, result: result === 'full' ? 'pass' : result });

        this._record(schema, (log) => {

            log[source][name] = log[source][name] || 0;
            log[source][name] |= internals.codes[result];
        });
    }

    resolve(state, ref, to) {

        if (!state.mainstay.debug) {
            return;
        }

        const log = { type: 'resolve', ref: ref.display, to, path: state.path };
        state.mainstay.debug.push(log);
    }

    value(state, by, from, to, name) {

        if (!state.mainstay.debug ||
            DeepEqual(from, to)) {

            return;
        }

        const log = { type: 'value', by, from, to, path: state.path };
        if (name) {
            log.name = name;
        }

        state.mainstay.debug.push(log);
    }

    _record(schema, each) {

        const log = this._sources.get(schema);
        if (log) {
            each(log);
            return;
        }

        const sources = this._combos.get(schema);
        for (const source of sources) {
            this._record(source, each);
        }
    }

    _scan(schema, _path) {

        const path = _path || [];

        let log = this._sources.get(schema);
        if (!log) {
            log = {
                paths: new Set(),
                entry: false,
                rule: {},
                valid: new Set(),
                invalid: new Set()
            };

            this._sources.set(schema, log);
        }

        if (path.length) {
            log.paths.add(path);
        }

        const each = (sub, source) => {

            const subId = internals.id(sub, source);
            this._scan(sub, path.concat(subId));
        };

        schema.$_modify({ each, ref: false });
    }

    _combine(merged, sources) {

        this._combos.set(merged, sources);
    }
};


internals.message = function (item) {

    const path = item.paths ? Errors.path(item.paths[0]) + (item.rule ? ':' : '') : '';
    return `${path}${item.rule || ''} (${item.status})`;
};


internals.id = function (schema, { source, name, path, key }) {

    if (schema._flags.id) {
        return schema._flags.id;
    }

    if (key) {
        return key;
    }

    name = `@${name}`;

    if (source === 'terms') {
        return [name, path[Math.min(path.length - 1, 1)]];
    }

    return name;
};


internals.sub = function (paths, skipped) {

    for (const path of paths) {
        for (const skip of skipped) {
            if (DeepEqual(path.slice(0, skip.length), skip)) {
                return true;
            }
        }
    }

    return false;
};


internals.debug = function (state, event) {

    if (state.mainstay.debug) {
        event.path = state.debug ? [...state.path, state.debug] : state.path;
        state.mainstay.debug.push(event);
    }
};