################################################################################
################                    AAindex1                   #################
################################################################################

#importing required modules and dependencies
import difflib
import json
import math
import os
import sys
import copy
import re
import csv
import warnings
from importlib.metadata import version as _pkg_version, PackageNotFoundError
from typing import Any, Iterator

from ._aaindex_matrix import Map, _write_json_cache

__all__: list[str] = ['AAIndex1', 'aaindex1']


class AAIndex1():
    """Python parser for AAindex1: Amino Acid Index Database.

    The AAindex is a database of numerical indices representing various
    physicochemical and biochemical properties of amino acids. This class
    stores the amino acid index of 20 numerical values for the 20 amino
    acids — AAindex1 (http://www.genome.jp/aaindex/).

    Attributes:
        aaindex_module_path: Absolute path to the aaindex package directory.
        data_dir: Subdirectory name containing raw and cached data files.
        aaindex_filename: Base filename for this database (no extension).
        aaindex_json: Parsed database keyed by accession number.
        categories: Dict mapping each record code to its category.
        last_updated: Date string of the last published database update.
    """
    def __init__(self) -> None:
        #resolve the package directory for data file lookups
        self.aaindex_module_path = os.path.dirname(os.path.abspath(sys.modules[self.__module__].__file__))
        self.data_dir = "data"
        self.aaindex_filename = "aaindex1"
        #date as shown on https://www.genome.jp/aaindex/
        self.last_updated = "February 13, 2017"
        #database and categories are loaded lazily on first access
        self._aaindex_json: dict | None = None
        self._categories: dict | None = None
        self._amino_acids_cache: list[str] | None = None
        try:
            self.version = _pkg_version("aaindex")
        except PackageNotFoundError:
            self.version = "unknown"

    def _load_data(self) -> None:
        """Load the database from the JSON cache, or parse the raw file if the cache is absent."""
        json_path = os.path.join(self.aaindex_module_path, self.data_dir, f"{self.aaindex_filename}.json")
        if os.path.isfile(json_path):
            try:
                with open(json_path) as aai_json:
                    self._aaindex_json = json.load(aai_json)
            except (json.JSONDecodeError, OSError) as e:
                #B3: an unreadable or truncated cache must not be fatal — fall back to the raw file
                warnings.warn(
                    f"Cached database at {json_path} is unreadable ({e}); "
                    f"re-parsing the raw AAindex1 file.",
                    UserWarning, stacklevel=2,
                )
                self._aaindex_json = self._parse_aaindex()
                self._set_amino_acids_cache()
                return
            #normalise types that may differ in older cached JSON
            for record in self._aaindex_json.values():
                cc = record.get('correlation_coefficients', {})
                for k, v in list(cc.items()):
                    if isinstance(v, str):
                        try:
                            cc[k] = float(v)
                        except ValueError:
                            pass
                vals = record.get('values', {})
                if '-' in vals and isinstance(vals['-'], int):
                    vals['-'] = float(vals['-'])
        else:
            self._aaindex_json = self._parse_aaindex()
        self._set_amino_acids_cache()

    def _set_amino_acids_cache(self) -> None:
        """Cache the amino acid codes taken from the first record's values dict."""
        if not self._aaindex_json:
            self._amino_acids_cache = []
            return
        self._amino_acids_cache = sorted(
            self._aaindex_json[next(iter(self._aaindex_json))]["values"].keys()
        )

    def _parse_aaindex(self) -> dict:
        """Parse the raw AAindex1 database file into a nested dict and cache as JSON.

        Each record is keyed by its accession number and stores metadata, amino
        acid values, and category. The result is written to a .json file in the
        data directory for fast subsequent loads.

        Returns:
            Parsed database keyed by accession number.

        Raises:
            IOError: If the raw database file cannot be opened.
            ValueError: If a duplicate accession number is encountered.
        """
        #initialise keys of AAi database
        template_dict = {
            "H": [], "D": [], "R": [], "A": [],
            "*": [], "T": [], "J": [], "C": [], "I": [],
        }

        #open AAi file for reading and parsing
        tmp_filepath = os.path.join(self.aaindex_module_path, self.data_dir, self.aaindex_filename)
        try:
            with open(tmp_filepath) as f:
                lines = f.readlines()
        except OSError as e:
            raise OSError(f"Error opening AAindex1 file, check file is in filepath: {tmp_filepath}.") from e

        #regex to normalise double-quote characters in field values
        clean_up_pattern = re.compile("\"")

        aaindex_json: dict = {}
        current_dict = copy.deepcopy(template_dict)
        current_entry: str = "H"  # first non-space line in any block is always H

        #iterate through each line, parsing records delimited by '//'
        for line in lines:
            if line.startswith("//"):

                #handle meta data of each record
                name = clean_up_pattern.sub("'", " ".join(current_dict["H"]))
                description = clean_up_pattern.sub("'", " ".join(current_dict["D"]))

                #append author, title and journal name to reference
                a = " ".join(current_dict["A"])
                t = " ".join(current_dict["T"])
                j = " ".join(current_dict["J"])
                references = clean_up_pattern.sub("'", f"{a} '{t}' {j}")

                #parse pub med article ID
                pmid = clean_up_pattern.sub("'", " ".join(current_dict["R"]))
                pmid = pmid.replace("PMID:", "")

                #parse correlation coefficients into a dict (values stored as float)
                correlation_coefficient = clean_up_pattern.sub("'", " ".join(current_dict["C"]))
                correlation_coefficient_ = {}
                correlation_coefficient_list = [
                    correlation_coefficient.split()[n:n + 2]
                    for n in range(0, len(correlation_coefficient.split()), 2)
                ]
                for correlation in correlation_coefficient_list:
                    if len(correlation) == 2:
                        try:
                            correlation_coefficient_[correlation[0]] = float(correlation[1])
                        except ValueError:
                            pass

                #parse notes from record
                notes = clean_up_pattern.sub("'", " ".join(current_dict["*"]))

                #parse individual amino acid values from I-lines
                aa_lines = current_dict["I"]
                if len(aa_lines) < 3:
                    warnings.warn(
                        f"Record {name!r} has a malformed I-block ({len(aa_lines)} line(s)); skipping.",
                        UserWarning, stacklevel=2,
                    )
                    current_dict = copy.deepcopy(template_dict)
                    continue
                #B4: header entries are 'X/Y' pairs — skip any that are malformed rather
                #than letting the [1] index raise
                aa_names = [aa for aa in aa_lines[0].split() if "/" in aa]
                row_0_names = [aa.split("/")[0] for aa in aa_names]
                row_1_names = [aa.split("/")[1] for aa in aa_names]
                row_0_values = aa_lines[1].split()
                row_1_values = aa_lines[2].split()

                #B4: the two value rows and the header must line up; a short row previously
                #raised a bare IndexError instead of the warn-and-skip path used elsewhere
                n_values = min(
                    len(row_0_names), len(row_1_names), len(row_0_values), len(row_1_values)
                )
                if not (
                    len(row_0_names) == len(row_1_names)
                    == len(row_0_values) == len(row_1_values)
                ):
                    warnings.warn(
                        f"Record {name!r} has a mismatched I-block "
                        f"({len(aa_names)} headers, {len(row_0_values)}/{len(row_1_values)} "
                        f"values); parsing the first {n_values} column(s) only.",
                        UserWarning, stacklevel=2,
                    )

                values: dict = {}
                for i in range(n_values):
                    try:
                        values[row_0_names[i]] = float(row_0_values[i])
                    except ValueError:
                        values[row_0_names[i]] = None
                    try:
                        values[row_1_names[i]] = float(row_1_values[i])
                    except ValueError:
                        values[row_1_names[i]] = None

                #guard against duplicate accession numbers
                if name in aaindex_json:
                    raise ValueError(f"Duplicate AAi Record found: {name}.")

                aaindex_json[name] = {
                    "description": description,
                    "references": references,
                    "pmid": pmid,
                    "correlation_coefficients": correlation_coefficient_,
                    "notes": notes,
                    "values": values,
                }

                current_dict = copy.deepcopy(template_dict)
                continue

            #blank lines carry no field code — skip before indexing to avoid a spurious
            #warning (and an IndexError on a file with no trailing newline)
            if not line.strip():
                continue

            #B7: guard against unknown field codes to prevent KeyError
            if line[0] != " ":
                if line[0] not in current_dict:
                    warnings.warn(
                        f"Unknown field code {line[0]!r} in AAindex1 file; line ignored.",
                        UserWarning, stacklevel=2,
                    )
                    continue
                current_entry = line[0]

            current_dict[current_entry].append(line[1:].strip())

        #post-process: assign category and '-' gap placeholder (NA stays as None)
        #B5: a record absent from the category file must not abort the whole parse — the
        #category file is maintained separately and lags behind new AAindex releases
        categories = self.categories
        uncategorised = [index for index in aaindex_json if index not in categories]
        if uncategorised:
            warnings.warn(
                f"{len(uncategorised)} record(s) have no entry in the category file and were "
                f"assigned category 'unknown': {uncategorised[:5]}"
                f"{'...' if len(uncategorised) > 5 else ''}",
                UserWarning, stacklevel=2,
            )
        for index in aaindex_json:
            aaindex_json[index]['category'] = categories.get(index, 'unknown')
            aaindex_json[index]['values']['-'] = 0.0

        #cache parsed database as JSON for fast subsequent loads; non-fatal if the data dir is read-only
        json_out_path = os.path.join(
            self.aaindex_module_path, self.data_dir, f"{self.aaindex_filename}.json"
        )
        _write_json_cache(json_out_path, aaindex_json)

        return aaindex_json

    def parse_aaindex(self) -> dict:
        """Deprecated: use _parse_aaindex() instead.

        .. deprecated::
            This method is an internal implementation detail and will be
            removed in a future version.
        """
        warnings.warn(
            "parse_aaindex() is deprecated and will be removed in a future version. "
            "It is an internal implementation detail.",
            DeprecationWarning,
            stacklevel=2,
        )
        return self._parse_aaindex()

    def _parse_categories(self, aaindex_category_file: str = 'aaindex_to_category.txt') -> dict:
        """Parse category file mapping each AAi record to one of 8 categories.

        Category file and parsing code inspired from https://github.com/harmslab/hops.

        Accepts only a bare filename (resolved against the data directory) or an
        absolute path. Relative paths containing directory components are rejected
        to prevent path traversal.

        Args:
            aaindex_category_file: Bare filename or absolute path of the category
                mapping file. Defaults to ``aaindex_to_category.txt`` in the data
                directory.

        Returns:
            Dict mapping each record code to its category string.

        Raises:
            ValueError: If *aaindex_category_file* contains path separators and is
                not an absolute path.
            IOError: If the category file cannot be opened.
        """
        #S1 fix: reject relative paths with directory components to prevent traversal
        if os.path.isabs(aaindex_category_file):
            category_filepath = aaindex_category_file
        elif os.path.basename(aaindex_category_file) != aaindex_category_file:
            raise ValueError(
                f"aaindex_category_file must be a bare filename or an absolute path; "
                f"relative paths with directory components are not allowed, "
                f"got: {aaindex_category_file!r}"
            )
        else:
            category_filepath = os.path.join(self.aaindex_module_path, self.data_dir, aaindex_category_file)

        try:
            with open(category_filepath) as f:
                category_lines = f.readlines()
        except OSError as e:
            raise OSError(f"Error opening AAindex1 category file: {category_filepath}.") from e

        #B7 fix: filter comment lines in memory — no intermediate file write needed
        aaindex_category: dict = {}
        reader = csv.reader(
            (line for line in category_lines if not line.startswith('#')),
            delimiter="\t",
        )
        for row in reader:
            if len(row) >= 2:
                category_substring = row[1].strip().split(" ", 1)
                aaindex_category[row[0]] = category_substring[0]

        return aaindex_category

    def parse_categories(self, aaindex_category_file: str = 'aaindex_to_category.txt') -> dict:
        """Deprecated: use _parse_categories() instead.

        .. deprecated::
            This method is an internal implementation detail and will be
            removed in a future version.
        """
        warnings.warn(
            "parse_categories() is deprecated and will be removed in a future version. "
            "It is an internal implementation detail.",
            DeprecationWarning,
            stacklevel=2,
        )
        return self._parse_categories(aaindex_category_file)

    def get_all_categories(self, category_file: str = "aaindex_to_category.txt") -> dict:
        """Return dict mapping every record code to its category.

        Delegates to :meth:`_parse_categories`.

        Args:
            category_file: Bare filename or absolute path of the category mapping
                file. Defaults to ``aaindex_to_category.txt`` in the data directory.

        Returns:
            Dict mapping each record code to its category string.
        """
        return self._parse_categories(category_file)

    def search(self, query: str | list[str]) -> dict:
        """Search records by keyword(s) across all text fields.

        Searches the description, accession code, PMID, references, and notes
        fields of each record. Matching is case-insensitive. Results are returned
        sorted by accession number.

        Args:
            query: Keyword string or list of keyword strings.

        Returns:
            Dict of matching records keyed by accession number, sorted
            alphabetically. Returns an empty dict if no records match.

        Raises:
            TypeError: If query is not a str or list.
        """
        all_indices: dict = {}

        if not isinstance(query, (list, str)):
            raise TypeError(f"Input query parameter must be a list or str, got {type(query)}.")

        #convert query parameter to list to make iterable
        if not isinstance(query, list):
            query = [query]

        #iterate over query list, matching keywords case-insensitively across all text fields
        for term in query:
            term_lower = term.lower()
            for index, record in self.aaindex_json.items():
                if (
                    term_lower in record['description'].lower()
                    or term_lower in index.lower()
                    or term_lower in record['pmid'].lower()
                    or term_lower in record['references'].lower()
                    or term_lower in record['notes'].lower()
                    or term_lower in record.get('category', '').lower()
                ):
                    all_indices[index] = record

        #I6: return results sorted by accession code for deterministic ordering
        #B2: copy so callers cannot mutate the database through search results
        return copy.deepcopy(dict(sorted(all_indices.items())))

    def search_fuzzy(
        self,
        query: str,
        n: int = 10,
        cutoff: float = 0.0,
    ) -> dict:
        """Search records using fuzzy matching with ranked results.

        Scores each record by how closely *query* matches any of its text
        fields (description, accession code, PMID, references, notes, category)
        using ``difflib.SequenceMatcher``. Results are returned in descending
        score order (most relevant first) with no external dependencies.

        Args:
            query: Search string.
            n: Maximum number of results to return. Defaults to ``10``.
            cutoff: Minimum similarity score in [0, 1] to include a record.
                    Defaults to ``0.0`` (all records scored, top *n* returned).

        Returns:
            Dict of matching records keyed by accession number, ordered by
            descending similarity score.

        Raises:
            TypeError: If *query* is not a str.
            ValueError: If *n* < 1 or *cutoff* is outside [0, 1].
        """
        if not isinstance(query, str):
            raise TypeError(f"query must be a str, got {type(query)}.")
        if not isinstance(n, int) or n < 1:
            raise ValueError(f"n must be a positive int, got {n}.")
        if not (0.0 <= cutoff <= 1.0):
            raise ValueError(f"cutoff must be in [0, 1], got {cutoff}.")
        query_lower = query.lower()
        scored: list[tuple[float, str]] = []
        for code, record in self.aaindex_json.items():
            candidate_fields = [
                record.get("description", ""),
                code,
                record.get("pmid", ""),
                record.get("references", ""),
                record.get("notes", ""),
                record.get("category", ""),
            ]
            score = max(
                difflib.SequenceMatcher(None, query_lower, field.lower()).ratio()
                for field in candidate_fields
                if field
            )
            if score >= cutoff:
                scored.append((score, code))
        scored.sort(key=lambda x: x[0], reverse=True)
        return {code: copy.deepcopy(self.aaindex_json[code]) for _, code in scored[:n]}

    def amino_acids(self) -> list[str]:
        """Return sorted list of amino acid single-letter codes.

        Includes the ``-`` placeholder for absent/gap amino acids.

        Returns:
            Sorted list of amino acid codes including ``-``.
        """
        #trigger lazy load if necessary (also sets _amino_acids_cache)
        _ = self.aaindex_json
        #B2: return a copy so a caller mutating the list cannot poison the cache
        return list(self._amino_acids_cache)

    def record_codes(self) -> list[str]:
        """Return sorted list of all accession numbers in the database.

        Returns:
            Sorted list of accession number strings.
        """
        records = list(self.aaindex_json.keys())
        records.sort()
        return records

    def num_records(self) -> int:
        """Return the total number of records in the database.

        Returns:
            Number of records as int.
        """
        return len(self.aaindex_json)

    def record_names(self) -> list[str]:
        """Return a list of description strings for all records.

        Returns:
            List of description strings in database insertion order.
        """
        return [v['description'] for v in self.aaindex_json.values()]

    def values(self, record_code: str) -> dict:
        """Return the amino acid values dict for a given record.

        Shortcut to avoid accessing the full record when only
        the values are needed.

        Args:
            record_code: AAindex accession number.

        Returns:
            Dict of amino acid values for the specified record.

        Raises:
            ValueError: If record_code is not found in the database.
        """
        return self[record_code]['values']

    def valid_categories(self) -> list[str]:
        """Return a sorted list of all distinct category strings in the database.

        Returns:
            Sorted list of valid category name strings.
        """
        return sorted({record['category'] for record in self.aaindex_json.values()})

    def get_record_by_category(self, category: str) -> dict:
        """Return all records belonging to a given category.

        Args:
            category: Category name to filter records by (case-insensitive).

        Returns:
            Dict of matching records keyed by accession number.

        Raises:
            TypeError: If category is not a string.
        """
        if not isinstance(category, str):
            raise TypeError(f"Input category parameter must be a str, got {type(category)}.")

        #validate the category against known values; raise with a helpful hint if unknown
        valid = self.valid_categories()
        valid_lower = [c.lower() for c in valid]
        if category.lower() not in valid_lower:
            close = difflib.get_close_matches(category.lower(), valid_lower, n=3, cutoff=0.6)
            if close:
                close_actual = [valid[valid_lower.index(c)] for c in close]
                hint = f" Did you mean: {close_actual}?"
            else:
                hint = f" Valid categories are: {valid}."
            raise ValueError(f"Category '{category}' not found in AAindex1.{hint}")

        #filter records by matching category field
        #B2: copy so callers cannot mutate the database through the returned records
        category_records = {
            code: copy.deepcopy(record) for code, record in self.aaindex_json.items()
            if record.get('category', '').lower() == category.lower()
        }
        return category_records

    def encode(
        self,
        sequence: str,
        record_code: str,
        gap_value: float = 0.0,
        unknown_value: float | None = None,
    ) -> list[float | None]:
        """Encode a protein sequence as a numeric feature vector using one index.

        Each amino acid in *sequence* is replaced by its corresponding value
        from the named AAindex1 record. Gaps (``-``) are replaced by
        *gap_value*. Amino acids whose value is NA in the source data are
        represented as ``None``. Amino acids not in the 20 canonical set are
        represented as *unknown_value* (default ``None``).

        Args:
            sequence: Protein sequence string of single-letter amino acid codes.
                      Case-insensitive.
            record_code: AAindex1 accession number.
            gap_value: Numeric value substituted for gap characters (``-``).
                       Defaults to ``0.0``.
            unknown_value: Value substituted for non-canonical amino acid
                           characters (e.g. ``B``, ``X``, ``Z``). Defaults to
                           ``None``. Set to a float (e.g. ``0.0``) to fill
                           unknowns with a fixed value instead.

        Returns:
            List of floats (or ``None``) with the same length as *sequence*.

        Raises:
            TypeError: If *sequence* is not a string.
            ValueError: If *record_code* is not found in the database.

        Example:
            >>> aaindex1.encode('ACDE', 'KYTJ820101')
            [1.8, 2.5, -3.5, -3.5]
        """
        if not isinstance(sequence, str):
            raise TypeError(f"sequence must be a str, got {type(sequence)}.")
        values = self[record_code]['values']
        result: list[float | None] = []
        for aa in sequence.upper():
            if aa == '-':
                result.append(gap_value)
            elif aa in values:
                result.append(values[aa])  # None if NA value in source data
            else:
                result.append(unknown_value)  # non-canonical amino acid
        return result

    def batch_encode(
        self,
        sequences: list[str],
        record_code: str,
        gap_value: float = 0.0,
        unknown_value: float | None = None,
    ) -> list[list[float | None]]:
        """Encode multiple protein sequences as numeric feature vectors using one index.

        Convenience wrapper around :meth:`encode` for processing a collection of
        sequences with a single AAindex1 record in one call. The record is looked
        up once and reused for all sequences, making this more efficient than
        calling :meth:`encode` in a loop.

        Args:
            sequences: List of protein sequence strings of single-letter amino
                       acid codes. Case-insensitive.
            record_code: AAindex1 accession number.
            gap_value: Numeric value substituted for gap characters (``-``).
                       Defaults to ``0.0``.
            unknown_value: Value substituted for non-canonical amino acid
                           characters. Defaults to ``None``.

        Returns:
            List of encoded sequences; each inner list has the same length as the
            corresponding input sequence and contains floats or ``None``.

        Raises:
            TypeError: If *sequences* is not a list, or any element is not a str.
            ValueError: If *record_code* is not found in the database.

        Example:
            >>> aaindex1.batch_encode(['ACDE', 'FGHI'], 'KYTJ820101')
            [[1.8, 2.5, -3.5, -3.5], [-3.2, -0.4, -3.2, -3.2]]
        """
        if not isinstance(sequences, list):
            raise TypeError(f"sequences must be a list, got {type(sequences)}.")
        values = self[record_code]['values']
        result: list[list[float | None]] = []
        for i, seq in enumerate(sequences):
            if not isinstance(seq, str):
                raise TypeError(f"sequences[{i}] must be a str, got {type(seq)}.")
            encoded: list[float | None] = []
            for aa in seq.upper():
                if aa == '-':
                    encoded.append(gap_value)
                elif aa in values:
                    encoded.append(values[aa])
                else:
                    encoded.append(unknown_value)
            result.append(encoded)
        return result

    def multi_encode(
        self,
        sequence: str,
        record_codes: list[str],
        gap_value: float = 0.0,
        unknown_value: float | None = None,
    ) -> dict[str, list[float | None]]:
        """Encode a protein sequence against multiple AAindex1 records at once.

        Encodes *sequence* using each record in *record_codes* and returns a
        dict mapping each accession number to its encoded vector. Each record is
        validated before encoding begins, so an invalid code raises immediately.

        A common use case is ML feature extraction, where a sequence must be
        represented as a matrix of physicochemical descriptors.

        Args:
            sequence: Protein sequence string of single-letter amino acid codes.
                      Case-insensitive.
            record_codes: List of AAindex1 accession numbers.
            gap_value: Numeric value substituted for gap characters (``-``).
                       Defaults to ``0.0``.
            unknown_value: Value substituted for non-canonical amino acid
                           characters. Defaults to ``None``.

        Returns:
            Dict mapping each accession number to its encoded vector (a list of
            floats or ``None`` with the same length as *sequence*).

        Raises:
            TypeError: If *sequence* is not a str or *record_codes* is not a list.
            ValueError: If any accession number in *record_codes* is not found.

        Example:
            >>> aaindex1.multi_encode('ACDE', ['KYTJ820101', 'EISD840101'])
            {'KYTJ820101': [1.8, 2.5, -3.5, -3.5], 'EISD840101': [...]}
        """
        if not isinstance(sequence, str):
            raise TypeError(f"sequence must be a str, got {type(sequence)}.")
        if not isinstance(record_codes, list):
            raise TypeError(f"record_codes must be a list, got {type(record_codes)}.")
        return {
            code: self.encode(sequence, code, gap_value, unknown_value)
            for code in record_codes
        }

    def get_correlated_indices(
        self,
        record_code: str,
        min_correlation: float = 0.8,
        depth: int = 1,
    ) -> dict[str, float]:
        """Return a dict of indices correlated with *record_code*.

        Traverses the ``correlation_coefficients`` links stored on each record
        (pre-computed by the AAindex authors for |r| ≥ 0.8 pairs) up to
        *depth* hops from the seed record. Only indices whose stored |r| meets
        *min_correlation* are included.

        Args:
            record_code: Seed AAindex1 accession number.
            min_correlation: Minimum absolute correlation coefficient to
                             include a neighbour. Must be in [0, 1].
                             Defaults to ``0.8``.
            depth: Number of hops to traverse from the seed. ``1`` returns
                   only direct neighbours; ``2`` also includes their
                   neighbours, etc. Defaults to ``1``.

        Returns:
            Dict mapping each correlated accession number to its stored
            correlation coefficient (float). The seed record itself is
            excluded from the result.

        Raises:
            TypeError: If *min_correlation* or *depth* are of the wrong type.
            ValueError: If *record_code* is not found, or if *min_correlation*
                        is outside [0, 1], or if *depth* is less than 1.

        Example:
            >>> aaindex1.get_correlated_indices('KYTJ820101', depth=1)
            {'EISD840101': 0.949, ...}
        """
        if not isinstance(min_correlation, (int, float)):
            raise TypeError(f"min_correlation must be a float, got {type(min_correlation)}.")
        if not isinstance(depth, int):
            raise TypeError(f"depth must be an int, got {type(depth)}.")
        if not (0.0 <= min_correlation <= 1.0):
            raise ValueError(f"min_correlation must be in [0, 1], got {min_correlation}.")
        if depth < 1:
            raise ValueError(f"depth must be >= 1, got {depth}.")

        #validate seed — __getitem__ raises ValueError if not found
        self[record_code]

        visited: dict[str, float] = {}
        frontier = {record_code.strip().upper()}

        for _ in range(depth):
            next_frontier: set = set()
            for code in frontier:
                if code not in self.aaindex_json:
                    continue
                for neighbour, coeff_str in self.aaindex_json[code]['correlation_coefficients'].items():
                    try:
                        coeff = float(coeff_str)
                    except (ValueError, TypeError):
                        continue
                    if abs(coeff) >= min_correlation and neighbour not in visited:
                        visited[neighbour] = coeff
                        next_frontier.add(neighbour)
            frontier = next_frontier - {record_code.strip().upper()}

        #exclude the seed from output
        seed_upper = record_code.strip().upper()
        visited.pop(seed_upper, None)
        return visited

    def compare_indices(
        self,
        code1: str,
        code2: str,
    ) -> float | None:
        """Compute the Pearson correlation coefficient between two AAindex1 records.

        Uses only the amino acids that have a non-``None`` value in *both*
        records (i.e. NA positions are excluded pairwise). Returns ``None``
        when fewer than two valid positions remain after masking.

        Args:
            code1: First AAindex1 accession number.
            code2: Second AAindex1 accession number.

        Returns:
            Pearson r as a float in [-1, 1], or ``None`` if there are
            insufficient non-NA values to compute a correlation.

        Raises:
            ValueError: If either accession number is not found in the database.

        Example:
            >>> aaindex1.compare_indices('KYTJ820101', 'EISD840101')
            0.949...
        """
        vals1 = self[code1]['values']
        vals2 = self[code2]['values']

        #collect paired values — both must be non-None and not the gap placeholder
        pairs = [
            (vals1[aa], vals2[aa])
            for aa in vals1
            if aa != '-' and vals1[aa] is not None and vals2.get(aa) is not None
        ]

        n = len(pairs)
        if n < 2:
            return None

        xs = [p[0] for p in pairs]
        ys = [p[1] for p in pairs]

        mean_x = sum(xs) / n
        mean_y = sum(ys) / n

        cov = sum((x - mean_x) * (y - mean_y) for x, y in zip(xs, ys))
        std_x = math.sqrt(sum((x - mean_x) ** 2 for x in xs))
        std_y = math.sqrt(sum((y - mean_y) ** 2 for y in ys))

        if std_x == 0.0 or std_y == 0.0:
            return None

        return cov / (std_x * std_y)

    def to_dict(self, record_code: str | None = None) -> dict:
        """Export one record or the full database as a plain Python dict.

        Args:
            record_code: If given, export only that record keyed by its
                         accession number. If ``None`` (default), export
                         the entire database.

        Returns:
            Dict containing the requested records.

        Raises:
            ValueError: If *record_code* is not found in the database.
        """
        if record_code is not None:
            code = record_code.strip().upper()
            if code not in self.aaindex_json:
                raise ValueError(f"Record ({code}) not found in AAindex1.")
            #B2: deep copy so callers cannot mutate the shared database through the export
            return {code: copy.deepcopy(self.aaindex_json[code])}
        return copy.deepcopy(self.aaindex_json)

    def to_json(self, record_code: str | None = None, indent: int = 4) -> str:
        """Serialise one record or the full database to a JSON string.

        Args:
            record_code: If given, serialise only that record. If ``None``
                         (default), serialise the entire database.
            indent: JSON indentation level. Defaults to ``4``.

        Returns:
            JSON-formatted string.

        Raises:
            ValueError: If *record_code* is not found in the database.
        """
        return json.dumps(self.to_dict(record_code), indent=indent)

    def to_dataframe(
        self,
        record_code: str | None = None,
        include_gap: bool = True,
    ) -> Any:
        """Export amino acid values as a pandas DataFrame.

        Requires ``pandas`` to be installed. If pandas is not available a
        clear ``ImportError`` is raised rather than a silent failure.

        When *record_code* is ``None`` the returned DataFrame has one column
        per amino acid and one row per AAindex1 record (index = accession
        numbers). When a specific record code is given the DataFrame has a
        single row.

        Args:
            record_code: If given, export only that record's values.
                         If ``None`` (default), export all records.
            include_gap: If ``True`` (default), include the ``-`` gap-placeholder
                         column. Set to ``False`` to drop it, which is useful for
                         ML pipelines that expect exactly 20 amino acid columns.

        Returns:
            ``pandas.DataFrame`` with amino acids as columns and record
            accession numbers as the index.

        Raises:
            ImportError: If ``pandas`` is not installed.
            ValueError: If *record_code* is not found in the database.
        """
        try:
            import pandas as pd  # noqa: PLC0415
        except ImportError as exc:
            raise ImportError(
                "pandas is required for to_dataframe(). "
                "Install it with: pip install pandas"
            ) from exc

        if record_code is not None:
            code = record_code.strip().upper()
            records_subset = {code: self[code]['values']}
        else:
            records_subset = {
                code: record['values'] for code, record in self.aaindex_json.items()
            }

        df = pd.DataFrame.from_dict(records_subset, orient='index')
        if not include_gap:
            df = df.drop(columns=['-'], errors='ignore')
        return df

    def plot_distribution(self, record_code: str) -> Any:
        """Plot the amino acid value distribution for a record as a bar chart.

        Requires ``matplotlib`` to be installed. Amino acids with NA values
        (``None``) are excluded from the plot. The gap placeholder ``-`` is
        always excluded.

        Args:
            record_code: AAindex1 accession number.

        Returns:
            ``matplotlib.axes.Axes`` containing the bar chart. The figure can
            be displayed with ``plt.show()`` or saved with ``plt.savefig()``.

        Raises:
            ImportError: If ``matplotlib`` is not installed.
            ValueError: If *record_code* is not found in the database.

        Example:
            >>> ax = aaindex1.plot_distribution('KYTJ820101')
            >>> import matplotlib.pyplot as plt; plt.show()
        """
        try:
            import matplotlib.pyplot as plt  # noqa: PLC0415
        except ImportError as exc:
            raise ImportError(
                "matplotlib is required for plot_distribution(). "
                "Install it with: pip install matplotlib"
            ) from exc

        record = self[record_code]
        vals = {
            aa: v for aa, v in record['values'].items()
            if aa != '-' and v is not None
        }
        labels = sorted(vals)
        heights = [vals[aa] for aa in labels]

        _, ax = plt.subplots()
        ax.bar(labels, heights)
        ax.set_xlabel("Amino Acid")
        ax.set_ylabel("Value")
        ax.set_title(record['description'][:80])
        return ax

    def __getitem__(self, record_code: str) -> "Map":
        """Return a record by accession number wrapped in a Map (dot-notation dict).

        Args:
            record_code: AAindex accession number (case-insensitive,
                         leading/trailing whitespace is stripped).

        Returns:
            Record data as a Map, accessible via dict or dot notation. The record
            is a copy — mutating it does not affect the loaded database.

        Raises:
            TypeError: If record_code is not a string.
            ValueError: If record_code is not found in the database.
        """
        try:
            record_code = record_code.strip().upper()
        except AttributeError:
            raise TypeError(
                f"Input parameter {record_code} is not of correct datatype string, got {type(record_code)}."
            )

        if record_code not in self.aaindex_json:
            close = difflib.get_close_matches(
                record_code, self.aaindex_json.keys(), n=3, cutoff=0.6
            )
            hint = f" Did you mean: {close}?" if close else ""
            raise ValueError(f"Record Index ({record_code}) not found in AAindex1.{hint}")

        #B2: Map only copies top-level keys, so the nested values dict would stay shared with
        #the singleton — deep copy it so record['values'][aa] = x cannot corrupt the database
        return Map(copy.deepcopy(self.aaindex_json[record_code]))

    def __sizeof__(self) -> int:
        """Return the on-disk size of the raw AAindex data file in bytes."""
        return os.path.getsize(
            os.path.join(self.aaindex_module_path, self.data_dir, self.aaindex_filename)
        )

    def __len__(self) -> int:
        """Return total number of records in the database."""
        return len(self.aaindex_json)

    def __contains__(self, record_code: object) -> bool:
        """Return True if record_code exists in the database.

        Normalises case and surrounding whitespace so that ``code in aaindex1``
        agrees with ``aaindex1[code]``.
        """
        if not isinstance(record_code, str):
            return False
        return record_code.strip().upper() in self.aaindex_json

    def __iter__(self) -> Iterator[str]:
        """Iterate over all record codes in the database."""
        return iter(self.aaindex_json)

    def __repr__(self) -> str:
        """Return a canonical string representation of this instance."""
        return f"AAIndex1(records={len(self.aaindex_json)}, last_updated='{self.last_updated}')"

######################          Getters & Setters          ######################

    @property
    def aaindex_json(self) -> dict:
        """Parsed database dict, loaded lazily on first access."""
        if self._aaindex_json is None:
            self._load_data()
        return self._aaindex_json

    @aaindex_json.setter
    def aaindex_json(self, value: dict) -> None:
        self._aaindex_json = value

    @property
    def categories(self) -> dict:
        if self._categories is None:
            self._categories = self.get_all_categories()
        return self._categories

    @categories.setter
    def categories(self, value: dict) -> None:
        self._categories = value

    @property
    def data_dir(self) -> str:
        return self._data_dir

    @data_dir.setter
    def data_dir(self, value: str) -> None:
        if os.path.basename(value) != value:
            raise ValueError(
                f"data_dir must be a simple directory name with no path separators, got: {value!r}"
            )
        self._data_dir = value

    @property
    def aaindex_filename(self) -> str:
        return self._aaindex_filename

    @aaindex_filename.setter
    def aaindex_filename(self, value: str) -> None:
        if os.path.basename(value) != value:
            raise ValueError(
                f"aaindex_filename must be a simple filename with no path separators, got: {value!r}"
            )
        self._aaindex_filename = value

    @property
    def last_updated(self) -> str:
        return self._last_updated

    @last_updated.setter
    def last_updated(self, value: str) -> None:
        self._last_updated = value


#create instance of AAIndex1 class
aaindex1 = AAIndex1()
