import fs from "fs";
import path from "path";
import {
  DEFAULT_DISCOVERY_EXCLUDES,
  normalizeDiscoveryConfig,
  normalizeDiscoveryPath,
  resolveDiscoveryRoots,
  shouldExcludeDiscoveryPath,
} from "../discovery/path-policy.mjs";

const TESTKIT_DIRNAME = "__testkit__";
const DISCOVERY_RULES = [
  { suffix: ".int.testkit.ts", type: "integration", framework: "k6" },
  { suffix: ".e2e.testkit.ts", type: "e2e", framework: "k6" },
  { suffix: ".scenario.testkit.ts", type: "scenario", framework: "k6" },
  { suffix: ".dal.testkit.ts", type: "dal", framework: "k6" },
  { suffix: ".load.testkit.ts", type: "load", framework: "k6" },
  { suffix: ".pw.testkit.ts", type: "e2e", framework: "playwright" },
];

export function discoverProject(productDir, explicitServices = {}, options = {}) {
  const strict = options.strict !== false;
  const repoDiscovery = normalizeDiscoveryConfig(options.discovery, { allowRoots: true });
  const { suiteFiles, legacyFiles } = discoverFiles(productDir, repoDiscovery);
  const groupedByService = {};
  const services = {};
  const diagnostics = buildLegacyFileDiagnostics(legacyFiles);
  const discoveredFiles = [];

  for (const filePath of suiteFiles) {
    const rule = inferRule(filePath);
    if (!rule) continue;

    const owners = inferOwners(filePath, explicitServices, repoDiscovery);
    if (owners === null) continue;
    if (owners.length === 0) {
      diagnostics.push({
        code: "unowned_test",
        severity: "error",
        message: `Unowned test file: ${filePath}`,
        path: filePath,
      });
      continue;
    }
    if (owners.length > 1) {
      diagnostics.push({
        code: "ambiguous_test",
        severity: "error",
        message: `Ambiguous test file: ${filePath} -> ${owners.map((owner) => owner.name).sort((left, right) => left.localeCompare(right)).join(", ")}`,
        path: filePath,
        serviceNames: owners.map((owner) => owner.name).sort((left, right) => left.localeCompare(right)),
      });
      continue;
    }

    const owner = owners[0];
    services[owner.name] = mergeServiceDiscovery(services[owner.name], owner);
    const relativeToService = relativeToServiceRoot(owner, filePath);
    const suiteRef = deriveSuiteRef(relativeToService, rule.suffix);
    discoveredFiles.push({
      serviceName: owner.name,
      type: rule.type,
      framework: rule.framework,
      filePath,
      suitePath: suiteRef.suitePath,
    });
  }

  if (strict && hasDiscoveryErrors(diagnostics)) {
    throw buildDiscoveryErrorFromDiagnostics(diagnostics);
  }

  for (const entry of discoveredFiles) {
    const grouped = groupedByService[entry.serviceName] || {};
    const suitesForType = grouped[entry.type] || [];
    const suiteKey = entry.suitePath.join("/");
    let suite = suitesForType.find(
      (candidate) => candidate._suiteKey === suiteKey && candidate.framework === entry.framework
    );

    if (!suite) {
      suite = {
        name: null,
        files: [],
        framework: entry.framework,
        _suiteKey: suiteKey,
        _suitePath: entry.suitePath,
      };
      suitesForType.push(suite);
      grouped[entry.type] = suitesForType;
      groupedByService[entry.serviceName] = grouped;
    }

    suite.files.push(entry.filePath);
  }

  const fileEntries = [];
  for (const grouped of Object.values(groupedByService)) {
    for (const suites of Object.values(grouped)) {
      const suiteNames = disambiguateSuiteNames(suites);
      for (const suite of suites) {
        suite.name = suiteNames.get(suite._suiteKey);
        suite.files.sort((left, right) => left.localeCompare(right));
        for (const filePath of suite.files) {
          fileEntries.push({
            serviceName: findSuiteServiceName(groupedByService, grouped, suite),
            type: findSuiteType(grouped, suites),
            framework: suite.framework,
            suiteName: suite.name,
            suitePath: [...suite._suitePath],
            filePath,
          });
        }
        delete suite._suiteKey;
        delete suite._suitePath;
      }
      suites.sort((left, right) => left.name.localeCompare(right.name));
    }
  }

  return {
    services,
    suitesByService: groupedByService,
    files: fileEntries.sort(
      (left, right) =>
        left.serviceName.localeCompare(right.serviceName) ||
        left.type.localeCompare(right.type) ||
        left.suiteName.localeCompare(right.suiteName) ||
        left.filePath.localeCompare(right.filePath)
    ),
    diagnostics,
  };
}

export function discoverSuites(productDir, explicitServices = {}, options = {}) {
  return discoverProject(productDir, explicitServices, options).suitesByService;
}

export function hasDiscoveryErrors(diagnostics = []) {
  return diagnostics.some((entry) => entry?.severity === "error");
}

export function buildDiscoveryErrorFromDiagnostics(diagnostics = []) {
  const lines = ["Filesystem discovery failed for one or more .testkit.ts files."];

  const legacyFiles = diagnostics.filter((entry) => entry.code === "legacy_path").map((entry) => entry.path);
  const unownedFiles = diagnostics.filter((entry) => entry.code === "unowned_test").map((entry) => entry.path);
  const ambiguousFiles = diagnostics.filter((entry) => entry.code === "ambiguous_test");

  if (legacyFiles.length > 0) {
    lines.push("");
    lines.push("Legacy test files outside __testkit__:");
    for (const filePath of legacyFiles) {
      lines.push(`- ${filePath}`);
    }
  }

  if (unownedFiles.length > 0) {
    lines.push("");
    lines.push("Unowned test files:");
    for (const filePath of unownedFiles) {
      lines.push(`- ${filePath}`);
    }
  }

  if (ambiguousFiles.length > 0) {
    lines.push("");
    lines.push("Ambiguous test files:");
    for (const entry of ambiguousFiles) {
      lines.push(`- ${entry.path} -> ${entry.serviceNames.join(", ")}`);
    }
  }

  lines.push("");
  lines.push('Expected test files to live under a "__testkit__" directory within a service root.');
  return new Error(lines.join("\n"));
}

function discoverFiles(productDir, repoDiscovery = {}) {
  const suiteFiles = [];
  const legacyFiles = [];
  const exclude = [
    ...new Set([
      ...DEFAULT_DISCOVERY_EXCLUDES,
      ...((repoDiscovery.exclude || []).map(normalizePath)),
    ]),
  ];
  const queue = resolveDiscoveryRoots(productDir, repoDiscovery.roots, ".");

  while (queue.length > 0) {
    const current = queue.pop();
    for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
      const absolutePath = path.join(current, entry.name);

      if (entry.isSymbolicLink()) continue;

      if (entry.isDirectory()) {
        const relativeDirPath = normalizeDiscoveryPath(path.relative(productDir, absolutePath));
        if (shouldExcludeDiscoveryPath(relativeDirPath, exclude)) continue;
        queue.push(absolutePath);
        continue;
      }

      if (!entry.isFile()) continue;

      const relativePath = normalizePath(path.relative(productDir, absolutePath));
      if (!inferRule(relativePath)) continue;
      if (relativePath.split("/").includes(TESTKIT_DIRNAME)) {
        suiteFiles.push(relativePath);
      } else {
        legacyFiles.push(relativePath);
      }
    }
  }

  return {
    suiteFiles: suiteFiles.sort((left, right) => left.localeCompare(right)),
    legacyFiles: legacyFiles.sort((left, right) => left.localeCompare(right)),
  };
}

function inferOwners(filePath, explicitServices, repoDiscovery = {}) {
  const serviceRules = Object.entries(explicitServices).flatMap(([name, config]) =>
    buildServiceRules(name, config, repoDiscovery)
  );
  if (serviceRules.length === 0) {
    return [
      {
        name: "app",
        source: "implicit-root",
        cwdPrefix: ".",
      },
    ];
  }

  const relevantRules = serviceRules.filter(
    (rule) => rule.depth === 0 || (rule.matchPrefix && hasPrefix(filePath, rule.matchPrefix))
  );
  const owningRules = relevantRules.filter((rule) => ownsFile(rule, filePath));
  if (owningRules.length === 0) {
    if (relevantRules.length > 0 && relevantRules.every((rule) => isExcludedForServiceRule(rule, filePath))) {
      return null;
    }
    return [];
  }

  const maxDepth = Math.max(...owningRules.map((rule) => rule.depth));
  return owningRules.filter((rule) => rule.depth === maxDepth);
}

function disambiguateSuiteNames(suites) {
  const used = new Map();
  const resolved = new Map();

  for (const suite of suites) {
    const pathSegments = suite._suitePath;
    let candidate = "";

    for (let depth = 1; depth <= pathSegments.length; depth += 1) {
      candidate = pathSegments.slice(-depth).join("-");
      const existing = used.get(candidate);
      if (!existing) {
        used.set(candidate, suite._suiteKey);
        break;
      }
      if (existing === suite._suiteKey) break;
      candidate = "";
    }

    if (!candidate) {
      candidate = pathSegments.join("-");
    }

    resolved.set(suite._suiteKey, candidate);
  }

  if (resolved.size !== suites.length) {
    for (const suite of suites) {
      if (!resolved.has(suite._suiteKey)) {
        resolved.set(suite._suiteKey, suite._suitePath.join("-"));
      }
    }
  }

  if (new Set(resolved.values()).size !== suites.length) {
    const counts = new Map();
    for (const suite of suites) {
      const base = suite._suitePath.join("-");
      const count = counts.get(base) || 0;
      counts.set(base, count + 1);
      resolved.set(suite._suiteKey, count === 0 ? base : `${base}-${count + 1}`);
    }
  }

  return resolved;
}

function buildServiceRules(serviceName, serviceConfig, repoDiscovery = {}) {
  const cwd = normalizePath(serviceConfig?.local?.cwd || ".");
  const serviceDiscovery = normalizeDiscoveryConfig(serviceConfig?.discovery, { allowRoots: true });
  const discoveryRoots =
    Array.isArray(serviceDiscovery.roots) && serviceDiscovery.roots.length > 0 ? serviceDiscovery.roots : ["."];
  const serviceRootRelativeExcludes = (serviceDiscovery.exclude || []).map((entry) =>
    cwd === "." ? normalizePath(entry) : normalizePath(path.posix.join(cwd, entry))
  );
  const exclude = [
    ...new Set([
      ...DEFAULT_DISCOVERY_EXCLUDES,
      ...((repoDiscovery.exclude || []).map(normalizePath)),
      ...serviceRootRelativeExcludes,
    ]),
  ];

  return discoveryRoots.map((root) => {
    const normalizedRoot = normalizePath(root || ".");
    const matchPrefix =
      cwd === "."
        ? normalizedRoot
        : normalizedRoot === "."
          ? cwd
          : normalizePath(path.posix.join(cwd, normalizedRoot));
    return {
      name: serviceName,
      source: "cwd",
      cwdPrefix: cwd === "." ? null : cwd,
      matchPrefix: matchPrefix === "." ? null : matchPrefix,
      depth: matchPrefix === "." ? 0 : matchPrefix.split("/").filter(Boolean).length,
      exclude,
    };
  });
}

function ownsFile(serviceRule, filePath) {
  if (isExcludedForServiceRule(serviceRule, filePath)) return false;
  if (serviceRule.depth === 0) return true;
  if (serviceRule.matchPrefix && hasPrefix(filePath, serviceRule.matchPrefix)) return true;
  return false;
}

function relativeToServiceRoot(serviceRule, filePath) {
  if (serviceRule.matchPrefix && hasPrefix(filePath, serviceRule.matchPrefix)) {
    return path.posix.relative(serviceRule.matchPrefix, filePath);
  }
  if (serviceRule.cwdPrefix && hasPrefix(filePath, serviceRule.cwdPrefix)) {
    return path.posix.relative(serviceRule.cwdPrefix, filePath);
  }
  return filePath;
}

function isExcludedForServiceRule(serviceRule, filePath) {
  return shouldExcludeDiscoveryPath(normalizePath(filePath), serviceRule.exclude || []);
}

function deriveSuiteRef(relativePath, suffix) {
  const parts = relativePath.split("/").filter(Boolean);
  const testkitIndex = parts.indexOf(TESTKIT_DIRNAME);
  if (testkitIndex === -1) {
    const fallback = path.posix.basename(relativePath, suffix);
    return {
      suitePath: [fallback],
    };
  }

  const before = parts.slice(0, testkitIndex);
  const after = parts.slice(testkitIndex + 1);
  if (after.length === 0) {
    const fallback = path.posix.basename(relativePath, suffix);
    return {
      suitePath: [fallback],
    };
  }

  if (after.length === 1) {
    if (before.length === 0) {
      return {
        suitePath: [path.posix.basename(after[0], suffix)],
      };
    }
    return {
      suitePath: before,
    };
  }

  return {
    suitePath: [...before, after[0]],
  };
}

function mergeServiceDiscovery(existing, owner) {
  if (!existing) {
    return {
      name: owner.name,
      inferredLocalCwd: owner.cwdPrefix || ".",
      source: owner.source,
    };
  }

  return {
    ...existing,
    inferredLocalCwd:
      existing.inferredLocalCwd === "." ? owner.cwdPrefix || "." : existing.inferredLocalCwd,
  };
}

function inferRule(filePath) {
  return DISCOVERY_RULES.find((rule) => filePath.endsWith(rule.suffix)) || null;
}

function hasPrefix(filePath, prefix) {
  return filePath === prefix || filePath.startsWith(`${prefix}/`);
}

function normalizePath(value) {
  const normalized = String(value).split(path.sep).join("/");
  if (normalized === "." || normalized === "./") return ".";
  return normalized.replace(/^\.\/+/, "").replace(/\/+$/, "") || ".";
}

function buildLegacyFileDiagnostics(legacyFiles) {
  return legacyFiles.map((filePath) => ({
    code: "legacy_path",
    severity: "error",
    message: `Legacy test file outside __testkit__: ${filePath}`,
    path: filePath,
  }));
}

function findSuiteServiceName(groupedByService, grouped, suite) {
  for (const [serviceName, candidate] of Object.entries(groupedByService)) {
    if (candidate !== grouped) continue;
    for (const suites of Object.values(candidate)) {
      if (suites.includes(suite)) return serviceName;
    }
  }
  return "app";
}

function findSuiteType(grouped, targetSuites) {
  for (const [type, suites] of Object.entries(grouped)) {
    if (suites === targetSuites) return type;
  }
  return "integration";
}
