import { homedir } from "os";
import { join, resolve } from "path";
import { describe, expect, it } from "vitest";
import type { ResourceDiagnostic } from "../src/core/diagnostics.js";
import {
  formatSkillsForPrompt,
  loadSkills,
  loadSkillsFromDir,
  type Skill,
} from "../src/core/skills.js";

const fixturesDir = resolve(__dirname, "fixtures/skills");
const collisionFixturesDir = resolve(__dirname, "fixtures/skills-collision");

describe("skills", () => {
  describe("loadSkillsFromDir", () => {
    it("should load a valid skill", () => {
      const { skills, diagnostics } = loadSkillsFromDir({
        dir: join(fixturesDir, "valid-skill"),
        source: "test",
      });

      expect(skills).toHaveLength(1);
      expect(skills[0].name).toBe("valid-skill");
      expect(skills[0].description).toBe("A valid skill for testing purposes.");
      expect(skills[0].source).toBe("test");
      expect(diagnostics).toHaveLength(0);
    });

    it("should warn when name doesn't match parent directory", () => {
      const { skills, diagnostics } = loadSkillsFromDir({
        dir: join(fixturesDir, "name-mismatch"),
        source: "test",
      });

      expect(skills).toHaveLength(1);
      expect(skills[0].name).toBe("different-name");
      expect(
        diagnostics.some((d: ResourceDiagnostic) =>
          d.message.includes("does not match parent directory"),
        ),
      ).toBe(true);
    });

    it("should warn when name contains invalid characters", () => {
      const { skills, diagnostics } = loadSkillsFromDir({
        dir: join(fixturesDir, "invalid-name-chars"),
        source: "test",
      });

      expect(skills).toHaveLength(1);
      expect(
        diagnostics.some((d: ResourceDiagnostic) =>
          d.message.includes("invalid characters"),
        ),
      ).toBe(true);
    });

    it("should warn when name exceeds 64 characters", () => {
      const { skills, diagnostics } = loadSkillsFromDir({
        dir: join(fixturesDir, "long-name"),
        source: "test",
      });

      expect(skills).toHaveLength(1);
      expect(
        diagnostics.some((d: ResourceDiagnostic) =>
          d.message.includes("exceeds 64 characters"),
        ),
      ).toBe(true);
    });

    it("should warn and skip skill when description is missing", () => {
      const { skills, diagnostics } = loadSkillsFromDir({
        dir: join(fixturesDir, "missing-description"),
        source: "test",
      });

      expect(skills).toHaveLength(0);
      expect(
        diagnostics.some((d: ResourceDiagnostic) =>
          d.message.includes("description is required"),
        ),
      ).toBe(true);
    });

    it("should ignore unknown frontmatter fields", () => {
      const { skills, diagnostics } = loadSkillsFromDir({
        dir: join(fixturesDir, "unknown-field"),
        source: "test",
      });

      expect(skills).toHaveLength(1);
      expect(diagnostics).toHaveLength(0);
    });

    it("should load nested skills recursively", () => {
      const { skills, diagnostics } = loadSkillsFromDir({
        dir: join(fixturesDir, "nested"),
        source: "test",
      });

      expect(skills).toHaveLength(1);
      expect(skills[0].name).toBe("child-skill");
      expect(diagnostics).toHaveLength(0);
    });

    it("should skip files without frontmatter", () => {
      const { skills, diagnostics } = loadSkillsFromDir({
        dir: join(fixturesDir, "no-frontmatter"),
        source: "test",
      });

      // no-frontmatter has no description, so it should be skipped
      expect(skills).toHaveLength(0);
      expect(
        diagnostics.some((d: ResourceDiagnostic) =>
          d.message.includes("description is required"),
        ),
      ).toBe(true);
    });

    it("should warn and skip skill when YAML frontmatter is invalid", () => {
      const { skills, diagnostics } = loadSkillsFromDir({
        dir: join(fixturesDir, "invalid-yaml"),
        source: "test",
      });

      expect(skills).toHaveLength(0);
      expect(
        diagnostics.some((d: ResourceDiagnostic) =>
          d.message.includes("at line"),
        ),
      ).toBe(true);
    });

    it("should preserve multiline descriptions from YAML", () => {
      const { skills, diagnostics } = loadSkillsFromDir({
        dir: join(fixturesDir, "multiline-description"),
        source: "test",
      });

      expect(skills).toHaveLength(1);
      expect(skills[0].description).toContain("\n");
      expect(skills[0].description).toContain(
        "This is a multiline description.",
      );
      expect(diagnostics).toHaveLength(0);
    });

    it("should warn when name contains consecutive hyphens", () => {
      const { skills, diagnostics } = loadSkillsFromDir({
        dir: join(fixturesDir, "consecutive-hyphens"),
        source: "test",
      });

      expect(skills).toHaveLength(1);
      expect(
        diagnostics.some((d: ResourceDiagnostic) =>
          d.message.includes("consecutive hyphens"),
        ),
      ).toBe(true);
    });

    it("should load all skills from fixture directory", () => {
      const { skills } = loadSkillsFromDir({
        dir: fixturesDir,
        source: "test",
      });

      // Should load all skills that have descriptions (even with warnings)
      // valid-skill, name-mismatch, invalid-name-chars, long-name, unknown-field, nested/child-skill, consecutive-hyphens
      // NOT: missing-description, no-frontmatter (both missing descriptions)
      expect(skills.length).toBeGreaterThanOrEqual(6);
    });

    it("should return empty for non-existent directory", () => {
      const { skills, diagnostics } = loadSkillsFromDir({
        dir: "/non/existent/path",
        source: "test",
      });

      expect(skills).toHaveLength(0);
      expect(diagnostics).toHaveLength(0);
    });

    it("should use parent directory name when name not in frontmatter", () => {
      // The no-frontmatter fixture has no name in frontmatter, so it should use "no-frontmatter"
      // But it also has no description, so it won't load
      // Let's test with a valid skill that relies on directory name
      const { skills } = loadSkillsFromDir({
        dir: join(fixturesDir, "valid-skill"),
        source: "test",
      });

      expect(skills).toHaveLength(1);
      expect(skills[0].name).toBe("valid-skill");
    });

    it("should parse disable-model-invocation frontmatter field", () => {
      const { skills, diagnostics } = loadSkillsFromDir({
        dir: join(fixturesDir, "disable-model-invocation"),
        source: "test",
      });

      expect(skills).toHaveLength(1);
      expect(skills[0].name).toBe("disable-model-invocation");
      expect(skills[0].disableModelInvocation).toBe(true);
      // Should not warn about unknown field
      expect(
        diagnostics.some((d: ResourceDiagnostic) =>
          d.message.includes("unknown frontmatter field"),
        ),
      ).toBe(false);
    });

    it("should default disableModelInvocation to false when not specified", () => {
      const { skills } = loadSkillsFromDir({
        dir: join(fixturesDir, "valid-skill"),
        source: "test",
      });

      expect(skills).toHaveLength(1);
      expect(skills[0].disableModelInvocation).toBe(false);
    });
  });

  describe("formatSkillsForPrompt", () => {
    it("should return empty string for no skills", () => {
      const result = formatSkillsForPrompt([]);
      expect(result).toBe("");
    });

    it("should format skills as XML", () => {
      const skills: Skill[] = [
        {
          name: "test-skill",
          description: "A test skill.",
          filePath: "/path/to/skill/SKILL.md",
          baseDir: "/path/to/skill",
          source: "test",
          disableModelInvocation: false,
        },
      ];

      const result = formatSkillsForPrompt(skills);

      expect(result).toContain("<available_skills>");
      expect(result).toContain("</available_skills>");
      expect(result).toContain("<skill>");
      expect(result).toContain("<name>test-skill</name>");
      expect(result).toContain("<description>A test skill.</description>");
      expect(result).toContain("<location>/path/to/skill/SKILL.md</location>");
    });

    it("should include intro text before XML", () => {
      const skills: Skill[] = [
        {
          name: "test-skill",
          description: "A test skill.",
          filePath: "/path/to/skill/SKILL.md",
          baseDir: "/path/to/skill",
          source: "test",
          disableModelInvocation: false,
        },
      ];

      const result = formatSkillsForPrompt(skills);
      const xmlStart = result.indexOf("<available_skills>");
      const introText = result.substring(0, xmlStart);

      expect(introText).toContain(
        "Skills are specialized instructions for specific tasks",
      );
      expect(introText).toContain("Do not load skills speculatively");
    });

    it("should escape XML special characters", () => {
      const skills: Skill[] = [
        {
          name: "test-skill",
          description: 'A skill with <special> & "characters".',
          filePath: "/path/to/skill/SKILL.md",
          baseDir: "/path/to/skill",
          source: "test",
          disableModelInvocation: false,
        },
      ];

      const result = formatSkillsForPrompt(skills);

      expect(result).toContain("&lt;special&gt;");
      expect(result).toContain("&amp;");
      expect(result).toContain("&quot;characters&quot;");
    });

    it("should format multiple skills", () => {
      const skills: Skill[] = [
        {
          name: "skill-one",
          description: "First skill.",
          filePath: "/path/one/SKILL.md",
          baseDir: "/path/one",
          source: "test",
          disableModelInvocation: false,
        },
        {
          name: "skill-two",
          description: "Second skill.",
          filePath: "/path/two/SKILL.md",
          baseDir: "/path/two",
          source: "test",
          disableModelInvocation: false,
        },
      ];

      const result = formatSkillsForPrompt(skills);

      expect(result).toContain("<name>skill-one</name>");
      expect(result).toContain("<name>skill-two</name>");
      expect((result.match(/<skill>/g) || []).length).toBe(2);
    });

    it("should exclude skills with disableModelInvocation from prompt", () => {
      const skills: Skill[] = [
        {
          name: "visible-skill",
          description: "A visible skill.",
          filePath: "/path/visible/SKILL.md",
          baseDir: "/path/visible",
          source: "test",
          disableModelInvocation: false,
        },
        {
          name: "hidden-skill",
          description: "A hidden skill.",
          filePath: "/path/hidden/SKILL.md",
          baseDir: "/path/hidden",
          source: "test",
          disableModelInvocation: true,
        },
      ];

      const result = formatSkillsForPrompt(skills);

      expect(result).toContain("<name>visible-skill</name>");
      expect(result).not.toContain("<name>hidden-skill</name>");
      expect((result.match(/<skill>/g) || []).length).toBe(1);
    });

    it("should return empty string when all skills have disableModelInvocation", () => {
      const skills: Skill[] = [
        {
          name: "hidden-skill",
          description: "A hidden skill.",
          filePath: "/path/hidden/SKILL.md",
          baseDir: "/path/hidden",
          source: "test",
          disableModelInvocation: true,
        },
      ];

      const result = formatSkillsForPrompt(skills);
      expect(result).toBe("");
    });
  });

  describe("loadSkills with options", () => {
    const emptyAgentDir = resolve(__dirname, "fixtures/empty-agent");
    const emptyCwd = resolve(__dirname, "fixtures/empty-cwd");

    it("should load from explicit skillPaths", () => {
      const { skills, diagnostics } = loadSkills({
        agentDir: emptyAgentDir,
        cwd: emptyCwd,
        skillPaths: [join(fixturesDir, "valid-skill")],
      });
      expect(skills).toHaveLength(1);
      expect(skills[0].source).toBe("path");
      expect(diagnostics).toHaveLength(0);
    });

    it("should warn when skill path does not exist", () => {
      const { skills, diagnostics } = loadSkills({
        agentDir: emptyAgentDir,
        cwd: emptyCwd,
        skillPaths: ["/non/existent/path"],
      });
      expect(skills).toHaveLength(0);
      expect(
        diagnostics.some((d: ResourceDiagnostic) =>
          d.message.includes("does not exist"),
        ),
      ).toBe(true);
    });

    it("should expand ~ in skillPaths", () => {
      const homeSkillsDir = join(homedir(), ".pi/agent/skills");
      const { skills: withTilde } = loadSkills({
        agentDir: emptyAgentDir,
        cwd: emptyCwd,
        skillPaths: ["~/.pi/agent/skills"],
      });
      const { skills: withoutTilde } = loadSkills({
        agentDir: emptyAgentDir,
        cwd: emptyCwd,
        skillPaths: [homeSkillsDir],
      });
      expect(withTilde.length).toBe(withoutTilde.length);
    });
  });

  describe("collision handling", () => {
    it("should detect name collisions and keep first skill", () => {
      // Load from first directory
      const first = loadSkillsFromDir({
        dir: join(collisionFixturesDir, "first"),
        source: "first",
      });

      const second = loadSkillsFromDir({
        dir: join(collisionFixturesDir, "second"),
        source: "second",
      });

      // Simulate the collision behavior from loadSkills()
      const skillMap = new Map<string, Skill>();
      const collisionWarnings: Array<{ skillPath: string; message: string }> =
        [];

      for (const skill of first.skills) {
        skillMap.set(skill.name, skill);
      }

      for (const skill of second.skills) {
        const existing = skillMap.get(skill.name);
        if (existing) {
          collisionWarnings.push({
            skillPath: skill.filePath,
            message: `name collision: "${skill.name}" already loaded from ${existing.filePath}`,
          });
        } else {
          skillMap.set(skill.name, skill);
        }
      }

      expect(skillMap.size).toBe(1);
      expect(skillMap.get("calendar")?.source).toBe("first");
      expect(collisionWarnings).toHaveLength(1);
      expect(collisionWarnings[0].message).toContain("name collision");
    });
  });
});
