// Copyright (c) Microsoft. All rights reserved.

// Hosted-AgentSkills
//
// Demonstrates how to host an agent that loads its behavioral guidelines from Foundry Skills at
// startup. Skills are authored as SKILL.md files, uploaded to Foundry via the Skills REST API,
// and downloaded by the agent on boot so guideline updates ship without code changes.
//
// The agent uses AgentSkillsProvider from the Agent Framework which implements the progressive
// disclosure pattern from the Agent Skills specification (https://agentskills.io/):
//   1. Advertise — skill names and descriptions are injected into the system prompt.
//   2. Load — the model calls load_skill to retrieve the full SKILL.md body on demand.
//
// IMPORTANT: In production, skill provisioning (uploading SKILL.md files to Foundry) is an
// external concern — it is NOT the hosted agent's responsibility. The provisioning helper below
// is included for sample convenience only, so the sample is self-contained and runnable without
// a separate setup step. A real deployment pipeline would provision skills separately (e.g., via
// a CI/CD step, a CLI script, or a management portal).

#pragma warning disable AAIP001 // ProjectAgentSkills is experimental

using System.ClientModel;
using System.IO.Compression;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Azure.Core;
using Azure.Identity;
using DotNetEnv;
using Hosted_Shared_Contributor_Setup;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;

// Load .env file if present (for local development)
Env.TraversePath().Load();

string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
    ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
string skillNames = Environment.GetEnvironmentVariable("SKILL_NAMES")
    ?? throw new InvalidOperationException("SKILL_NAMES is not set. Provide a comma-separated list of skill names (e.g., support-style,escalation-policy).");

string[] requestedSkills = skillNames.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (requestedSkills.Length == 0)
{
    throw new InvalidOperationException("SKILL_NAMES must list at least one skill name.");
}

// Validate skill names to prevent path traversal.
foreach (string name in requestedSkills)
{
    if (name.Contains('.') || name.Contains('/') || name.Contains('\\') || Path.IsPathRooted(name))
    {
        throw new InvalidOperationException(
            $"Invalid skill name '{name}': skill names must not contain path separators or dots.");
    }
}

// Use a chained credential: try a temporary dev token first (for local Docker debugging),
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production).
TokenCredential credential = new ChainedTokenCredential(
    new DevTemporaryTokenCredential(),
    new DefaultAzureCredential());

AIProjectClient projectClient = new(new Uri(endpoint), credential);
ProjectAgentSkills skillsClient = projectClient.AgentAdministrationClient.GetAgentSkills();

// ── Provision skills (sample convenience only — NOT a production pattern) ─────
// In production, skills are provisioned externally (e.g., via CI/CD or a management script).
// This helper ensures the sample's SKILL.md files exist in Foundry so the sample is runnable
// out of the box without a separate setup step. Set PROVISION_SAMPLE_SKILLS=true to enable.
string sourceSkillsDir = Path.Combine(AppContext.BaseDirectory, "skills");
bool provisionEnabled = string.Equals(
    Environment.GetEnvironmentVariable("PROVISION_SAMPLE_SKILLS"), "true", StringComparison.OrdinalIgnoreCase);
if (provisionEnabled && Directory.Exists(sourceSkillsDir))
{
    await EnsureSkillsProvisionedAsync(skillsClient, sourceSkillsDir, requestedSkills);
}

// ── Download skills from Foundry ─────────────────────────────────────────────
// Pull the latest copy of each skill from Foundry into a runtime-only folder.
// This directory is recreated on every startup so the agent always picks up
// the latest version of each skill.
string downloadedSkillsDir = Path.Combine(AppContext.BaseDirectory, "downloaded_skills");
await DownloadSkillsAsync(skillsClient, requestedSkills, downloadedSkillsDir);

// ── Wire skills into the agent ───────────────────────────────────────────────
// AgentSkillsProvider implements progressive disclosure: skill names and descriptions
// are advertised in the system prompt (~100 tokens per skill), and the full SKILL.md
// body is loaded on demand when the model calls the load_skill tool.
AgentSkillsProvider skillsProvider = new(downloadedSkillsDir);

ChatClientAgent agent = projectClient.AsAIAgent(new ChatClientAgentOptions
{
    Name = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-agent-skills",
    ChatOptions = new ChatOptions
    {
        ModelId = deploymentName,
        Instructions = "You are a customer-support assistant for Contoso Outdoors.",
    },
    AIContextProviders = [skillsProvider]
});

// Host the agent as a Foundry Hosted Agent using the Responses API.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.

var app = builder.Build();
app.MapFoundryResponses();

// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();

app.Run();

// ── Helpers ──────────────────────────────────────────────────────────────────

// Downloads each named skill from Foundry and extracts the ZIP archive into a
// separate subdirectory under the target directory.
static async Task DownloadSkillsAsync(ProjectAgentSkills skillsClient, string[] skillNames, string targetDir)
{
    if (Directory.Exists(targetDir))
    {
        Directory.Delete(targetDir, recursive: true);
    }

    Directory.CreateDirectory(targetDir);

    foreach (string name in skillNames)
    {
        Console.WriteLine($"Downloading skill '{name}' from Foundry...");
        BinaryData zipData = await skillsClient.DownloadSkillAsync(name);

        string skillDir = Path.Combine(targetDir, name);
        Directory.CreateDirectory(skillDir);

        using var zipStream = zipData.ToStream();
        using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read);
        SafeExtractZip(archive, skillDir);

        if (!File.Exists(Path.Combine(skillDir, "SKILL.md")))
        {
            throw new InvalidOperationException(
                $"Downloaded archive for '{name}' did not contain a SKILL.md at the root.");
        }
    }
}

// Extracts a ZIP archive into a destination directory, rejecting entries that would
// escape the target path (zip-slip guard).
static void SafeExtractZip(ZipArchive archive, string destinationDir)
{
    string destRoot = Path.GetFullPath(destinationDir);
    string destRootWithSep = Path.EndsInDirectorySeparator(destRoot)
        ? destRoot
        : destRoot + Path.DirectorySeparatorChar;

    // Use ordinal comparison on Unix (case-sensitive FS) and ordinal-ignore-case on Windows.
    var comparison = OperatingSystem.IsWindows()
        ? StringComparison.OrdinalIgnoreCase
        : StringComparison.Ordinal;

    foreach (ZipArchiveEntry entry in archive.Entries)
    {
        string entryPath = Path.GetFullPath(Path.Combine(destRoot, entry.FullName));
        if (!entryPath.StartsWith(destRootWithSep, comparison)
            && !string.Equals(entryPath, destRoot, comparison))
        {
            throw new InvalidOperationException(
                $"Refusing to extract unsafe path '{entry.FullName}' outside of '{destRoot}'.");
        }

        if (string.IsNullOrEmpty(entry.Name))
        {
            // Directory entry — ensure it exists.
            Directory.CreateDirectory(entryPath);
        }
        else
        {
            Directory.CreateDirectory(Path.GetDirectoryName(entryPath)!);
            entry.ExtractToFile(entryPath, overwrite: true);
        }
    }
}

// Ensures each requested skill is provisioned in Foundry. For each skill name, checks whether
// the skill exists and uploads it from the local source directory if it does not.
//
// This is a sample convenience helper — in production, skill provisioning is an external concern.
static async Task EnsureSkillsProvisionedAsync(ProjectAgentSkills skillsClient, string sourceDir, string[] skillNames)
{
    foreach (string name in skillNames)
    {
        string skillPath = Path.Combine(sourceDir, name);
        if (!Directory.Exists(skillPath) || !File.Exists(Path.Combine(skillPath, "SKILL.md")))
        {
            continue; // No local source for this skill — skip provisioning.
        }

        try
        {
            await skillsClient.GetSkillAsync(name);
            Console.WriteLine($"Skill '{name}' already exists in Foundry.");
        }
        catch (ClientResultException ex) when (ex.Status == 404)
        {
            Console.WriteLine($"Provisioning skill '{name}' from {skillPath}...");
            AgentsSkill imported = await skillsClient.CreateSkillFromPackageAsync(skillPath);
            Console.WriteLine($"  Imported skill '{imported.Name}' (id={imported.SkillId}, has_blob={imported.HasBlob}).");
        }
    }
}
