/**
 * Unit tests for includeResolver graph and cache utilities.
 */

import * as path from "path";
import { isPathWithinRoot, resolveIncludePath } from "../../core/src/includeResolver/graph.js";
import { includeSymbolCache, includeSymbolWarmInFlight } from "../../core/src/includeResolver/cache.js";
import { invalidateIncludeCacheForPath } from "../../core/src/includeResolver/traversal.js";

// ---------------------------------------------------------------------------
// isPathWithinRoot
// ---------------------------------------------------------------------------

describe("isPathWithinRoot", () => {
    const root = path.normalize("/project/workspace");

    test("returns true for the root itself", () => {
        expect(isPathWithinRoot(root, root)).toBe(true);
    });

    test("returns true for direct child", () => {
        expect(isPathWithinRoot(path.join(root, "file.txt"), root)).toBe(true);
    });

    test("returns true for nested child", () => {
        expect(isPathWithinRoot(path.join(root, "sub", "deep", "file.txt"), root)).toBe(true);
    });

    test("returns false for sibling directory", () => {
        expect(isPathWithinRoot(path.normalize("/project/other/file.txt"), root)).toBe(false);
    });

    test("returns false for partial prefix match that is not a child (e.g., /project/workspace2)", () => {
        expect(isPathWithinRoot(path.normalize("/project/workspace2/file.txt"), root)).toBe(false);
    });

    test("returns false for parent directory", () => {
        expect(isPathWithinRoot(path.normalize("/project"), root)).toBe(false);
    });
});

// ---------------------------------------------------------------------------
// resolveIncludePath — absolute path rejection
// ---------------------------------------------------------------------------

describe("resolveIncludePath — absolute path rejection", () => {
    const baseDir = path.normalize("/project/workspace/scripts");

    test("rejects Unix-style absolute include path", () => {
        expect(resolveIncludePath(baseDir, "/etc/passwd")).toBeNull();
    });

    test("rejects Windows-style absolute include path", () => {
        // path.isAbsolute handles both separators on any platform
        expect(resolveIncludePath(baseDir, "C:\\Windows\\system32\\drivers\\etc\\hosts")).toBeNull();
    });

    test("resolves normal relative path (if the file exists in fs — skip resolution here)", () => {
        // We just verify it does NOT return null due to absolute-path check.
        // resolvePathCaseInsensitive will return null because the path doesn't exist on disk,
        // but the function should not reject it due to the absolute-path guard.
        const result = resolveIncludePath(baseDir, "common.txt");
        // null is fine here (file doesn't exist on disk); it must NOT throw
        expect(typeof result === "string" || result === null).toBe(true);
    });

    test("rejects path that resolves outside allowedRoot", () => {
        const allowedRoot = path.normalize("/project/workspace");
        // ../../ traverses above the allowed root
        expect(resolveIncludePath(baseDir, "../../outside.txt", allowedRoot)).toBeNull();
    });

    test("passes boundary check when relative path resolves within allowedRoot", () => {
        // Verify via isPathWithinRoot directly (tested exhaustively above)
        const root = path.normalize("/project/workspace");
        const within = path.join(root, "sub", "file.txt");
        expect(isPathWithinRoot(within, root)).toBe(true);
    });
});

// ---------------------------------------------------------------------------
// invalidateIncludeCacheForPath — cascade invalidation
// ---------------------------------------------------------------------------

describe("invalidateIncludeCacheForPath — cascade invalidation", () => {
    const keyA = path.normalize("/project/a.txt");
    const keyB = path.normalize("/project/b.txt");
    const keyC = path.normalize("/project/c.txt");

    function seedCache() {
        // A depends on nothing
        includeSymbolCache.set(keyA, {
            mtimeMs: 1000, size: 100,
            symbols: { functions: new Set(), variables: new Map(), functionCaseMap: new Map() },
            dependencies: new Set()
        });
        // B depends on A
        includeSymbolCache.set(keyB, {
            mtimeMs: 1001, size: 200,
            symbols: { functions: new Set(), variables: new Map(), functionCaseMap: new Map() },
            dependencies: new Set([keyA])
        });
        // C depends on B (transitively depends on A)
        includeSymbolCache.set(keyC, {
            mtimeMs: 1002, size: 300,
            symbols: { functions: new Set(), variables: new Map(), functionCaseMap: new Map() },
            dependencies: new Set([keyB])
        });
    }

    beforeEach(() => {
        includeSymbolCache.clear();
        includeSymbolWarmInFlight.clear();
    });

    test("invalidating a leaf removes only that entry", () => {
        seedCache();
        invalidateIncludeCacheForPath(keyA);
        expect(includeSymbolCache.has(keyA)).toBe(false);
        // B and C are NOT invalidated — they depend on A but are not currently
        // considered stale by the invalidation (only direct dependents are cascaded)
        // Actually the implementation cascades: B depends on A so B is also invalidated
        // Let's verify the actual cascade behavior:
        expect(includeSymbolCache.has(keyB)).toBe(false); // B -> A, so B is cascaded
        expect(includeSymbolCache.has(keyC)).toBe(false); // C -> B -> A, cascaded twice
    });

    test("invalidating a middle node removes it and its dependents", () => {
        seedCache();
        invalidateIncludeCacheForPath(keyB);
        expect(includeSymbolCache.has(keyB)).toBe(false);
        expect(includeSymbolCache.has(keyC)).toBe(false);
        // A is NOT a dependent of B, so it should remain
        expect(includeSymbolCache.has(keyA)).toBe(true);
    });

    test("invalidating a root node removes only itself when nothing depends on it", () => {
        seedCache();
        invalidateIncludeCacheForPath(keyC);
        expect(includeSymbolCache.has(keyC)).toBe(false);
        // A and B have no dependents pointing to C
        expect(includeSymbolCache.has(keyA)).toBe(true);
        expect(includeSymbolCache.has(keyB)).toBe(true);
    });

    test("handles invalidating a path not in cache (no-op)", () => {
        seedCache();
        expect(() => {
            invalidateIncludeCacheForPath(path.normalize("/project/nonexistent.txt"));
        }).not.toThrow();
        // Cache should be untouched
        expect(includeSymbolCache.has(keyA)).toBe(true);
        expect(includeSymbolCache.has(keyB)).toBe(true);
        expect(includeSymbolCache.has(keyC)).toBe(true);
    });

    test("handles circular dependency without infinite loop", () => {
        // Seed a circular: A <-> B
        includeSymbolCache.set(keyA, {
            mtimeMs: 1000, size: 100,
            symbols: { functions: new Set(), variables: new Map(), functionCaseMap: new Map() },
            dependencies: new Set([keyB])
        });
        includeSymbolCache.set(keyB, {
            mtimeMs: 1001, size: 200,
            symbols: { functions: new Set(), variables: new Map(), functionCaseMap: new Map() },
            dependencies: new Set([keyA])
        });
        expect(() => {
            invalidateIncludeCacheForPath(keyA);
        }).not.toThrow();
        expect(includeSymbolCache.has(keyA)).toBe(false);
        expect(includeSymbolCache.has(keyB)).toBe(false);
    });

    test("also clears in-flight promises for invalidated paths", () => {
        seedCache();
        includeSymbolWarmInFlight.set(keyA, Promise.resolve({}));
        includeSymbolWarmInFlight.set(keyB, Promise.resolve({}));
        invalidateIncludeCacheForPath(keyA);
        expect(includeSymbolWarmInFlight.has(keyA)).toBe(false);
        expect(includeSymbolWarmInFlight.has(keyB)).toBe(false);
    });
});
