/**
 * tracker-linear plugin — Linear as an issue tracker.
 *
 * Uses the Linear GraphQL API with either:
 * - LINEAR_API_KEY (direct API access)
 * - COMPOSIO_API_KEY (via Composio SDK's LINEAR_RUN_QUERY_OR_MUTATION tool)
 *
 * Auto-detects which key is available and routes accordingly.
 */

import { request } from "node:https";
import type {
  PluginModule,
  Tracker,
  Issue,
  IssueFilters,
  IssueUpdate,
  CreateIssueInput,
  ProjectConfig,
} from "@composio/ao-core";
import type { Composio } from "@composio/core";

// ---------------------------------------------------------------------------
// Transport abstraction
// ---------------------------------------------------------------------------

/**
 * A function that sends a GraphQL query/mutation and returns the parsed data.
 * Both the direct Linear API and Composio SDK transports implement this.
 */
type GraphQLTransport = <T>(query: string, variables?: Record<string, unknown>) => Promise<T>;

// ---------------------------------------------------------------------------
// Direct Linear API transport
// ---------------------------------------------------------------------------

const LINEAR_API_URL = "https://api.linear.app/graphql";

function getApiKey(): string {
  const key = process.env["LINEAR_API_KEY"];
  if (!key) {
    throw new Error(
      "LINEAR_API_KEY environment variable is required for the Linear tracker plugin",
    );
  }
  return key;
}

interface LinearResponse<T> {
  data?: T;
  errors?: Array<{ message: string }>;
}

function createDirectTransport(): GraphQLTransport {
  return <T>(query: string, variables?: Record<string, unknown>): Promise<T> => {
    const apiKey = getApiKey();
    const body = JSON.stringify({ query, variables });

    return new Promise<T>((resolve, reject) => {
      const url = new URL(LINEAR_API_URL);
      let settled = false;
      const settle = (fn: () => void) => {
        if (!settled) {
          settled = true;
          fn();
        }
      };

      const req = request(
        {
          hostname: url.hostname,
          path: url.pathname,
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            Authorization: apiKey,
            "Content-Length": Buffer.byteLength(body),
          },
        },
        (res) => {
          const chunks: Buffer[] = [];
          res.on("error", (err: Error) => settle(() => reject(err)));
          res.on("data", (chunk: Buffer) => chunks.push(chunk));
          res.on("end", () => {
            settle(() => {
              try {
                const text = Buffer.concat(chunks).toString("utf-8");
                const status = res.statusCode ?? 0;
                if (status < 200 || status >= 300) {
                  reject(new Error(`Linear API returned HTTP ${status}: ${text.slice(0, 200)}`));
                  return;
                }
                const json: LinearResponse<T> = JSON.parse(text);
                if (json.errors && json.errors.length > 0) {
                  reject(new Error(`Linear API error: ${json.errors[0].message}`));
                  return;
                }
                if (!json.data) {
                  reject(new Error("Linear API returned no data"));
                  return;
                }
                resolve(json.data);
              } catch (err) {
                reject(err);
              }
            });
          });
        },
      );

      req.setTimeout(30_000, () => {
        settle(() => {
          req.destroy();
          reject(new Error("Linear API request timed out after 30s"));
        });
      });

      req.on("error", (err) => settle(() => reject(err)));
      req.write(body);
      req.end();
    });
  };
}

// ---------------------------------------------------------------------------
// Composio SDK transport
// ---------------------------------------------------------------------------

type ComposioTools = Composio["tools"];

function createComposioTransport(apiKey: string, entityId: string): GraphQLTransport {
  // Lazy-load the Composio client — cached as a promise so the constructor
  // is called only once, even under concurrent requests.
  let clientPromise: Promise<ComposioTools> | undefined;

  function getClient(): Promise<ComposioTools> {
    if (!clientPromise) {
      clientPromise = (async () => {
        try {
          const { Composio } = await import("@composio/core");
          const client = new Composio({ apiKey });
          return client.tools;
        } catch (err: unknown) {
          const msg = err instanceof Error ? err.message : String(err);
          if (
            msg.includes("Cannot find module") ||
            msg.includes("Cannot find package") ||
            msg.includes("ERR_MODULE_NOT_FOUND")
          ) {
            throw new Error(
              "Composio SDK (@composio/core) is not installed. " +
                "Install it with: pnpm add @composio/core",
              { cause: err },
            );
          }
          throw err;
        }
      })();
    }
    return clientPromise;
  }

  return async <T>(query: string, variables?: Record<string, unknown>): Promise<T> => {
    const tools = await getClient();

    const resultPromise = tools.execute("LINEAR_RUN_QUERY_OR_MUTATION", {
      entityId,
      arguments: {
        query_or_mutation: query,
        variables: variables ? JSON.stringify(variables) : "{}",
      },
    });

    // Apply 30s timeout for parity with the direct transport
    let timer: ReturnType<typeof setTimeout> | undefined;
    const timeoutPromise = new Promise<never>((_resolve, reject) => {
      timer = setTimeout(() => {
        reject(new Error("Composio Linear API request timed out after 30s"));
      }, 30_000);
    });

    // Whichever promise loses the race is left without a handler.
    // Attach no-op .catch() to both so the loser doesn't trigger an
    // unhandled promise rejection. This does not affect Promise.race —
    // it still propagates the winning rejection normally.
    resultPromise.catch(() => {});
    timeoutPromise.catch(() => {});

    try {
      const result = await Promise.race([resultPromise, timeoutPromise]);

      if (!result.successful) {
        throw new Error(`Composio Linear API error: ${result.error ?? "unknown error"}`);
      }

      if (!result.data) {
        throw new Error("Composio Linear API returned no data");
      }

      return result.data as T;
    } finally {
      clearTimeout(timer);
    }
  };
}

// ---------------------------------------------------------------------------
// Types for Linear responses
// ---------------------------------------------------------------------------

interface LinearIssueNode {
  id: string;
  identifier: string;
  title: string;
  description: string | null;
  url: string;
  priority: number;
  state: {
    name: string;
    type: string; // "triage" | "backlog" | "unstarted" | "started" | "completed" | "canceled"
  };
  labels: {
    nodes: Array<{ name: string }>;
  };
  assignee: {
    name: string;
    displayName: string;
  } | null;
  team: {
    key: string;
  };
}

// ---------------------------------------------------------------------------
// State mapping
// ---------------------------------------------------------------------------

function mapLinearState(stateType: string): Issue["state"] {
  switch (stateType) {
    case "completed":
      return "closed";
    case "canceled":
      return "cancelled";
    case "started":
      return "in_progress";
    default:
      // triage, backlog, unstarted
      return "open";
  }
}

// ---------------------------------------------------------------------------
// Issue fields fragment
// ---------------------------------------------------------------------------

const ISSUE_FIELDS = `
  id
  identifier
  title
  description
  url
  priority
  state { name type }
  labels { nodes { name } }
  assignee { name displayName }
  team { key }
`;

// ---------------------------------------------------------------------------
// Tracker implementation
// ---------------------------------------------------------------------------

function createLinearTracker(query: GraphQLTransport): Tracker {
  return {
    name: "linear",

    async getIssue(identifier: string, _project: ProjectConfig): Promise<Issue> {
      const data = await query<{ issue: LinearIssueNode }>(
        `query($id: String!) {
          issue(id: $id) {
            ${ISSUE_FIELDS}
          }
        }`,
        { id: identifier },
      );

      const node = data.issue;
      return {
        id: node.identifier,
        title: node.title,
        description: node.description ?? "",
        url: node.url,
        state: mapLinearState(node.state.type),
        labels: node.labels.nodes.map((l) => l.name),
        assignee: node.assignee?.displayName ?? node.assignee?.name,
        priority: node.priority,
      };
    },

    async isCompleted(identifier: string, _project: ProjectConfig): Promise<boolean> {
      const data = await query<{ issue: { state: { type: string } } }>(
        `query($id: String!) {
          issue(id: $id) {
            state { type }
          }
        }`,
        { id: identifier },
      );

      const stateType = data.issue.state.type;
      return stateType === "completed" || stateType === "canceled";
    },

    issueUrl(identifier: string, project: ProjectConfig): string {
      const slug = project.tracker?.["workspaceSlug"] as string | undefined;
      if (slug) {
        return `https://linear.app/${slug}/issue/${identifier}`;
      }
      // Fallback: Linear also supports /issue/ URLs that redirect,
      // but they require authentication
      return `https://linear.app/issue/${identifier}`;
    },

    issueLabel(url: string, _project: ProjectConfig): string {
      // Extract identifier from Linear URL
      // Examples:
      //   https://linear.app/composio/issue/INT-1327
      //   https://linear.app/issue/INT-1327
      const match = url.match(/\/issue\/([A-Z]+-\d+)/);
      if (match) {
        return match[1];
      }
      // Fallback: return the last segment of the URL
      const parts = url.split("/");
      return parts[parts.length - 1] || url;
    },

    branchName(identifier: string, _project: ProjectConfig): string {
      // Linear convention: feat/INT-1330
      return `feat/${identifier}`;
    },

    async generatePrompt(identifier: string, project: ProjectConfig): Promise<string> {
      const issue = await this.getIssue(identifier, project);
      const lines = [
        `You are working on Linear ticket ${issue.id}: ${issue.title}`,
        `Issue URL: ${issue.url}`,
        "",
      ];

      if (issue.labels.length > 0) {
        lines.push(`Labels: ${issue.labels.join(", ")}`);
      }

      if (issue.priority !== undefined) {
        const priorityNames: Record<number, string> = {
          0: "No priority",
          1: "Urgent",
          2: "High",
          3: "Normal",
          4: "Low",
        };
        lines.push(`Priority: ${priorityNames[issue.priority] ?? String(issue.priority)}`);
      }

      if (issue.description) {
        lines.push("## Description", "", issue.description);
      }

      lines.push(
        "",
        "Please implement the changes described in this ticket. When done, commit and push your changes.",
      );

      return lines.join("\n");
    },

    async listIssues(filters: IssueFilters, project: ProjectConfig): Promise<Issue[]> {
      // Build filter object using GraphQL variables to prevent injection
      const filter: Record<string, unknown> = {};
      const variables: Record<string, unknown> = {};

      if (filters.state === "closed") {
        filter["state"] = { type: { in: ["completed", "canceled"] } };
      } else if (filters.state !== "all") {
        // Default to open (exclude completed/canceled) to match tracker-github
        filter["state"] = { type: { nin: ["completed", "canceled"] } };
      }

      if (filters.assignee) {
        filter["assignee"] = { displayName: { eq: filters.assignee } };
      }

      if (filters.labels && filters.labels.length > 0) {
        filter["labels"] = { name: { in: filters.labels } };
      }

      // Add team filter if available from project config
      const teamId = project.tracker?.["teamId"];
      if (teamId) {
        filter["team"] = { id: { eq: teamId } };
      }

      variables["filter"] = Object.keys(filter).length > 0 ? filter : undefined;
      variables["first"] = filters.limit ?? 30;

      const data = await query<{
        issues: { nodes: LinearIssueNode[] };
      }>(
        `query($filter: IssueFilter, $first: Int!) {
          issues(filter: $filter, first: $first) {
            nodes {
              ${ISSUE_FIELDS}
            }
          }
        }`,
        variables,
      );

      return data.issues.nodes.map((node) => ({
        id: node.identifier,
        title: node.title,
        description: node.description ?? "",
        url: node.url,
        state: mapLinearState(node.state.type),
        labels: node.labels.nodes.map((l) => l.name),
        assignee: node.assignee?.displayName ?? node.assignee?.name,
        priority: node.priority,
      }));
    },

    async updateIssue(
      identifier: string,
      update: IssueUpdate,
      _project: ProjectConfig,
    ): Promise<void> {
      // Linear's issue() query accepts both UUID and short identifier (e.g. "INT-1330").
      // We resolve to UUID here for use in mutations.
      const issueData = await query<{
        issue: { id: string; team: { id: string } };
      }>(
        `query($id: String!) {
          issue(id: $id) {
            id
            team { id }
          }
        }`,
        { id: identifier },
      );

      const issueUuid = issueData.issue.id;
      const teamId = issueData.issue.team.id;

      // Handle state change
      if (update.state) {
        // Need to find the correct workflow state ID
        const statesData = await query<{
          workflowStates: { nodes: Array<{ id: string; name: string; type: string }> };
        }>(
          `query($teamId: ID!) {
            workflowStates(filter: { team: { id: { eq: $teamId } } }) {
              nodes { id name type }
            }
          }`,
          { teamId },
        );

        const targetType =
          update.state === "closed"
            ? "completed"
            : update.state === "open"
              ? "unstarted"
              : "started";

        const targetState = statesData.workflowStates.nodes.find((s) => s.type === targetType);

        if (!targetState) {
          throw new Error(`No workflow state of type "${targetType}" found for team ${teamId}`);
        }

        await query(
          `mutation($id: String!, $stateId: String!) {
            issueUpdate(id: $id, input: { stateId: $stateId }) {
              success
            }
          }`,
          { id: issueUuid, stateId: targetState.id },
        );
      }

      // Handle assignee
      if (update.assignee) {
        const usersData = await query<{
          users: { nodes: Array<{ id: string; displayName: string; name: string }> };
        }>(
          `query($filter: UserFilter) {
            users(filter: $filter) {
              nodes { id displayName name }
            }
          }`,
          { filter: { displayName: { eq: update.assignee } } },
        );

        const user = usersData.users.nodes[0];
        if (user) {
          await query(
            `mutation($id: String!, $assigneeId: String!) {
              issueUpdate(id: $id, input: { assigneeId: $assigneeId }) {
                success
              }
            }`,
            { id: issueUuid, assigneeId: user.id },
          );
        }
      }

      // Handle labels (additive — merge with existing labels to match tracker-github behavior)
      if (update.labels && update.labels.length > 0) {
        // Fetch existing label IDs on the issue
        const existingData = await query<{
          issue: { labels: { nodes: Array<{ id: string }> } };
        }>(
          `query($id: String!) {
            issue(id: $id) {
              labels { nodes { id } }
            }
          }`,
          { id: issueUuid },
        );
        const existingIds = new Set(existingData.issue.labels.nodes.map((l) => l.id));

        // Resolve new label names to IDs
        const labelsData = await query<{
          issueLabels: { nodes: Array<{ id: string; name: string }> };
        }>(
          `query($teamId: ID) {
            issueLabels(filter: { team: { id: { eq: $teamId } } }) {
              nodes { id name }
            }
          }`,
          { teamId },
        );

        const labelMap = new Map(labelsData.issueLabels.nodes.map((l) => [l.name, l.id]));
        for (const name of update.labels) {
          const id = labelMap.get(name);
          if (id) existingIds.add(id);
        }

        await query(
          `mutation($id: String!, $labelIds: [String!]!) {
            issueUpdate(id: $id, input: { labelIds: $labelIds }) {
              success
            }
          }`,
          { id: issueUuid, labelIds: [...existingIds] },
        );
      }

      // Handle comment
      if (update.comment) {
        await query(
          `mutation($issueId: String!, $body: String!) {
            commentCreate(input: { issueId: $issueId, body: $body }) {
              success
            }
          }`,
          { issueId: issueUuid, body: update.comment },
        );
      }
    },

    async createIssue(input: CreateIssueInput, project: ProjectConfig): Promise<Issue> {
      const teamId = project.tracker?.["teamId"];
      if (!teamId) {
        throw new Error("Linear tracker requires 'teamId' in project tracker config");
      }

      const variables: Record<string, unknown> = {
        title: input.title,
        description: input.description ?? "",
        teamId,
      };

      if (input.priority !== undefined) {
        variables["priority"] = input.priority;
      }

      const data = await query<{
        issueCreate: {
          success: boolean;
          issue: LinearIssueNode;
        };
      }>(
        `mutation($title: String!, $description: String!, $teamId: String!, $priority: Int) {
          issueCreate(input: {
            title: $title,
            description: $description,
            teamId: $teamId,
            priority: $priority
          }) {
            success
            issue {
              ${ISSUE_FIELDS}
            }
          }
        }`,
        variables,
      );

      const node = data.issueCreate.issue;
      const issue: Issue = {
        id: node.identifier,
        title: node.title,
        description: node.description ?? "",
        url: node.url,
        state: mapLinearState(node.state.type),
        labels: node.labels.nodes.map((l) => l.name),
        assignee: node.assignee?.displayName ?? node.assignee?.name,
        priority: node.priority,
      };

      // Assign after creation (Linear's issueCreate uses assigneeId, not display name)
      if (input.assignee) {
        try {
          const usersData = await query<{
            users: { nodes: Array<{ id: string; displayName: string; name: string }> };
          }>(
            `query($filter: UserFilter) {
              users(filter: $filter) {
                nodes { id displayName name }
              }
            }`,
            { filter: { displayName: { eq: input.assignee } } },
          );

          const user = usersData.users.nodes[0];
          if (user) {
            await query(
              `mutation($id: String!, $assigneeId: String!) {
                issueUpdate(id: $id, input: { assigneeId: $assigneeId }) {
                  success
                }
              }`,
              { id: node.id, assigneeId: user.id },
            );
            issue.assignee = input.assignee;
          }
        } catch {
          // Assignee is best-effort
        }
      }

      // Add labels after creation (Linear's issueCreate doesn't accept label names directly)
      if (input.labels && input.labels.length > 0) {
        try {
          // Look up label IDs by name for the team
          const labelsData = await query<{
            issueLabels: { nodes: Array<{ id: string; name: string }> };
          }>(
            `query($teamId: ID) {
              issueLabels(filter: { team: { id: { eq: $teamId } } }) {
                nodes { id name }
              }
            }`,
            { teamId },
          );

          const labelMap = new Map(labelsData.issueLabels.nodes.map((l) => [l.name, l.id]));
          const appliedLabels: string[] = [];
          const labelIds: string[] = [];
          for (const name of input.labels) {
            const id = labelMap.get(name);
            if (id) {
              labelIds.push(id);
              appliedLabels.push(name);
            }
          }

          if (labelIds.length > 0) {
            await query(
              `mutation($id: String!, $labelIds: [String!]!) {
                issueUpdate(id: $id, input: { labelIds: $labelIds }) {
                  success
                }
              }`,
              { id: node.id, labelIds },
            );
            // Reflect only the labels that actually exist in Linear
            issue.labels = appliedLabels;
          }
        } catch {
          // Labels are best-effort; don't fail the whole creation
        }
      }

      return issue;
    },
  };
}

// ---------------------------------------------------------------------------
// Plugin module export
// ---------------------------------------------------------------------------

export const manifest = {
  name: "linear",
  slot: "tracker" as const,
  description: "Tracker plugin: Linear issue tracker",
  version: "0.1.0",
};

export function create(): Tracker {
  const composioKey = process.env["COMPOSIO_API_KEY"];
  if (composioKey) {
    const entityId = process.env["COMPOSIO_ENTITY_ID"] ?? "default";
    return createLinearTracker(createComposioTransport(composioKey, entityId));
  }
  return createLinearTracker(createDirectTransport());
}

export default { manifest, create } satisfies PluginModule<Tracker>;
