use super::*;
use crate::pipeline::{FILES_MATCH_OPTIONS, normalize_rel_path};
use std::ops::Deref;

/// Rule filtering logic encapsulated for clarity.
///
/// This trait and its implementation extract the rule enablement logic
/// from LintConfig, making it easier to test and maintain.
trait RuleFilter {
    /// Check if a specific rule is enabled based on config.
    fn is_rule_enabled(&self, rule_id: &str) -> bool;
}

/// Default implementation of rule filtering logic.
///
/// Determines whether a rule is enabled based on:
/// 1. Explicit disabled_rules list
/// 2. Target tool or tools array filtering
/// 3. Category enablement flags
struct DefaultRuleFilter<'a> {
    rules: &'a RuleConfig,
    target: TargetTool,
    tools: &'a [String],
}

impl<'a> DefaultRuleFilter<'a> {
    fn new(rules: &'a RuleConfig, target: TargetTool, tools: &'a [String]) -> Self {
        Self {
            rules,
            target,
            tools,
        }
    }

    /// Check if a rule applies to the current target tool(s)
    fn is_rule_for_target(&self, rule_id: &str) -> bool {
        // If tools array is specified, use it for filtering
        if !self.tools.is_empty() {
            return self.is_rule_for_tools(rule_id);
        }

        // Legacy: CC-* rules only apply to ClaudeCode or Generic targets
        if rule_id.starts_with("CC-") {
            return matches!(self.target, TargetTool::ClaudeCode | TargetTool::Generic);
        }
        // All other rules apply to all targets (see TOOL_RULE_PREFIXES for tool-specific rules)
        true
    }

    /// Check if a rule applies based on the tools array
    fn is_rule_for_tools(&self, rule_id: &str) -> bool {
        for (prefix, tool) in agnix_rules::TOOL_RULE_PREFIXES {
            if rule_id.starts_with(prefix) {
                // Check if the required tool is in the tools list (case-insensitive)
                // Also accept backward-compat aliases (e.g., "copilot" for "github-copilot")
                return self
                    .tools
                    .iter()
                    .any(|t| t.eq_ignore_ascii_case(tool) || Self::is_tool_alias(t, tool));
            }
        }

        // Generic rules (AS-*, XML-*, REF-*, XP-*, AGM-*, MCP-*, PE-*) apply to all tools
        true
    }

    /// Check if a user-provided tool name is a backward-compatible alias
    /// for the canonical tool name from rules.json.
    ///
    /// Currently only "github-copilot" has an alias ("copilot"). This exists for
    /// backward compatibility: early versions of agnix used the shorter "copilot"
    /// name in configs, and we need to continue supporting that for existing users.
    /// The canonical names in rules.json use the full "github-copilot" to match
    /// the official tool name from GitHub's documentation.
    ///
    /// Note: This function does NOT treat canonical names as aliases of themselves.
    /// For example, "github-copilot" is NOT an alias for "github-copilot" - that's
    /// handled by the direct eq_ignore_ascii_case comparison in is_rule_for_tools().
    fn is_tool_alias(user_tool: &str, canonical_tool: &str) -> bool {
        // Backward compatibility: accept short names as aliases
        match canonical_tool {
            "github-copilot" => user_tool.eq_ignore_ascii_case("copilot"),
            _ => false,
        }
    }

    /// Check if a rule's category is enabled
    fn is_category_enabled(&self, rule_id: &str) -> bool {
        match rule_id {
            s if [
                "AS-", "CC-SK-", "CR-SK-", "CL-SK-", "CP-SK-", "CX-SK-", "OC-SK-", "WS-SK-",
                "KR-SK-", "AMP-SK-", "RC-SK-",
            ]
            .iter()
            .any(|p| s.starts_with(p)) =>
            {
                self.rules.skills
            }
            s if s.starts_with("AMP-") => self.rules.amp_checks,
            s if s.starts_with("CC-HK-") => self.rules.hooks,
            s if s.starts_with("CC-AG-") => self.rules.agents,
            s if s.starts_with("CC-MEM-") => self.rules.memory,
            s if s.starts_with("CC-OS-") => self.rules.output_styles,
            s if s.starts_with("CC-PL-") => self.rules.plugins,
            s if s.starts_with("XML-") => self.rules.xml,
            s if s.starts_with("MCP-") => self.rules.mcp,
            s if s.starts_with("REF-") || s.starts_with("imports::") => self.rules.imports,
            s if s.starts_with("XP-") => self.rules.cross_platform,
            s if s.starts_with("AGM-") => self.rules.agents_md,
            s if s.starts_with("COP-") => self.rules.copilot,
            s if s.starts_with("CUR-") => self.rules.cursor,
            s if s.starts_with("CLN-") => self.rules.cline,
            s if s.starts_with("OC-") => self.rules.opencode,
            s if s.starts_with("GM-") => self.rules.gemini_md,
            s if s.starts_with("CDX-") => self.rules.codex,
            s if s.starts_with("ROO-") => self.rules.roo_code,
            s if s.starts_with("WS-") => self.rules.windsurf,
            // Kiro category routing:
            // - Steering umbrella: steering files, IDE hooks, MCP, and power metadata checks
            // - Agents: agent JSON rules and CLI hook checks nested under agents
            s if s.starts_with("KIRO-")
                || s.starts_with("KR-HK-001")
                || s.starts_with("KR-HK-002")
                || s.starts_with("KR-HK-003")
                || s.starts_with("KR-HK-004")
                || s.starts_with("KR-MCP-")
                || s.starts_with("KR-PW-") =>
            {
                self.rules.kiro_steering
            }
            s if s.starts_with("KR-AG-")
                || s.starts_with("KR-HK-005")
                || s.starts_with("KR-HK-006") =>
            {
                self.rules.kiro_agents
            }
            s if s.starts_with("PE-") => self.rules.prompt_engineering,
            // Unknown rules are enabled by default
            _ => true,
        }
    }
}

impl RuleFilter for DefaultRuleFilter<'_> {
    fn is_rule_enabled(&self, rule_id: &str) -> bool {
        // Check if explicitly disabled
        if self.rules.disabled_rules.iter().any(|r| r == rule_id) {
            return false;
        }

        // Check if rule applies to target
        if !self.is_rule_for_target(rule_id) {
            return false;
        }

        // Check if category is enabled
        self.is_category_enabled(rule_id)
    }
}

impl LintConfig {
    // =========================================================================
    // Rule Filtering (delegates to DefaultRuleFilter)
    // =========================================================================

    /// Check if a specific rule is enabled based on config
    ///
    /// A rule is enabled if:
    /// 1. It's not in the disabled_rules list
    /// 2. It's applicable to the current target tool
    /// 3. Its category is enabled
    ///
    /// This delegates to `DefaultRuleFilter` which encapsulates the filtering logic.
    pub fn is_rule_enabled(&self, rule_id: &str) -> bool {
        let filter = DefaultRuleFilter::new(&self.data.rules, self.data.target, &self.data.tools);
        filter.is_rule_enabled(rule_id)
    }

    /// Check if a user-provided tool name is a backward-compatible alias
    /// for the canonical tool name from rules.json.
    ///
    /// Currently only "github-copilot" has an alias ("copilot"). This exists for
    /// backward compatibility: early versions of agnix used the shorter "copilot"
    /// name in configs, and we need to continue supporting that for existing users.
    /// The canonical names in rules.json use the full "github-copilot" to match
    /// the official tool name from GitHub's documentation.
    ///
    /// Note: This function does NOT treat canonical names as aliases of themselves.
    /// For example, "github-copilot" is NOT an alias for "github-copilot" - that's
    /// handled by the direct eq_ignore_ascii_case comparison in is_rule_for_tools().
    pub fn is_tool_alias(user_tool: &str, canonical_tool: &str) -> bool {
        DefaultRuleFilter::is_tool_alias(user_tool, canonical_tool)
    }

    /// Build a per-file view of this config, applying `[[overrides]]`
    /// whose `paths` globs match `path`.
    ///
    /// The returned [`PerFileLintConfig`] dereferences to `&LintConfig`,
    /// so all `LintConfig` accessors (e.g., `rules()`, `target()`,
    /// `fs()`, `root_dir()`) are available unchanged. Only
    /// [`PerFileLintConfig::is_rule_enabled`] differs from the global
    /// behavior: it returns `false` for rule IDs disabled by any matching
    /// override, falling through to [`LintConfig::is_rule_enabled`]
    /// otherwise.
    ///
    /// **Symlinked paths**: when `root_dir` has been canonicalized by the
    /// caller (e.g., the CLI canonicalizes the project root) but `path`
    /// retains a symlink prefix (e.g., `~/.claude/CLAUDE.md` where
    /// `~/.claude` is a dotfile-manager symlink), the direct
    /// `strip_prefix` would fail. `for_path` detects this and retries
    /// once via the configured [`FileSystem::canonicalize`] so overrides
    /// still match. Non-symlinked paths pay no syscall cost.
    pub fn for_path(&self, path: &Path) -> PerFileLintConfig<'_> {
        // Empty fast-path: skip matching entirely when there are no overrides.
        if self.runtime.compiled_overrides.is_empty() {
            return PerFileLintConfig {
                config: self,
                extra_disabled: Vec::new(),
            };
        }

        let rel_path = if let Some(root) = self.runtime.root_dir.as_ref() {
            let direct = normalize_rel_path(path, root);
            // `strip_prefix` failure leaves `direct` absolute. Common cause:
            // the caller canonicalized `root_dir` (e.g. the CLI canonicalizes
            // the project root via `std::fs::canonicalize`) but `path` still
            // carries a symlink prefix — a typical pattern with dotfile
            // managers (`~/.claude` symlinked into a `mackup` / `stow` /
            // `chezmoi` store). Retry once with `path` canonicalized so the
            // override actually matches in that case.
            if std::path::Path::new(&direct).is_absolute() {
                match self.runtime.fs.canonicalize(path) {
                    Ok(canonical) => normalize_rel_path(&canonical, root),
                    Err(_) => direct,
                }
            } else {
                direct
            }
        } else {
            // No root_dir: match against filename only, like resolve_with_compiled.
            path.file_name()
                .and_then(|n| n.to_str())
                .unwrap_or("")
                .to_string()
        };

        let mut extra_disabled: Vec<&str> = Vec::new();
        for (compiled, src) in self
            .runtime
            .compiled_overrides
            .iter()
            .zip(self.data.overrides.iter())
        {
            let matched = compiled
                .patterns
                .iter()
                .any(|p| p.matches_with(&rel_path, FILES_MATCH_OPTIONS));
            if matched {
                extra_disabled.extend(src.disabled_rules.iter().map(String::as_str));
            }
        }

        PerFileLintConfig {
            config: self,
            extra_disabled,
        }
    }
}

/// Per-file view of a [`LintConfig`] with `[[overrides]]` applied.
///
/// Validators receive `&PerFileLintConfig<'_>` instead of `&LintConfig`.
/// It [`Deref`]s to `&LintConfig`, so all existing accessor calls
/// (`config.rules()`, `config.fs()`, `config.root_dir()`, …) continue to
/// work unchanged. Only [`Self::is_rule_enabled`] is overridden, layering
/// per-file disabled rules on top of the global rule filter.
///
/// Construct via [`LintConfig::for_path`].
pub struct PerFileLintConfig<'a> {
    config: &'a LintConfig,
    /// Rule IDs disabled by matching `[[overrides]]`, borrowed from the
    /// parent config's `overrides[*].disabled_rules`.
    extra_disabled: Vec<&'a str>,
}

impl<'a> Deref for PerFileLintConfig<'a> {
    type Target = LintConfig;

    fn deref(&self) -> &LintConfig {
        self.config
    }
}

impl PerFileLintConfig<'_> {
    /// Check if a rule is enabled for this file.
    ///
    /// Returns `false` if the rule is listed in any `[[overrides]]` block
    /// whose `paths` matched this file; otherwise delegates to
    /// [`LintConfig::is_rule_enabled`].
    pub fn is_rule_enabled(&self, rule_id: &str) -> bool {
        if self.extra_disabled.contains(&rule_id) {
            return false;
        }
        self.config.is_rule_enabled(rule_id)
    }
}
