#!/usr/bin/env node
import * as fs from "fs";
import * as path from "path";
import { Command } from "@commander-js/extra-typings";
import { DOMParser, Document, Element } from "@xmldom/xmldom";

interface ElementInfo {
  type: string;
  element: Element;
  properties: Record<string, string>;
  descriptions: string[];
}

interface LintWarning {
  code: string;
  message: string;
  element?: ElementInfo;
}

interface LintContext {
  filePath: string;
  fileDir: string;
  svgDoc: Document;
  elements: ElementInfo[];
  warnings: LintWarning[];
}

// Setup the command line interface
const program = new Command()
  .name("svg-linter")
  .description("Lints SVG files according to our constraints")
  .version("0.1.0")
  .argument("<file>", "Path to the SVG file to lint")
  .option(
    "--inspect",
    "Inspect mode - outputs all SVG elements and their properties"
  )
  .parse();

const options = program.opts();

/**
 * Reads and parses an SVG file
 * @param filePath Path to the SVG file
 * @returns The parsed SVG document
 */
function parseSvgFile(filePath: string): Document {
  try {
    const resolvedPath = path.resolve(filePath);
    const svgContent = fs.readFileSync(resolvedPath, "utf-8");
    const parser = new DOMParser();
    return parser.parseFromString(svgContent, "image/svg+xml");
  } catch (error) {
    console.error(
      `Error reading or parsing SVG file: ${
        error instanceof Error ? error.message : String(error)
      }`
    );
    process.exit(1);
  }
}

/**
 * Extracts properties from an SVG element
 * @param element The SVG element to extract properties from
 * @returns Record of property name to value
 */
function extractElementProperties(element: Element): Record<string, string> {
  const properties: Record<string, string> = {};

  for (let i = 0; i < element.attributes.length; i++) {
    const attr = element.attributes[i];
    properties[attr.name] = attr.value;
  }

  return properties;
}

/**
 * Extracts all elements of a specific type from an SVG document
 * @param doc The SVG document
 * @param elementType The element type to extract
 * @returns Array of elements with their properties
 */
/**
 * Extract desc elements from a parent element
 * @param element The parent element
 * @returns Array of text content from desc elements
 */
function extractDescElements(element: Element): string[] {
  const descriptions: string[] = [];
  const descElements = element.getElementsByTagName("desc");

  for (let i = 0; i < descElements.length; i++) {
    const descElement = descElements[i];
    const textContent = descElement.textContent;
    if (textContent) {
      descriptions.push(textContent.trim());
    }
  }

  return descriptions;
}

function extractElements(doc: Document, elementType: string): ElementInfo[] {
  const elements = doc.getElementsByTagName(elementType);
  const result: ElementInfo[] = [];

  for (let i = 0; i < elements.length; i++) {
    const element = elements[i];
    result.push({
      type: elementType,
      element: element,
      properties: extractElementProperties(element),
      descriptions: extractDescElements(element),
    });
  }

  return result;
}

/**
 * Linting functions
 */

// Lint: There should be exactly one element with an id "start", and it should be a rect
function lintStartRect(context: LintContext): void {
  const startElements = context.elements.filter(
    (el) => el.properties.id === "start"
  );

  if (startElements.length === 0) {
    context.warnings.push({
      code: "START_MISSING",
      message:
        "No element with id 'start' found. One rect with id='start' is required.",
    });
    return;
  }

  if (startElements.length > 1) {
    context.warnings.push({
      code: "START_DUPLICATE",
      message: `Found ${startElements.length} elements with id 'start'. Only one is allowed.`,
    });
  }

  const nonRectStarts = startElements.filter((el) => el.type !== "rect");
  if (nonRectStarts.length > 0) {
    context.warnings.push({
      code: "START_NOT_RECT",
      message: `Element with id 'start' must be a rect, but found: ${nonRectStarts
        .map((el) => el.type)
        .join(", ")}.`,
      element: nonRectStarts[0],
    });
  }
}

// Lint: For circles with a description element, the description should point to a relative path to an existing file
function lintCircleDescriptions(context: LintContext): void {
  const circlesWithDesc = context.elements.filter(
    (el) => el.type === "circle" && el.descriptions.length > 0
  );

  circlesWithDesc.forEach((circle) => {
    circle.descriptions.forEach((desc) => {
      // Check if description appears to be a file path
      if (desc.includes("/") || desc.includes(".")) {
        // Try to resolve the path relative to the SVG file's directory
        const descPath = path.resolve(context.fileDir, desc);
        if (!fs.existsSync(descPath)) {
          context.warnings.push({
            code: "CIRCLE_DESC_FILE_MISSING",
            message: `Circle (id=${circle.properties.id ||
              "no-id"}) has description pointing to non-existent file: ${desc}`,
            element: circle,
          });
        }
      }
    });
  });
}

// Lint: If there's an ellipse with any description, emit a warning
function lintEllipseDescriptions(context: LintContext): void {
  const ellipsesWithDesc = context.elements.filter(
    (el) => el.type === "ellipse" && el.descriptions.length > 0
  );

  ellipsesWithDesc.forEach((ellipse) => {
    // Calculate average radius
    const rx = parseFloat(ellipse.properties.rx || "0");
    const ry = parseFloat(ellipse.properties.ry || "0");
    const avgRadius = (rx + ry) / 2;

    context.warnings.push({
      code: "ELLIPSE_WITH_DESC",
      message: `Ellipse (id=${ellipse.properties.id ||
        "no-id"}) has a description. It will be processed as a circle with radius ${avgRadius} (average of rx=${rx} and ry=${ry}).`,
      element: ellipse,
    });
  });
}

// Lint: Check that hyperlinks starting with "anchor" point to existing anchors and all anchors are referenced
function lintHyperlinkAnchors(context: LintContext): void {
  // Find all anchor elements (rect elements with IDs starting with "anchor")
  const anchorElements = context.elements.filter(
    (el) => el.type === "rect" && el.properties.id?.startsWith("anchor")
  );

  // Find all hyperlinks (a elements)
  const aElements = Array.from(context.svgDoc.getElementsByTagName("a"));

  // Track anchors that have been referenced
  const referencedAnchors = new Set<string>();

  // Check all hyperlinks for valid anchor references
  aElements.forEach((aElement) => {
    const href = aElement.getAttribute("xlink:href");
    if (!href) return;

    if (href.startsWith("anchor")) {
      // This is an anchor reference - check if the anchor exists
      const anchorId = href.startsWith("#") ? href.substring(1) : href;
      const targetAnchor = anchorElements.find(
        (el) => el.properties.id === anchorId
      );

      if (!targetAnchor) {
        // Hyperlink points to a non-existent anchor
        context.warnings.push({
          code: "BROKEN_ANCHOR_LINK",
          message: `Hyperlink points to non-existent anchor: ${href}`,
        });
      } else {
        // Mark this anchor as referenced
        referencedAnchors.add(anchorId);
      }
    }
  });

  // Check for unreferenced anchors
  anchorElements.forEach((anchor) => {
    if (!referencedAnchors.has(anchor.properties.id)) {
      context.warnings.push({
        code: "UNREFERENCED_ANCHOR",
        message: `Anchor element with id '${anchor.properties.id}' is not referenced by any hyperlink.`,
        element: anchor,
      });
    }
  });
}

// Lint: Validate that image elements with descriptions follow the proper format for video scrolls
function lintVideoScrollPaths(context: LintContext): void {
  const imagesWithDesc = context.elements.filter(
    (el) => el.type === "image" && el.descriptions.length > 0
  );

  imagesWithDesc.forEach((image) => {
    const desc = image.descriptions[0];
    const descLines = desc.split("\n").filter((line) => line.trim().length > 0);

    // Check that the description has at least 2 lines
    if (descLines.length < 2) {
      context.warnings.push({
        code: "INVALID_SCROLL_DESC_FORMAT",
        message: `Image (id=${image.properties.id ||
          "no-id"}) has a description that doesn't follow the required format: first line for direction, second line for file path.`,
        element: image,
      });
      return;
    }

    // First line should be a valid direction
    const directionLine = descLines[0];
    const validDirections = [
      "up",
      "down",
      "left",
      "right",
      "up left",
      "up right",
      "down left",
      "down right",
    ];

    // Check if any valid direction is in the direction line
    const hasValidDirection = validDirections.some((dir) =>
      directionLine.toLowerCase().includes(dir)
    );

    if (!hasValidDirection) {
      context.warnings.push({
        code: "INVALID_SCROLL_DIRECTION",
        message: `Image (id=${image.properties.id ||
          "no-id"}) has a description with invalid scroll direction: "${directionLine}". Valid directions: ${validDirections.join(
          ", "
        )}.`,
        element: image,
      });
    }

    // Second line should point to an existing file
    const filePath = descLines[1].replace(/^\//, "");
    try {
      const fullPath = path.resolve(context.fileDir, filePath);
      if (!fs.existsSync(fullPath)) {
        context.warnings.push({
          code: "SCROLL_FILE_MISSING",
          message: `Image (id=${image.properties.id ||
            "no-id"}) references a non-existent file in description: ${filePath}`,
          element: image,
        });
      }
    } catch (error) {
      // Path resolution error
      context.warnings.push({
        code: "SCROLL_PATH_ERROR",
        message: `Error checking path for image (id=${image.properties.id ||
          "no-id"}): ${error instanceof Error ? error.message : String(error)}`,
        element: image,
      });
    }

    // Also check if xlink:href exists and points to a valid directory
    const xlinkHref = image.properties["xlink:href"];
    if (!xlinkHref) {
      context.warnings.push({
        code: "SCROLL_MISSING_HREF",
        message: `Image (id=${image.properties.id ||
          "no-id"}) with scroll description is missing xlink:href attribute`,
        element: image,
      });
    } else if (!xlinkHref.includes("/")) {
      context.warnings.push({
        code: "INVALID_SCROLL_PATH",
        message: `Image (id=${image.properties.id ||
          "no-id"}) has an xlink:href that doesn't point to a directory: ${xlinkHref}`,
        element: image,
      });
    }
  });
}

// Lint: Check that all element IDs are unique across the SVG
function lintIdUniqueness(context: LintContext): void {
  // Create a map to track IDs and their elements
  const idMap = new Map<string, ElementInfo[]>();

  // Collect all elements with IDs
  context.elements.forEach((element) => {
    if (element.properties.id) {
      const elementsWithId = idMap.get(element.properties.id) || [];
      elementsWithId.push(element);
      idMap.set(element.properties.id, elementsWithId);
    }
  });

  // Check for duplicates
  idMap.forEach((elements, id) => {
    if (elements.length > 1) {
      context.warnings.push({
        code: "DUPLICATE_ID",
        message: `ID '${id}' is used by ${
          elements.length
        } elements: ${elements.map((e) => e.type).join(", ")}.`,
        element: elements[0],
      });
    }
  });
}

// Lint: Check that files referenced in videoscroll file lists exist
function lintVideoScrollFileContents(context: LintContext): void {
  // Find all image elements with descriptions that follow the videoscroll format
  const imagesWithDesc = context.elements.filter(
    (el) => el.type === "image" && el.descriptions.length > 0
  );

  // Process only images with valid descriptions (at least 2 lines)
  imagesWithDesc.forEach((image) => {
    const desc = image.descriptions[0];
    const descLines = desc.split("\n").filter((line) => line.trim().length > 0);

    // Skip if the description doesn't have at least 2 lines
    if (descLines.length < 2) return;

    // Get the file list path from the second line
    const fileListPath = descLines[1].replace(/^\//, "");

    try {
      // Resolve the full path to the file list
      const fullFileListPath = path.resolve(context.fileDir, fileListPath);

      // Skip if the file list doesn't exist (already checked in lintVideoScrollPaths)
      if (!fs.existsSync(fullFileListPath)) return;

      // Read the file list contents
      const fileListContent = fs.readFileSync(fullFileListPath, "utf-8");
      const referencedFiles = fileListContent
        .split("\n")
        .map((line) => line.trim())
        .filter((line) => line.length > 0);

      // Get the directory containing the file list
      const fileListDir = path.dirname(fullFileListPath);

      // Check if each referenced file exists
      const missingFiles: string[] = [];

      referencedFiles.forEach((referencedFile) => {
        const fullFilePath = path.resolve(fileListDir, referencedFile);
        if (!fs.existsSync(fullFilePath)) {
          missingFiles.push(referencedFile);
        }
      });

      // If there are missing files, emit a warning
      if (missingFiles.length > 0) {
        context.warnings.push({
          code: "SCROLL_MISSING_FILES",
          message: `Image (id=${image.properties.id ||
            "no-id"}) references a file list (${fileListPath}) that contains ${
            missingFiles.length
          } missing files: ${missingFiles.join(", ")}.`,
          element: image,
        });
      }
    } catch (error) {
      // Error reading or parsing the file list
      context.warnings.push({
        code: "SCROLL_FILE_LIST_ERROR",
        message: `Error reading file list for image (id=${image.properties.id ||
          "no-id"}): ${error instanceof Error ? error.message : String(error)}`,
        element: image,
      });
    }
  });
}

// Run all lint checks
function runAllLints(context: LintContext): void {
  lintStartRect(context);
  lintCircleDescriptions(context);
  lintEllipseDescriptions(context);
  lintHyperlinkAnchors(context);
  lintVideoScrollPaths(context);
  lintIdUniqueness(context);
  lintVideoScrollFileContents(context);
}

// Output warnings
function outputWarnings(context: LintContext): void {
  if (context.warnings.length === 0) {
    console.log("✅ SVG file passes all lints.");
    return;
  }

  console.log("\n⚠️  Lint Warnings:");
  console.log("=================\n");

  context.warnings.forEach((warning, index) => {
    console.log(`${index + 1}. [${warning.code}] ${warning.message}`);
    if (warning.element) {
      console.log(
        `   Element: ${warning.element.type}${
          warning.element.properties.id
            ? ` (id=${warning.element.properties.id})`
            : ""
        }`
      );
    }
    console.log("");
  });

  console.log(`Total warnings: ${context.warnings.length}`);
}

// Output inspect details
function outputInspectDetails(elements: ElementInfo[]): void {
  console.log("\nElements found in the SVG:");
  console.log("========================\n");

  if (elements.length === 0) {
    console.log(
      "No matching elements (image, rect, ellipse, circle) found in the SVG."
    );
    return;
  }

  elements.forEach((element, index) => {
    console.log(`${index + 1}. Type: ${element.type}`);
    console.log("   Properties:");
    Object.entries(element.properties).forEach(([name, value]) => {
      if (value.length > 128) {
        console.log(`     - ${name}: ${value.substring(0, 128)}...`);
      } else {
        console.log(`     - ${name}: ${value}`);
      }
    });

    // Output descriptions if any exist
    if (element.descriptions.length > 0) {
      console.log("   Descriptions:");
      element.descriptions.forEach((desc, descIndex) => {
        if (desc.length > 128) {
          console.log(`     - ${descIndex + 1}: ${desc.substring(0, 128)}...`);
        } else {
          console.log(`     - ${descIndex + 1}: ${desc}`);
        }
      });
    }

    console.log("");
  });

  console.log(`Total elements found: ${elements.length}`);
}

/**
 * Main function to process the SVG file
 */
function main() {
  const options = program.opts();
  const file = program.args[0];
  console.log(`Processing SVG file: ${file}`);

  const svgDoc = parseSvgFile(file);
  const fileDir = path.dirname(path.resolve(file));

  // Extract elements of interest
  const elementTypes = ["image", "rect", "ellipse", "circle"];
  const allElements: ElementInfo[] = [];

  elementTypes.forEach((type) => {
    const elements = extractElements(svgDoc, type);
    allElements.push(...elements);
  });

  // Create lint context
  const context: LintContext = {
    filePath: file,
    fileDir: fileDir,
    svgDoc: svgDoc,
    elements: allElements,
    warnings: [],
  };

  // Run all lint checks
  runAllLints(context);

  // Output inspection details if requested
  if (options.inspect) {
    outputInspectDetails(allElements);
  }

  // Output warnings
  outputWarnings(context);

  // Exit with appropriate code
  if (context.warnings.length > 0) {
    process.exit(1);
  } else {
    process.exit(0);
  }
}

// Execute the main function
main();
